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 c1e234a273d..50e063b0c70 100644 --- a/.gitignore +++ b/.gitignore @@ -85,4 +85,6 @@ superset-dev-data/ !.codex/config.toml !.codex/commands !.codex/prompts +.serena/ +test-conflict-repo/ .amp/* diff --git a/README.md b/README.md index 86ef7998d8a..f21df197d25 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,90 @@ 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 | + +## 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/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.vite.config.ts b/apps/desktop/electron.vite.config.ts index 34f323a04f7..1ef8f36fe4c 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 }, }) @@ -178,6 +178,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 72c3b46032c..c91df0e0065 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", @@ -57,6 +57,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", @@ -98,6 +99,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", @@ -156,10 +158,16 @@ "date-fns": "^4.1.0", "default-shell": "^2.2.0", "detect-libc": "2.0.4", + "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.7.3", + "exceljs": "^4.4.0", "execa": "^9.6.0", "express": "^5.1.0", "fast-glob": "^3.3.3", @@ -167,11 +175,14 @@ "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", "http-proxy": "^1.18.1", "idb": "^8.0.3", "idb-keyval": "^6.2.2", "jose": "^6.1.3", + "jszip": "^3.10.1", "libsql": "0.5.22", "line-column-path": "^3.0.0", "lodash": "^4.17.21", @@ -183,11 +194,13 @@ "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", @@ -201,6 +214,7 @@ "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", @@ -217,6 +231,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" }, @@ -229,9 +250,11 @@ "@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/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", @@ -240,7 +263,7 @@ "@vitejs/plugin-react": "^5.0.1", "code-inspector-plugin": "^1.2.2", "cross-env": "^10.0.0", - "electron": "40.2.1", + "electron": "40.8.5", "electron-builder": "^26.4.0", "electron-vite": "^4.0.0", "material-icon-theme": "^5.32.0", diff --git a/apps/desktop/runtime-dependencies.ts b/apps/desktop/runtime-dependencies.ts index f039d9f6fe7..134e1930386 100644 --- a/apps/desktop/runtime-dependencies.ts +++ b/apps/desktop/runtime-dependencies.ts @@ -83,6 +83,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 +121,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/trpc/routers/browser/browser.ts b/apps/desktop/src/lib/trpc/routers/browser/browser.ts index 50681573e85..f2aed8c6bc9 100644 --- a/apps/desktop/src/lib/trpc/routers/browser/browser.ts +++ b/apps/desktop/src/lib/trpc/routers/browser/browser.ts @@ -115,6 +115,19 @@ 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); + }; + }); + }), + onContextMenuAction: publicProcedure .input(z.object({ paneId: z.string() })) .subscription(({ input }) => { @@ -136,6 +149,33 @@ export const createBrowserRouter = () => { return { success: true }; }), + 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 }) => { diff --git a/apps/desktop/src/lib/trpc/routers/changes/branches.ts b/apps/desktop/src/lib/trpc/routers/changes/branches.ts index 8283e4b4e29..10566dc9bf2 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,13 +14,71 @@ 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 { gitCreateBranch, gitSwitchBranch } from "./security/git-commands"; +import { assertRegisteredWorktree } from "./security/path-validation"; import { clearStatusCacheForWorktree } from "./utils/status-cache"; +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({ getBranches: publicProcedure @@ -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(); + 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); clearStatusCacheForWorktree(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); + + clearStatusCacheForWorktree(input.worktreePath); + return { success: true, branch: currentBranch }; + }, + ), + updateBaseBranch: publicProcedure .input( z.object({ @@ -150,11 +320,14 @@ 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); 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-operations.ts b/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts index 73826001d8b..4ce8ad7c9e2 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts @@ -1,4 +1,9 @@ +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 { getCurrentBranch } from "../workspaces/utils/git"; @@ -337,5 +342,228 @@ export const createGitOperationsRouter = () => { } }, ), + + fetchRemote: publicProcedure + .input(z.object({ worktreePath: z.string() })) + .mutation(async ({ input }): Promise<{ success: boolean }> => { + assertRegisteredWorktree(input.worktreePath); + const git = await getGitWithShellPath(input.worktreePath); + await git.fetch(["--prune"]); + clearStatusCacheForWorktree(input.worktreePath); + return { success: true }; + }), + + 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 }; + }), }); }; 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..eaad2fbc5b3 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,28 @@ async function getGitWithShellPath(worktreePath: string) { return getSimpleGitWithShellPath(worktreePath); } +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,22 +72,44 @@ 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"); - } + assertValidBranchName(branch); const git = await getGitWithShellPath(worktreePath); await runWithPostCheckoutHookTolerance({ context: `Switched branch to "${branch}" in ${worktreePath}`, run: async () => { + const localBranches = await git.branchLocal(); + if (localBranches.all.includes(branch)) { + try { + await git.raw(["switch", branch]); + return; + } catch (switchError) { + const errorMessage = String(switchError); + if (errorMessage.includes("is not a git command")) { + await git.checkout(branch); + return; + } + throw switchError; + } + } + + const remoteBranches = await git.branch(["-r"]); + const remoteBranch = `origin/${branch}`; + if (remoteBranches.all.includes(remoteBranch)) { + try { + await git.raw(["switch", "--track", "-c", branch, remoteBranch]); + return; + } catch (switchError) { + const errorMessage = String(switchError); + if (errorMessage.includes("is not a git command")) { + await git.checkout(["-b", branch, "--track", remoteBranch]); + return; + } + throw switchError; + } + } + try { // Prefer `git switch` - unambiguous branch operation (git 2.23+) await git.raw(["switch", branch]); @@ -87,6 +131,49 @@ export async function gitSwitchBranch( }); } +/** + * 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 }), + }); +} + /** * Checkout (restore) a file path, discarding local changes. * diff --git a/apps/desktop/src/lib/trpc/routers/changes/status.ts b/apps/desktop/src/lib/trpc/routers/changes/status.ts index 89b570bd6e9..a29ea9b9893 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"; @@ -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/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..dbd94f3bf69 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 @@ -3,7 +3,7 @@ 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 { getPullRequestRepoNames } from "../../workspaces/utils/github/repo-context"; import { execWithShellEnv } from "../../workspaces/utils/shell-env"; import { buildPullRequestCompareUrl, @@ -24,32 +24,57 @@ async function findOpenPRByHeadCommit( 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 repoNames = getPullRequestRepoNames( + await getRepoContext(worktreePath), ); + const repoArgSets = + repoNames.length > 0 + ? repoNames.map((repoName) => ["--repo", repoName]) + : [[]]; + + for (const repoArgs of repoArgSets) { + try { + 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; + const parsed = JSON.parse(stdout) as Array<{ + url?: string; + headRefOid?: string; + }>; + const match = parsed.find( + (candidate) => candidate.headRefOid === headSha, + ); + if (match?.url?.trim()) { + return match.url.trim(); + } + } catch (error) { + console.warn( + "[git/findExistingOpenPRUrl] Failed repo-scoped commit-based PR lookup:", + { + worktreePath, + repoArgs, + message: error instanceof Error ? error.message : String(error), + }, + ); + } + } + + return null; } catch (error) { const message = error instanceof Error ? error.message : String(error); console.warn( 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..92447405254 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"; @@ -30,6 +35,7 @@ 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"; function logWorkerWarning(message: string, error: unknown): void { @@ -69,6 +75,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); @@ -217,6 +225,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 +260,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 +337,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..ee852f2c500 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/docker/index.ts @@ -0,0 +1,511 @@ +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 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 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), + ); +} + +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 = await findComposeFiles(workspaceRoot); + return { + workspaceRoot, + composeFiles, + }; + }), + + list: publicProcedure + .input(z.object({ workspaceId: z.string() })) + .query(async ({ input }) => { + const workspaceRoot = getWorkspaceRootPath(input.workspaceId); + const composeFiles = await findComposeFiles(workspaceRoot); + + if (composeFiles.length === 0) { + return { + composeFiles: [], + 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 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, + }; + }), + dockerAvailable, + dockerError, + workspaceRoot, + }; + }), + + startProject: publicProcedure + .input(composeActionInput) + .mutation(async ({ input }) => { + const composeFile = await resolveComposeFileForWorkspace( + input.workspaceId, + input.composeFilePath, + ); + + try { + await execDocker( + ["compose", "-f", composeFile.absolutePath, "up", "-d"], + { 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); + } + }), + + 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); + } + }), + }); +}; + +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 4dde4b6e196..d8ced5287ea 100644 --- a/apps/desktop/src/lib/trpc/routers/external/index.ts +++ b/apps/desktop/src/lib/trpc/routers/external/index.ts @@ -21,6 +21,30 @@ const ExternalAppSchema = z.enum(EXTERNAL_APPS); 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 normalizeOpenInAppError(error: unknown): never { + 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; @@ -80,7 +104,10 @@ async function openPathInApp( throw lastError; } - await shell.openPath(filePath); + const openError = await shell.openPath(filePath); + if (openError) { + throw new Error(openError); + } } /** @@ -109,6 +136,18 @@ export const createExternalRouter = () => { shell.showItemInFolder(input); }), + openInDefaultApp: publicProcedure + .input(z.string()) + .mutation(async ({ input }) => { + const openError = await shell.openPath(input); + if (openError) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: openError, + }); + } + }), + openInApp: publicProcedure .input( z.object({ @@ -118,7 +157,11 @@ export const createExternalRouter = () => { }), ) .mutation(async ({ input }) => { - await openPathInApp(input.path, input.app); + try { + await openPathInApp(input.path, input.app); + } catch (error) { + normalizeOpenInAppError(error); + } // Persist defaults only after successful launch if (input.projectId) { @@ -175,11 +218,18 @@ export const createExternalRouter = () => { // 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..539f068d31a 100644 --- a/apps/desktop/src/lib/trpc/routers/filesystem/index.ts +++ b/apps/desktop/src/lib/trpc/routers/filesystem/index.ts @@ -28,6 +28,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 @@ -215,16 +238,7 @@ export const createFilesystemRouter = () => { }), 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) { @@ -238,6 +252,34 @@ export const createFilesystemRouter = () => { 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: [], + }; + } + + 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/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts index 0aa455d02c4..da7f9345042 100644 --- a/apps/desktop/src/lib/trpc/routers/index.ts +++ b/apps/desktop/src/lib/trpc/routers/index.ts @@ -1,4 +1,5 @@ import type { BrowserWindow } from "electron"; +import type { WindowManager } from "main/lib/window-manager"; import { router } from ".."; import { createAnalyticsRouter } from "./analytics"; import { createAuthRouter } from "./auth"; @@ -10,10 +11,15 @@ 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 { createHostServiceManagerRouter } from "./host-service-manager"; import { createHotkeysRouter } from "./hotkeys"; +import { createLanguageServicesRouter } from "./language-services"; import { createMenuRouter } from "./menu"; import { createModelProvidersRouter } from "./model-providers"; import { createNotificationsRouter } from "./notifications"; @@ -23,12 +29,16 @@ import { createProjectsRouter } from "./projects"; import { createResourceMetricsRouter } from "./resource-metrics"; import { createRingtoneRouter } from "./ringtone"; import { createSettingsRouter } from "./settings"; +import { createTabTearoffRouter } from "./tab-tearoff"; import { createTerminalRouter } from "./terminal"; import { createUiStateRouter } from "./ui-state"; 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(), @@ -51,12 +61,18 @@ export const createAppRouter = (getWindow: () => BrowserWindow | null) => { resourceMetrics: createResourceMetricsRouter(), menu: createMenuRouter(), hotkeys: createHotkeysRouter(getWindow), + languageServices: createLanguageServicesRouter(), external: createExternalRouter(), settings: createSettingsRouter(), config: createConfigRouter(), + databases: createDatabasesRouter(), + diagnostics: createDiagnosticsRouter(), + docker: createDockerRouter(), uiState: createUiStateRouter(), ringtone: createRingtoneRouter(getWindow), hostServiceManager: createHostServiceManagerRouter(), + tabTearoff: createTabTearoffRouter(wm), + extensions: createExtensionsRouter(getWindow), }); }; 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..e9985be940d --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/language-services/index.ts @@ -0,0 +1,154 @@ +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(), +}); + +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 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 }; + }), + + 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/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/settings/index.ts b/apps/desktop/src/lib/trpc/routers/settings/index.ts index 9a49802c7a7..4b59b9d706b 100644 --- a/apps/desktop/src/lib/trpc/routers/settings/index.ts +++ b/apps/desktop/src/lib/trpc/routers/settings/index.ts @@ -25,6 +25,7 @@ import { DEFAULT_CONFIRM_ON_QUIT, DEFAULT_FILE_OPEN_MODE, DEFAULT_OPEN_LINKS_IN_APP, + DEFAULT_PREVENT_AGENT_SLEEP, DEFAULT_SHOW_PRESETS_BAR, DEFAULT_SHOW_RESOURCE_MONITOR, DEFAULT_TERMINAL_LINK_BEHAVIOR, @@ -591,6 +592,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 }; + }), + getShowPresetsBar: publicProcedure.query(() => { const row = getSettings(); return row.showPresetsBar ?? DEFAULT_SHOW_PRESETS_BAR; 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..3e1e7a81e1b --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/tab-tearoff.ts @@ -0,0 +1,36 @@ +import type { WindowManager } from "main/lib/window-manager"; +import { z } from "zod"; +import { publicProcedure, router } from ".."; + +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(({ 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, + }); + + 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 60ce5e744f3..4710ed60be8 100644 --- a/apps/desktop/src/lib/trpc/routers/ui-state/index.ts +++ b/apps/desktop/src/lib/trpc/routers/ui-state/index.ts @@ -17,11 +17,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(), @@ -43,7 +43,15 @@ 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", + ]), name: z.string(), isNew: z.boolean().optional(), status: z.enum(["idle", "working", "permission", "review"]).optional(), @@ -86,6 +94,11 @@ const paneSchema = z.object({ targetPaneId: z.string(), }) .optional(), + databaseExplorer: z + .object({ + connectionId: z.string().nullable(), + }) + .optional(), workspaceRun: z .object({ workspaceId: z.string(), 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..7cb48317fdf 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,6 +1,8 @@ -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 { localDb } from "main/lib/local-db"; import { z } from "zod"; @@ -12,28 +14,259 @@ import { updateProjectDefaultBranch, } from "../utils/db-helpers"; import { + branchExistsOnRemote, fetchDefaultBranch, getAheadBehindCount, + getCurrentBranch, getDefaultBranch, listExternalWorktrees, refreshDefaultBranch, } from "../utils/git"; import { clearGitHubCachesForWorktree, + extractNwoFromUrl, + fetchCheckJobSteps, fetchGitHubPRComments, fetchGitHubPRStatus, + getRepoContext, type PullRequestCommentsTarget, - resolveReviewThread, } from "../utils/github"; +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(), +}); + +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; +} + +function workflowSupportsDispatch({ + repoPath, + workflowPath, +}: { + repoPath: string; + workflowPath?: string; +}): boolean { + if (!workflowPath) { + return false; + } + + const absolutePath = path.join(repoPath, workflowPath); + if (!existsSync(absolutePath)) { + return false; + } + + try { + const content = readFileSync(absolutePath, "utf8"); + return ( + /^\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) + ); + } catch { + return false; + } +} + function resolveCommentsPullRequestTarget({ input, githubStatus, @@ -59,6 +292,7 @@ function resolveCommentsPullRequestTarget({ return { prNumber, + prUrl: input.prUrl ?? githubStatus?.pr?.url, repoContext: { repoUrl, upstreamUrl, @@ -83,7 +317,7 @@ function hasMeaningfulGitHubStatusChange({ next, }: { current: GitHubStatus | null | undefined; - next: GitHubStatus; + next: GitHubStatus | null; }): boolean { return ( JSON.stringify(stripGitHubStatusTimestamp(current)) !== @@ -91,6 +325,733 @@ 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 [pullRequestsResult, workflowsResult, labelsResult, assigneesResult] = + await Promise.all([ + execWithShellEnv( + "gh", + [ + "pr", + "list", + "--repo", + repositoryNameWithOwner, + "--state", + "open", + "--limit", + "8", + "--json", + "number,title,url,state,isDraft,headRefName,updatedAt,author", + ], + { cwd: repoPath }, + ), + execWithShellEnv( + "gh", + [ + "api", + `repos/${repositoryNameWithOwner}/actions/workflows?per_page=100`, + ], + { cwd: repoPath }, + ), + execWithShellEnv( + "gh", + ["api", `repos/${repositoryNameWithOwner}/labels?per_page=100`], + { cwd: repoPath }, + ), + execWithShellEnv( + "gh", + ["api", `repos/${repositoryNameWithOwner}/assignees?per_page=100`], + { cwd: repoPath }, + ), + ]); + + const rawPullRequests = JSON.parse(pullRequestsResult.stdout) as unknown; + const pullRequests = z + .array(ghRepositoryPullRequestSchema) + .parse(rawPullRequests); + + const rawWorkflows = JSON.parse(workflowsResult.stdout) as unknown; + const workflows = + ghRepositoryWorkflowsResponseSchema.parse(rawWorkflows).workflows ?? []; + const rawLabels = JSON.parse(labelsResult.stdout) as unknown; + const labels = z.array(ghRepositoryLabelSchema).parse(rawLabels); + const rawAssignees = JSON.parse(assigneesResult.stdout) as unknown; + const assignees = z.array(ghRepositoryAssigneeSchema).parse(rawAssignees); + + 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") + .filter((workflow) => + workflowSupportsDispatch({ + repoPath, + workflowPath: workflow.path, + }), + ) + .map((workflow) => ({ + id: workflow.id, + name: workflow.name, + path: workflow.path ?? "", + state: workflow.state ?? "unknown", + })), + }; +} + +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: `![${basename}](${assetUrl})`, + }; +} + +async function dispatchGitHubWorkflowForWorkspace({ + workspaceId, + workflowId, + ref, +}: { + workspaceId: string; + workflowId: number; + ref?: string; +}) { + 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; + } + } + + await execWithShellEnv( + "gh", + [ + "api", + "--method", + "POST", + `repos/${repositoryNameWithOwner}/actions/workflows/${workflowId}/dispatches`, + "-f", + `ref=${targetRef}`, + ], + { 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 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, + })); +} + export const createGitStatusProcedures = () => { return router({ refreshGitStatus: publicProcedure @@ -175,7 +1136,12 @@ export const createGitStatusProcedures = () => { }), getGitHubStatus: publicProcedure - .input(z.object({ workspaceId: z.string() })) + .input( + z.object({ + workspaceId: z.string(), + forceFresh: z.boolean().optional(), + }), + ) .query(async ({ input }) => { const workspace = getWorkspace(input.workspaceId); if (!workspace) { @@ -185,14 +1151,26 @@ 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); + } + + const freshStatus = await fetchGitHubPRStatus(repoPath); if ( - freshStatus && + worktree && hasMeaningfulGitHubStatusChange({ current: worktree.githubStatus, next: freshStatus, @@ -219,14 +1197,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 +1222,235 @@ 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(), + }), + ) + .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); + }), + + 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 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(); } - await resolveReviewThread({ - worktreePath: worktree.path, - threadId: input.threadId, - resolve: input.resolve, + 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 +1541,34 @@ 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); + }), }); }; 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/github/cache.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/cache.ts index adda1913d44..e551d18573b 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 @@ -9,10 +9,12 @@ import type { RepoContext } from "./types"; const GITHUB_STATUS_CACHE_TTL_MS = 10_000; const GITHUB_PR_COMMENTS_CACHE_TTL_MS = 30_000; const GITHUB_REPO_CONTEXT_CACHE_TTL_MS = 300_000; +const GITHUB_COMMIT_AUTHOR_CACHE_TTL_MS = 300_000; const MAX_GITHUB_STATUS_CACHE_ENTRIES = 256; const MAX_GITHUB_PR_COMMENTS_CACHE_ENTRIES = 512; const MAX_GITHUB_REPO_CONTEXT_CACHE_ENTRIES = 256; +const MAX_GITHUB_COMMIT_AUTHOR_CACHE_ENTRIES = 2048; const githubStatusResource = createCachedResource({ ttlMs: GITHUB_STATUS_CACHE_TTL_MS, @@ -29,6 +31,16 @@ const repoContextResource = createCachedResource({ maxEntries: MAX_GITHUB_REPO_CONTEXT_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 { @@ -132,6 +144,24 @@ 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); 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..b69b75c0b37 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 @@ -100,6 +100,56 @@ 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 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"); + } +} + function getReviewThreadCommentId( comment: ReviewThreadCommentNode, ): string | null { @@ -135,10 +185,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 +257,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,56 +311,6 @@ 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, @@ -511,7 +511,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.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts index 66ff14f3cf1..7c1bd9782bb 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 @@ -20,12 +20,14 @@ 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 }; @@ -33,6 +35,13 @@ export { clearGitHubCachesForWorktree, 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; @@ -74,6 +83,7 @@ async function resolvePullRequestCommentsTarget( return { prNumber: prInfo.number, repoContext, + prUrl: prInfo.url, }; } @@ -374,3 +384,74 @@ async function fetchPreviewDeploymentUrl( return undefined; } } + +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 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 []; + } +} 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..6589eec35bb 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,6 +1,7 @@ export type { PullRequestCommentsTarget } from "./github"; export { clearGitHubCachesForWorktree, + fetchCheckJobSteps, fetchGitHubPRComments, fetchGitHubPRStatus, resolveReviewThread, 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..6564215c211 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,7 @@ import type { CheckItem, GitHubStatus } from "@superset/local-db"; import { execGitWithShellPath } from "../git-client"; import { execWithShellEnv } from "../shell-env"; -import { getPullRequestRepoArgs } from "./repo-context"; +import { getPullRequestRepoNames } from "./repo-context"; import { type GHPRResponse, GHPRResponseSchema, @@ -9,7 +9,17 @@ 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(repoContext?: RepoContext): string[][] { + const repoNames = getPullRequestRepoNames(repoContext); + + if (repoNames.length === 0) { + return [[]]; + } + + return repoNames.map((repoName) => ["--repo", repoName]); +} export async function getPRForBranch( worktreePath: string, @@ -213,29 +223,46 @@ async function findPRByHeadBranch( ): Promise { try { const matches = new Map(); + const repoArgSets = getPullRequestRepoArgSets(repoContext); + + for (const repoArgs of repoArgSets) { + for (const branchCandidate of getPRHeadBranchCandidates(localBranch)) { + let stdout: string; + try { + ({ stdout } = await execWithShellEnv( + "gh", + [ + "pr", + "list", + ...repoArgs, + "--state", + "all", + "--head", + branchCandidate, + "--limit", + "20", + "--json", + PR_JSON_FIELDS, + ], + { cwd: worktreePath }, + )); + } catch (error) { + 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); + } } } } @@ -269,28 +296,46 @@ async function findPRByHeadCommit( return null; } - 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[] = []; + for (const repoArgs of getPullRequestRepoArgSets(repoContext)) { + let stdout: string; + try { + ({ stdout } = await execWithShellEnv( + "gh", + [ + "pr", + "list", + ...repoArgs, + "--state", + "all", + "--search", + `${headSha} is:pr`, + "--limit", + "20", + "--json", + PR_JSON_FIELDS, + ], + { cwd: worktreePath }, + )); + } catch (error) { + 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); + exactHeadMatches.push( + ...candidates.filter((candidate) => candidate.headRefOid === headSha), + ); + } - const candidates = parsePRListResponse(stdout); - const exactHeadMatches = candidates.filter( - (candidate) => candidate.headRefOid === headSha, - ); const bestMatch = sortPRCandidates(exactHeadMatches, headSha)[0]; if (bestMatch) { return formatPRData(bestMatch); @@ -375,6 +420,7 @@ function formatPRData(data: GHPRResponse): NonNullable { checksStatus: computeChecksStatus(data.statusCheckRollup), checks: parseChecks(data.statusCheckRollup), requestedReviewers: parseReviewRequests(data.reviewRequests), + assignees: parseAssignees(data.assignees), }; } @@ -385,6 +431,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..13f705c2452 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 @@ -139,13 +139,39 @@ 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 function getPullRequestRepoArgs( repoContext?: Pick | null, ): string[] { diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/types.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/types.ts index 7f379fcffc9..391a16115da 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/types.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/types.ts @@ -32,6 +32,15 @@ export const GHReviewRequestSchema = z.object({ type: z.enum(["User", "Team"]).optional(), }); +export const GHUserSchema = z.object({ + login: z.string().optional(), +}); + +export const GHIdentityCandidateUserSchema = z.object({ + login: z.string().optional(), + avatarUrl: z.string().optional(), +}); + export const GHCommentAuthorSchema = z.object({ login: z.string().optional(), avatar_url: z.string().optional(), @@ -78,6 +87,21 @@ export const GHPageInfoSchema = z.object({ endCursor: z.string().nullable(), }); +export const GHUsersConnectionSchema = z.object({ + nodes: z.array(GHIdentityCandidateUserSchema.nullable()).optional(), + pageInfo: GHPageInfoSchema, +}); + +export const GHIdentityCandidatesResponseSchema = z.object({ + data: z.object({ + repository: z + .object({ + users: GHUsersConnectionSchema, + }) + .nullable(), + }), +}); + export const GHReviewThreadCommentsConnectionSchema = z.object({ nodes: z.array(GHReviewThreadCommentSchema.nullable()).optional(), pageInfo: GHPageInfoSchema, @@ -155,6 +179,7 @@ export const GHPRResponseSchema = z.object({ statusCheckRollup: z.array(GHCheckContextSchema).nullable(), comments: z.array(GHCommentSchema).nullable().optional(), reviewRequests: z.array(GHReviewRequestSchema).nullable().optional(), + assignees: z.array(GHUserSchema).nullable().optional(), }); export const GHRepoResponseSchema = z.object({ @@ -171,6 +196,38 @@ export interface RepoContext { export type GHPRResponse = z.infer; +/** + * GitHub Actions job step schema + */ +export const GHJobStepSchema = z.object({ + name: z.string(), + status: z.enum(["queued", "in_progress", "completed"]), + conclusion: z + .enum(["success", "failure", "cancelled", "skipped", ""]) + .nullable() + .optional(), + number: z.number(), + started_at: z.string().nullable().optional(), + completed_at: z.string().nullable().optional(), +}); + +export type GHJobStep = z.infer; + +export const GHJobResponseSchema = z.object({ + id: z.number(), + name: z.string(), + status: z.enum(["queued", "in_progress", "completed", "waiting"]), + conclusion: z + .enum(["success", "failure", "cancelled", "skipped", "timed_out", ""]) + .nullable() + .optional(), + started_at: z.string().nullable().optional(), + completed_at: z.string().nullable().optional(), + steps: z.array(GHJobStepSchema).optional(), +}); + +export type GHJobResponse = z.infer; + export const GHDeploymentSchema = z.object({ id: z.number(), ref: z.string(), diff --git a/apps/desktop/src/lib/window-loader.ts b/apps/desktop/src/lib/window-loader.ts index d31c07dde09..1b01eb63c66 100644 --- a/apps/desktop/src/lib/window-loader.ts +++ b/apps/desktop/src/lib/window-loader.ts @@ -2,7 +2,7 @@ import type { BrowserWindow } from "electron"; import { env } from "shared/env.shared"; /** Window IDs defined in the router configuration */ -type WindowId = "main" | "about"; +type WindowId = "main" | "about" | "tearoff"; /** * Load an Electron window with the appropriate URL for TanStack Router. diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 8e41a9236c7..97f87619d92 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; -import { settings } from "@superset/local-db"; +import { projects, settings, workspaces } from "@superset/local-db"; +import { desc, eq, isNull } from "drizzle-orm"; import { app, BrowserWindow, @@ -28,8 +29,10 @@ import { setupAutoUpdater } from "./lib/auto-updater"; import { resolveDevWorkspaceName } from "./lib/dev-workspace-name"; import { setWorkspaceDockIcon } from "./lib/dock-icon"; import { loadWebviewBrowserExtension } from "./lib/extensions"; +import { createExtensionIconProtocolHandler } from "./lib/extensions/extension-icon-protocol"; +import { loadInstalledExtensions } from "./lib/extensions/extension-manager"; import { getHostServiceManager } from "./lib/host-service-manager"; -import { localDb } from "./lib/local-db"; +import { closeLocalDb, localDb } from "./lib/local-db"; import { ensureProjectIconsDir, getProjectIconPath } from "./lib/project-icons"; import { initSentry } from "./lib/sentry"; import { @@ -65,6 +68,144 @@ if (process.defaultApp) { app.setAsDefaultProtocolClient(PROTOCOL_SCHEME); } +function normalizeRepoValue( + value: string, +): { owner: string | null; repo: string } | null { + const trimmed = value.trim(); + if (!trimmed) return null; + + let candidate = trimmed.replace(/\.git$/i, ""); + + if (/^https?:\/\//i.test(candidate)) { + try { + const url = new URL(candidate); + candidate = url.pathname.replace(/^\/+/, "").replace(/\/+$/, ""); + } catch { + return null; + } + } + + candidate = candidate.replace(/^github\.com[/:]/i, ""); + const parts = candidate + .split("/") + .map((part) => part.trim()) + .filter(Boolean); + + if (parts.length >= 2) { + return { + owner: parts[parts.length - 2].toLowerCase(), + repo: parts[parts.length - 1].toLowerCase(), + }; + } + + if (parts.length === 1) { + return { + owner: null, + repo: parts[0].toLowerCase(), + }; + } + + return null; +} + +function normalizeOptionalPositiveInt(value: string | null): string | null { + if (!value) return null; + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) return null; + return String(parsed); +} + +function resolveWorkspaceOpenRouteFromDeepLink(url: URL): string | null { + const repoParam = url.searchParams.get("repo"); + const fileParam = url.searchParams.get("file"); + const branchParam = url.searchParams.get("branch")?.trim() || null; + const normalizedRepo = repoParam ? normalizeRepoValue(repoParam) : null; + + if (!normalizedRepo) { + return null; + } + + const candidates = localDb + .select({ + workspaceId: workspaces.id, + workspaceBranch: workspaces.branch, + lastOpenedAt: workspaces.lastOpenedAt, + projectGithubOwner: projects.githubOwner, + projectMainRepoPath: projects.mainRepoPath, + }) + .from(workspaces) + .innerJoin(projects, eq(workspaces.projectId, projects.id)) + .where(isNull(workspaces.deletingAt)) + .orderBy(desc(workspaces.lastOpenedAt)) + .all() + .filter((row) => { + const repoName = path.basename(row.projectMainRepoPath).toLowerCase(); + if (repoName !== normalizedRepo.repo) { + return false; + } + + if (!normalizedRepo.owner) { + return true; + } + + return ( + (row.projectGithubOwner ?? "").toLowerCase() === normalizedRepo.owner + ); + }); + + if (candidates.length === 0) { + return null; + } + + const match = + (branchParam + ? candidates.find( + (candidate) => candidate.workspaceBranch === branchParam, + ) + : null) ?? candidates[0]; + + if (!match) { + return null; + } + + const params = new URLSearchParams(); + if (fileParam?.trim()) { + params.set("file", fileParam.trim()); + } + + const line = normalizeOptionalPositiveInt(url.searchParams.get("line")); + if (line) { + params.set("line", line); + } + + const column = normalizeOptionalPositiveInt(url.searchParams.get("column")); + if (column) { + params.set("column", column); + } + + const search = params.toString(); + return `/workspace/${match.workspaceId}${search ? `?${search}` : ""}`; +} + +function getRendererPathFromDeepLink(urlString: string): string | null { + let parsed: URL; + try { + parsed = new URL(urlString); + } catch { + return null; + } + + if (parsed.hostname === "open") { + return resolveWorkspaceOpenRouteFromDeepLink(parsed) ?? "/workspace"; + } + + const host = parsed.hostname ? `/${parsed.hostname}` : ""; + const routePath = parsed.pathname === "/" ? "" : parsed.pathname; + const search = parsed.search || ""; + const hash = parsed.hash || ""; + return `${host}${routePath}${search}${hash}` || "/"; +} + async function processDeepLink(url: string): Promise { console.log("[main] Processing deep link:", url); @@ -79,9 +220,12 @@ async function processDeepLink(url: string): Promise { return; } - // Non-auth deep links: extract path and navigate in renderer - // e.g. superset://tasks/my-slug -> /tasks/my-slug - const path = `/${url.split("://")[1]}`; + const path = getRendererPathFromDeepLink(url); + if (!path) { + console.error("[main] Failed to resolve deep link route:", url); + return; + } + focusMainWindow(); const windows = BrowserWindow.getAllWindows(); @@ -197,6 +341,7 @@ app.on("before-quit", async (event) => { // Quit confirmed or no confirmation needed - exit immediately // Let OS clean up child processes, tray, etc. isQuitting = true; + closeLocalDb(); getHostServiceManager().stopAll(); disposeTray(); app.exit(0); @@ -262,6 +407,15 @@ protocol.registerSchemesAsPrivileged([ supportFetchAPI: true, }, }, + { + scheme: "superset-ext-icon", + privileges: { + standard: true, + secure: true, + bypassCSP: true, + supportFetchAPI: true, + }, + }, ]); const gotTheLock = app.requestSingleInstanceLock(); @@ -328,12 +482,20 @@ if (!gotTheLock) { .protocol.handle("superset-font", fontProtocolHandler); } + // Serve extension icons via custom protocol + const extIconHandler = createExtensionIconProtocolHandler(); + protocol.handle("superset-ext-icon", extIconHandler); + session + .fromPartition("persist:superset") + .protocol.handle("superset-ext-icon", extIconHandler); + ensureProjectIconsDir(); setWorkspaceDockIcon(); initSentry(); await initAppState(); await loadWebviewBrowserExtension(); + await loadInstalledExtensions(); // Must happen before renderer restore runs await reconcileDaemonSessions(); diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-common.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-common.ts index 55a2f14ab8a..81026819804 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-common.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-common.ts @@ -120,10 +120,75 @@ if [ -z "$REAL_BIN" ]; then exit 127 fi +export SUPERSET_WRAPPER_PID="$$" + ${execLine} `; } +export function getSleepInhibitorShellSnippet(): string { + return `_superset_manage_sleep_inhibitor() { + [ -n "$SUPERSET_WRAPPER_PID" ] || return 0 + [ "$SUPERSET_PREVENT_AGENT_SLEEP" = "1" ] || return 0 + + _superset_platform="$(uname -s 2>/dev/null)" + case "$_superset_platform" in + Darwin) + command -v caffeinate >/dev/null 2>&1 || return 0 + ;; + Linux) + command -v systemd-inhibit >/dev/null 2>&1 || return 0 + ;; + *) + return 0 + ;; + esac + + _superset_sleep_dir="\${TMPDIR:-/tmp}/superset-sleep-inhibitors" + mkdir -p "$_superset_sleep_dir" >/dev/null 2>&1 || return 0 + _superset_pid_file="$_superset_sleep_dir/\${SUPERSET_WRAPPER_PID}.pid" + + case "$EVENT_TYPE" in + Start|PermissionRequest) + if [ -f "$_superset_pid_file" ]; then + _superset_inhibitor_pid=$(cat "$_superset_pid_file" 2>/dev/null) + if [ -n "$_superset_inhibitor_pid" ] && kill -0 "$_superset_inhibitor_pid" 2>/dev/null; then + return 0 + fi + rm -f "$_superset_pid_file" >/dev/null 2>&1 || true + fi + + kill -0 "$SUPERSET_WRAPPER_PID" 2>/dev/null || return 0 + + case "$_superset_platform" in + Darwin) + caffeinate -i -w "$SUPERSET_WRAPPER_PID" >/dev/null 2>&1 & + ;; + Linux) + systemd-inhibit --what=idle:sleep --who="Superset" --why="Agent task in progress" \\ + /bin/sh -c 'wrapper_pid="$1"; while kill -0 "$wrapper_pid" 2>/dev/null; do sleep 15; done' \\ + _ "$SUPERSET_WRAPPER_PID" >/dev/null 2>&1 & + ;; + esac + + echo "$!" > "$_superset_pid_file" + ;; + Stop) + if [ -f "$_superset_pid_file" ]; then + _superset_inhibitor_pid=$(cat "$_superset_pid_file" 2>/dev/null) + if [ -n "$_superset_inhibitor_pid" ] && kill -0 "$_superset_inhibitor_pid" 2>/dev/null; then + kill "$_superset_inhibitor_pid" >/dev/null 2>&1 || true + fi + rm -f "$_superset_pid_file" >/dev/null 2>&1 || true + fi + ;; + esac +} + +_superset_manage_sleep_inhibitor +`; +} + export function createWrapper(binaryName: string, script: string): void { const changed = writeFileIfChanged(getWrapperPath(binaryName), script, 0o755); console.log( diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-copilot.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-copilot.ts index 4aba1cec9ac..fa2d92237be 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-copilot.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-copilot.ts @@ -4,6 +4,7 @@ import { env } from "shared/env.shared"; import { buildWrapperScript, createWrapper, + getSleepInhibitorShellSnippet, writeFileIfChanged, } from "./agent-wrappers-common"; import { HOOKS_DIR } from "./paths"; @@ -28,6 +29,7 @@ export function getCopilotHookScriptContent(): string { const template = fs.readFileSync(COPILOT_HOOK_TEMPLATE_PATH, "utf-8"); return template .replace("{{MARKER}}", COPILOT_HOOK_MARKER) + .replace("{{SLEEP_INHIBITOR_SNIPPET}}", getSleepInhibitorShellSnippet()) .replace(/\{\{DEFAULT_PORT\}\}/g, String(env.DESKTOP_NOTIFICATIONS_PORT)); } diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-cursor.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-cursor.ts index f5b8580bd56..2bbc25af7a4 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-cursor.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-cursor.ts @@ -5,6 +5,7 @@ import { env } from "shared/env.shared"; import { buildWrapperScript, createWrapper, + getSleepInhibitorShellSnippet, isSupersetManagedHookCommand, reconcileManagedEntries, writeFileIfChanged, @@ -46,6 +47,7 @@ export function getCursorHookScriptContent(): string { const template = fs.readFileSync(CURSOR_HOOK_TEMPLATE_PATH, "utf-8"); return template .replace("{{MARKER}}", CURSOR_HOOK_MARKER) + .replace("{{SLEEP_INHIBITOR_SNIPPET}}", getSleepInhibitorShellSnippet()) .replace(/\{\{DEFAULT_PORT\}\}/g, String(env.DESKTOP_NOTIFICATIONS_PORT)); } diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-gemini.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-gemini.ts index 8e3b7efa282..123ba266e75 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-gemini.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-gemini.ts @@ -5,6 +5,7 @@ import { env } from "shared/env.shared"; import { buildWrapperScript, createWrapper, + getSleepInhibitorShellSnippet, isSupersetManagedHookCommand, reconcileManagedEntries, writeFileIfChanged, @@ -52,6 +53,7 @@ export function getGeminiHookScriptContent(): string { const template = fs.readFileSync(GEMINI_HOOK_TEMPLATE_PATH, "utf-8"); return template .replace("{{MARKER}}", GEMINI_HOOK_MARKER) + .replace("{{SLEEP_INHIBITOR_SNIPPET}}", getSleepInhibitorShellSnippet()) .replace(/\{\{DEFAULT_PORT\}\}/g, String(env.DESKTOP_NOTIFICATIONS_PORT)); } diff --git a/apps/desktop/src/main/lib/agent-setup/notify-hook.ts b/apps/desktop/src/main/lib/agent-setup/notify-hook.ts index 19968b1385d..056833fe5bb 100644 --- a/apps/desktop/src/main/lib/agent-setup/notify-hook.ts +++ b/apps/desktop/src/main/lib/agent-setup/notify-hook.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { env } from "shared/env.shared"; +import { getSleepInhibitorShellSnippet } from "./agent-wrappers-common"; import { HOOKS_DIR } from "./paths"; export const NOTIFY_SCRIPT_NAME = "notify.sh"; @@ -41,6 +42,7 @@ export function getNotifyScriptContent(): string { const template = fs.readFileSync(NOTIFY_SCRIPT_TEMPLATE_PATH, "utf-8"); return template .replaceAll("{{MARKER}}", NOTIFY_SCRIPT_MARKER) + .replace("{{SLEEP_INHIBITOR_SNIPPET}}", getSleepInhibitorShellSnippet()) .replaceAll("{{DEFAULT_PORT}}", String(env.DESKTOP_NOTIFICATIONS_PORT)); } diff --git a/apps/desktop/src/main/lib/agent-setup/shell-wrappers.test.ts b/apps/desktop/src/main/lib/agent-setup/shell-wrappers.test.ts index 3baa8ab0d64..ef7be4dfb2c 100644 --- a/apps/desktop/src/main/lib/agent-setup/shell-wrappers.test.ts +++ b/apps/desktop/src/main/lib/agent-setup/shell-wrappers.test.ts @@ -839,7 +839,7 @@ export SUPERSET_WORKSPACE_PATH="/wrong/path" expect(args).toEqual([ "-l", "--init-command", - `set -l _superset_bin "${TEST_BIN_DIR}"; contains -- "$_superset_bin" $PATH; or set -gx PATH "$_superset_bin" $PATH; function _superset_shell_ready --on-event fish_prompt; printf '\\033]777;superset-shell-ready\\007'; functions -e _superset_shell_ready; end`, + `set -l _superset_bin "${TEST_BIN_DIR}"; contains -- "$_superset_bin" $PATH; or set -gx PATH "$_superset_bin" $PATH; function _superset_shell_ready --on-event fish_prompt; printf '\\033]777;superset-shell-ready\\007'; functions -e _superset_shell_ready; end; function _superset_prompt_marker --on-event fish_prompt; printf '\\033]777;superset-prompt\\007'; end`, ]); }); @@ -853,7 +853,7 @@ export SUPERSET_WORKSPACE_PATH="/wrong/path" expect(args).toEqual([ "-l", "--init-command", - `set -l _superset_bin "/tmp/with space/quote\\"buck\\$slash\\\\bin"; contains -- "$_superset_bin" $PATH; or set -gx PATH "$_superset_bin" $PATH; function _superset_shell_ready --on-event fish_prompt; printf '\\033]777;superset-shell-ready\\007'; functions -e _superset_shell_ready; end`, + `set -l _superset_bin "/tmp/with space/quote\\"buck\\$slash\\\\bin"; contains -- "$_superset_bin" $PATH; or set -gx PATH "$_superset_bin" $PATH; function _superset_shell_ready --on-event fish_prompt; printf '\\033]777;superset-shell-ready\\007'; functions -e _superset_shell_ready; end; function _superset_prompt_marker --on-event fish_prompt; printf '\\033]777;superset-prompt\\007'; end`, ]); }); }); diff --git a/apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts b/apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts index a38d404c3eb..137414350be 100644 --- a/apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts +++ b/apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts @@ -194,6 +194,16 @@ _superset_home="\${SUPERSET_ORIG_ZDOTDIR:-$HOME}" export ZDOTDIR="$_superset_home" [[ -f "$_superset_home/.zshrc" ]] && source "$_superset_home/.zshrc" ${SUPERSET_ENV_RESTORE} +# Disable zsh-autosuggestions to avoid conflict with Superset's built-in +# terminal suggestions. Uses the plugin's own disable function which +# properly restores original ZLE widget bindings. Only affects this +# terminal session; the user's .zshrc is not modified. +if [[ -n "$SUPERSET_DISABLE_ZSH_AUTOSUGGEST" ]]; then + if (( $+functions[_zsh_autosuggest_disable] )); then + _zsh_autosuggest_disable + fi + add-zsh-hook -d precmd _zsh_autosuggest_start 2>/dev/null +fi ${buildPathPrependFunction(paths.BIN_DIR)} ${buildZshPrecmdHook(paths.BIN_DIR)} rehash 2>/dev/null || true @@ -227,6 +237,14 @@ _superset_shell_ready() { } # Keep our hook LAST so it fires after direnv and other precmd hooks complete. precmd_functions=(\${precmd_functions[@]} _superset_shell_ready) +# Persistent precmd: emit prompt marker every time the shell shows a prompt. +# Used by the suggestion system to detect "user is at shell prompt" vs +# "a command/TUI is running". Unlike _superset_shell_ready (one-shot), +# this fires on EVERY prompt. +_superset_prompt_marker() { + printf '\\033]777;superset-prompt\\007' +} +precmd_functions=(\${precmd_functions[@]} _superset_prompt_marker) export ZDOTDIR="$_superset_home" `; const wroteZlogin = writeFileIfChanged(zloginPath, zloginScript, 0o644); @@ -297,6 +315,15 @@ else PROMPT_COMMAND="_superset_shell_ready" fi fi +# Persistent prompt marker (fires every prompt, not one-shot). +_superset_prompt_marker() { + printf '\\033]777;superset-prompt\\007' +} +if [[ "$(declare -p PROMPT_COMMAND 2>/dev/null)" == "declare -a"* ]]; then + PROMPT_COMMAND=("\${PROMPT_COMMAND[@]}" "_superset_prompt_marker") +else + PROMPT_COMMAND="\${PROMPT_COMMAND};_superset_prompt_marker" +fi `; const changed = writeFileIfChanged(rcfilePath, script, 0o644); console.log(`[agent-setup] ${changed ? "Updated" : "Verified"} bash wrapper`); @@ -332,7 +359,7 @@ export function getShellArgs( return [ "-l", "--init-command", - `set -l _superset_bin "${escapedBinDir}"; contains -- "$_superset_bin" $PATH; or set -gx PATH "$_superset_bin" $PATH; function _superset_shell_ready --on-event fish_prompt; printf '\\033]777;superset-shell-ready\\007'; functions -e _superset_shell_ready; end`, + `set -l _superset_bin "${escapedBinDir}"; contains -- "$_superset_bin" $PATH; or set -gx PATH "$_superset_bin" $PATH; function _superset_shell_ready --on-event fish_prompt; printf '\\033]777;superset-shell-ready\\007'; functions -e _superset_shell_ready; end; function _superset_prompt_marker --on-event fish_prompt; printf '\\033]777;superset-prompt\\007'; end`, ]; } if (["zsh", "sh", "ksh"].includes(shellName)) { diff --git a/apps/desktop/src/main/lib/agent-setup/templates/copilot-hook.template.sh b/apps/desktop/src/main/lib/agent-setup/templates/copilot-hook.template.sh index cdfb1581084..cda1f05e9d8 100644 --- a/apps/desktop/src/main/lib/agent-setup/templates/copilot-hook.template.sh +++ b/apps/desktop/src/main/lib/agent-setup/templates/copilot-hook.template.sh @@ -29,6 +29,8 @@ printf '{}\n' [ -z "$SUPERSET_TAB_ID" ] && exit 0 +{{SLEEP_INHIBITOR_SNIPPET}} + curl -sG "http://127.0.0.1:${SUPERSET_PORT:-{{DEFAULT_PORT}}}/hook/complete" \ --connect-timeout 1 --max-time 2 \ --data-urlencode "paneId=$SUPERSET_PANE_ID" \ diff --git a/apps/desktop/src/main/lib/agent-setup/templates/cursor-hook.template.sh b/apps/desktop/src/main/lib/agent-setup/templates/cursor-hook.template.sh index f2e2483ffa9..dc13f64b278 100644 --- a/apps/desktop/src/main/lib/agent-setup/templates/cursor-hook.template.sh +++ b/apps/desktop/src/main/lib/agent-setup/templates/cursor-hook.template.sh @@ -27,6 +27,8 @@ fi # cursor-agent runs inside a Superset terminal, so env vars are inherited directly [ -z "$SUPERSET_TAB_ID" ] && exit 0 +{{SLEEP_INHIBITOR_SNIPPET}} + curl -sG "http://127.0.0.1:${SUPERSET_PORT:-{{DEFAULT_PORT}}}/hook/complete" \ --connect-timeout 1 --max-time 2 \ --data-urlencode "paneId=$SUPERSET_PANE_ID" \ diff --git a/apps/desktop/src/main/lib/agent-setup/templates/gemini-hook.template.sh b/apps/desktop/src/main/lib/agent-setup/templates/gemini-hook.template.sh index a54e780c99a..53b25e1ef06 100644 --- a/apps/desktop/src/main/lib/agent-setup/templates/gemini-hook.template.sh +++ b/apps/desktop/src/main/lib/agent-setup/templates/gemini-hook.template.sh @@ -28,6 +28,8 @@ printf '{}\n' # Skip notification if not inside a Superset terminal [ -z "$SUPERSET_TAB_ID" ] && exit 0 +{{SLEEP_INHIBITOR_SNIPPET}} + curl -sG "http://127.0.0.1:${SUPERSET_PORT:-{{DEFAULT_PORT}}}/hook/complete" \ --connect-timeout 1 --max-time 2 \ --data-urlencode "paneId=$SUPERSET_PANE_ID" \ diff --git a/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh b/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh index 925702abf4b..13de189926f 100644 --- a/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh +++ b/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh @@ -53,6 +53,8 @@ fi # This prevents parse failures from causing false completion notifications [ -z "$EVENT_TYPE" ] && exit 0 +{{SLEEP_INHIBITOR_SNIPPET}} + DEBUG_HOOKS_ENABLED="0" if [ -n "$SUPERSET_DEBUG_HOOKS" ]; then case "$SUPERSET_DEBUG_HOOKS" in @@ -68,7 +70,7 @@ elif [ "$SUPERSET_ENV" = "development" ] || [ "$NODE_ENV" = "development" ]; the fi if [ "$DEBUG_HOOKS_ENABLED" = "1" ]; then - echo "[notify-hook] event=$EVENT_TYPE sessionId=$SESSION_ID hookSessionId=$HOOK_SESSION_ID resourceId=$RESOURCE_ID paneId=$SUPERSET_PANE_ID tabId=$SUPERSET_TAB_ID workspaceId=$SUPERSET_WORKSPACE_ID" >&2 + echo "[notify-hook] event=$EVENT_TYPE sessionId=$SESSION_ID hookSessionId=$HOOK_SESSION_ID resourceId=$RESOURCE_ID paneId=$SUPERSET_PANE_ID tabId=$SUPERSET_TAB_ID workspaceId=$SUPERSET_WORKSPACE_ID wrapperPid=$SUPERSET_WRAPPER_PID" >&2 fi # Timeouts prevent blocking agent completion if notification server is unresponsive diff --git a/apps/desktop/src/main/lib/app-state/index.ts b/apps/desktop/src/main/lib/app-state/index.ts index 00e9fe790f5..47f756bbfd2 100644 --- a/apps/desktop/src/main/lib/app-state/index.ts +++ b/apps/desktop/src/main/lib/app-state/index.ts @@ -1,5 +1,8 @@ import { JSONFilePreset } from "lowdb/node"; -import { APP_STATE_PATH } from "../app-environment"; +import { + APP_STATE_PATH, + ensureSupersetHomeDirExists, +} from "../app-environment"; import type { AppState } from "./schemas"; import { defaultAppState } from "./schemas"; @@ -36,6 +39,7 @@ function ensureValidShape(data: Partial): AppState { export async function initAppState(): Promise { if (_appState) return; + ensureSupersetHomeDirExists(); _appState = await JSONFilePreset(APP_STATE_PATH, defaultAppState); // Reshape data to ensure it has the correct structure (handles legacy formats) diff --git a/apps/desktop/src/main/lib/auto-updater.ts b/apps/desktop/src/main/lib/auto-updater.ts index 1d95ca60805..5d5a0f2de6a 100644 --- a/apps/desktop/src/main/lib/auto-updater.ts +++ b/apps/desktop/src/main/lib/auto-updater.ts @@ -2,8 +2,7 @@ import { EventEmitter } from "node:events"; import { app, dialog } from "electron"; import { autoUpdater } from "electron-updater"; import { env } from "main/env.main"; -import { setSkipQuitConfirmation } from "main/index"; -import { prerelease } from "semver"; +import { gt, prerelease, valid } from "semver"; import { AUTO_UPDATE_STATUS, type AutoUpdateStatus } from "shared/auto-update"; import { PLATFORM } from "shared/constants"; @@ -23,6 +22,17 @@ function isPrereleaseBuild(): boolean { const IS_PRERELEASE = isPrereleaseBuild(); const IS_AUTO_UPDATE_PLATFORM = PLATFORM.IS_MAC || PLATFORM.IS_LINUX; +// Fork builds use GitHub API to check for new releases instead of electron-updater. +// electron-updater's feed URL points to superset-sh/superset which doesn't +// distribute binaries for this fork, causing the UI to get stuck on +// "Downloading update..." indefinitely. +const IS_FORK = true; + +const FORK_OWNER = "MocA-Love"; +const FORK_REPO = "superset"; +const FORK_RELEASES_URL = `https://github.com/${FORK_OWNER}/${FORK_REPO}/releases`; +const FORK_API_URL = `https://api.github.com/repos/${FORK_OWNER}/${FORK_REPO}/releases/latest`; + // Use explicit feed URLs to ensure we always fetch platform-specific manifests // (for example latest-mac.yml and latest-linux.yml) from the correct release. // - Stable: fetches from /releases/latest/download/ (latest non-prerelease) @@ -86,13 +96,13 @@ export function getUpdateStatus(): AutoUpdateStatusEvent { } export function installUpdate(): void { - if (env.NODE_ENV === "development") { - console.info("[auto-updater] Install skipped in dev mode"); + if (IS_FORK) { + import("electron") + .then(({ shell }) => shell.openExternal(FORK_RELEASES_URL)) + .catch(() => {}); emitStatus(AUTO_UPDATE_STATUS.IDLE); return; } - // Skip confirmation dialog - quitAndInstall internally calls app.quit() - setSkipQuitConfirmation(); autoUpdater.quitAndInstall(false, true); } @@ -101,8 +111,125 @@ export function dismissUpdate(): void { autoUpdateEmitter.emit("status-changed", { status: AUTO_UPDATE_STATUS.IDLE }); } +// ── Fork: GitHub API release check ────────────────────────────────────────── + +async function fetchLatestForkRelease(): Promise { + const { net } = await import("electron"); + return new Promise((resolve, reject) => { + const request = net.request({ url: FORK_API_URL, method: "GET" }); + request.setHeader("Accept", "application/vnd.github+json"); + request.setHeader("User-Agent", "Superset-Desktop"); + + let data = ""; + request.on("response", (response) => { + if (response.statusCode !== 200) { + reject(new Error(`GitHub API returned ${response.statusCode}`)); + return; + } + response.on("data", (chunk) => { + data += chunk.toString(); + }); + response.on("end", () => { + try { + const release = JSON.parse(data) as { tag_name: string }; + // Strip "v" or "desktop-v" prefix from tag + const version = release.tag_name.replace(/^(desktop-)?v/, ""); + resolve(valid(version) ? version : null); + } catch { + reject(new Error("Failed to parse GitHub API response")); + } + }); + }); + request.on("error", reject); + request.end(); + }); +} + +async function checkForkForUpdates(interactive: boolean): Promise { + emitStatus(AUTO_UPDATE_STATUS.CHECKING); + + try { + const latestVersion = await fetchLatestForkRelease(); + const currentAppVersion = app.getVersion(); + + if (!latestVersion) { + console.info("[auto-updater:fork] Could not determine latest version"); + emitStatus(AUTO_UPDATE_STATUS.IDLE); + if (interactive) { + dialog.showMessageBox({ + type: "info", + title: "Updates", + message: "Could not determine the latest version.", + }); + } + return; + } + + console.info( + `[auto-updater:fork] Current: ${currentAppVersion}, Latest: ${latestVersion}`, + ); + + if (gt(latestVersion, currentAppVersion)) { + console.info( + `[auto-updater:fork] Update available: ${currentAppVersion} → ${latestVersion}`, + ); + emitStatus(AUTO_UPDATE_STATUS.READY, latestVersion); + } else { + console.info("[auto-updater:fork] Already up to date"); + emitStatus(AUTO_UPDATE_STATUS.IDLE); + if (interactive) { + dialog.showMessageBox({ + type: "info", + title: "No Updates", + message: "You're up to date!", + detail: `Version ${currentAppVersion} is the latest version.`, + }); + } + } + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + if (isNetworkError(err)) { + console.info("[auto-updater:fork] Network unavailable, will retry later"); + emitStatus(AUTO_UPDATE_STATUS.IDLE); + if (interactive) { + dialog.showMessageBox({ + type: "info", + title: "No Internet Connection", + message: + "Unable to check for updates. Please check your internet connection.", + }); + } + return; + } + console.error( + "[auto-updater:fork] Failed to check for updates:", + err.message, + ); + emitStatus(AUTO_UPDATE_STATUS.ERROR, undefined, err.message); + if (interactive) { + dialog.showMessageBox({ + type: "error", + title: "Update Error", + message: "Failed to check for updates. Please try again later.", + }); + } + } +} + +// ── Public check functions ────────────────────────────────────────────────── + export function checkForUpdates(): void { - if (env.NODE_ENV === "development" || !IS_AUTO_UPDATE_PLATFORM) { + if (env.NODE_ENV === "development") { + return; + } + + if (IS_FORK) { + isDismissed = false; + void checkForkForUpdates(false); + return; + } + + if (!IS_AUTO_UPDATE_PLATFORM) { return; } isDismissed = false; @@ -127,6 +254,13 @@ export function checkForUpdatesInteractive(): void { }); return; } + + if (IS_FORK) { + isDismissed = false; + void checkForkForUpdates(true); + return; + } + if (!IS_AUTO_UPDATE_PLATFORM) { dialog.showMessageBox({ type: "info", @@ -177,6 +311,8 @@ export function checkForUpdatesInteractive(): void { }); } +// ── Dev simulation helpers ────────────────────────────────────────────────── + export function simulateUpdateReady(): void { if (env.NODE_ENV !== "development") return; isDismissed = false; @@ -199,13 +335,45 @@ export function simulateError(): void { ); } +// ── Setup ─────────────────────────────────────────────────────────────────── + export function setupAutoUpdater(): void { - if (env.NODE_ENV === "development" || !IS_AUTO_UPDATE_PLATFORM) { + if (env.NODE_ENV === "development") { + return; + } + + // Fork builds: periodic GitHub API check (no electron-updater) + if (IS_FORK) { + console.info( + `[auto-updater:fork] Initialized: version=${app.getVersion()}, checking ${FORK_API_URL}`, + ); + + const interval = setInterval(checkForUpdates, UPDATE_CHECK_INTERVAL_MS); + interval.unref(); + + if (app.isReady()) { + void checkForUpdates(); + } else { + app + .whenReady() + .then(() => checkForUpdates()) + .catch((error) => { + console.error( + "[auto-updater:fork] Failed to start update checks:", + error, + ); + }); + } + return; + } + + // Upstream builds: electron-updater (macOS / Linux only) + if (!IS_AUTO_UPDATE_PLATFORM) { return; } - autoUpdater.autoDownload = true; - autoUpdater.autoInstallOnAppQuit = true; + autoUpdater.autoDownload = false; + autoUpdater.autoInstallOnAppQuit = false; autoUpdater.disableDifferentialDownload = true; // Allow downgrade for prerelease builds so users can switch back to stable diff --git a/apps/desktop/src/main/lib/browser/browser-manager.ts b/apps/desktop/src/main/lib/browser/browser-manager.ts index 3fe8d6201f6..00a830103f8 100644 --- a/apps/desktop/src/main/lib/browser/browser-manager.ts +++ b/apps/desktop/src/main/lib/browser/browser-manager.ts @@ -49,6 +49,7 @@ class BrowserManager extends EventEmitter { wc.setWindowOpenHandler(({ url }) => { if (url && url !== "about:blank") { this.emit(`new-window:${paneId}`, url); + this.emit("new-window", { paneId, url }); } return { action: "deny" as const }; }); @@ -93,7 +94,11 @@ class BrowserManager extends EventEmitter { const wc = this.getWebContents(paneId); if (!wc) throw new Error(`No webContents for pane ${paneId}`); const image = await wc.capturePage(); - clipboard.writeImage(image); + try { + clipboard.writeImage(image); + } catch (error) { + console.error("[browser-manager] clipboard.writeImage failed:", error); + } return image.toPNG().toString("base64"); } @@ -138,7 +143,13 @@ class BrowserManager extends EventEmitter { }, { label: "Copy Link Address", - click: () => clipboard.writeText(linkURL), + click: () => { + try { + clipboard.writeText(linkURL); + } catch { + // clipboard unavailable + } + }, }, { type: "separator" }, ); @@ -202,13 +213,27 @@ class BrowserManager extends EventEmitter { { label: "Copy Page URL", click: () => { - if (pageURL) clipboard.writeText(pageURL); + if (pageURL) { + try { + clipboard.writeText(pageURL); + } catch { + // clipboard unavailable + } + } }, enabled: !!pageURL && pageURL !== "about:blank", }, ); } + menuItems.push( + { type: "separator" }, + { + label: "Inspect Element", + click: () => wc.inspectElement(params.x, params.y), + }, + ); + const menu = Menu.buildFromTemplate(menuItems); menu.popup(); }; diff --git a/apps/desktop/src/main/lib/extensions/compatibility-checker.ts b/apps/desktop/src/main/lib/extensions/compatibility-checker.ts new file mode 100644 index 00000000000..e8ddb02111a --- /dev/null +++ b/apps/desktop/src/main/lib/extensions/compatibility-checker.ts @@ -0,0 +1,262 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { glob } from "fast-glob"; +import type { ChromeManifest } from "./crx-downloader"; + +/** APIs fully supported in Electron */ +const _SUPPORTED_APIS = new Set([ + "chrome.devtools.inspectedWindow", + "chrome.devtools.network", + "chrome.devtools.panels", + "chrome.scripting", + "chrome.webRequest", + "chrome.storage.local", + "chrome.runtime.lastError", + "chrome.runtime.id", + "chrome.runtime.getManifest", + "chrome.runtime.getURL", + "chrome.runtime.connect", + "chrome.runtime.sendMessage", + "chrome.runtime.onConnect", + "chrome.runtime.onMessage", + "chrome.runtime.onInstalled", + "chrome.runtime.onStartup", + "chrome.extension.getURL", + "chrome.extension.getBackgroundPage", +]); + +/** Permissions that Electron cannot provide */ +const UNSUPPORTED_PERMISSIONS = new Set([ + "bookmarks", + "browsingData", + "contentSettings", + "cookies", + "debugger", + "declarativeContent", + "declarativeNetRequest", + "desktopCapture", + "downloads", + "downloads.shelf", + "enterprise.deviceAttributes", + "enterprise.platformKeys", + "fontSettings", + "gcm", + "geolocation", + "history", + "identity", + "idle", + "loginState", + "nativeMessaging", + "notifications", + "pageCapture", + "platformKeys", + "power", + "printerProvider", + "printing", + "printingMetrics", + "privacy", + "proxy", + "search", + "sessions", + "signedInDevices", + "system.cpu", + "system.display", + "system.memory", + "system.storage", + "tabCapture", + "tabGroups", + "topSites", + "tts", + "ttsEngine", + "wallpaper", + "webNavigation", +]); + +/** chrome.* API patterns that don't work in Electron */ +const UNSUPPORTED_API_PATTERNS = [ + "chrome.bookmarks", + "chrome.browsingData", + "chrome.contentSettings", + "chrome.cookies", + "chrome.debugger", + "chrome.declarativeContent", + "chrome.declarativeNetRequest", + "chrome.desktopCapture", + "chrome.downloads", + "chrome.fontSettings", + "chrome.gcm", + "chrome.history", + "chrome.identity", + "chrome.notifications", + "chrome.pageCapture", + "chrome.privacy", + "chrome.proxy", + "chrome.sessions", + "chrome.tabCapture", + "chrome.tabGroups", + "chrome.topSites", + "chrome.tts", + "chrome.ttsEngine", + "chrome.webNavigation", + "chrome.storage.sync", + "chrome.storage.managed", + "chrome.tabs.create", + "chrome.tabs.remove", + "chrome.tabs.move", + "chrome.tabs.group", + "chrome.tabs.ungroup", + "chrome.tabs.duplicate", + "chrome.tabs.discard", + "chrome.tabs.captureVisibleTab", + "chrome.tabs.goBack", + "chrome.tabs.goForward", + "chrome.windows.create", + "chrome.windows.remove", + "chrome.windows.update", +]; + +export type CompatibilityLevel = "full" | "partial" | "low"; + +export interface CompatibilityIssue { + type: "unsupported_permission" | "unsupported_api" | "unsupported_feature"; + severity: "warning" | "error"; + message: string; + detail?: string; +} + +export interface CompatibilityReport { + level: CompatibilityLevel; + issues: CompatibilityIssue[]; + summary: string; +} + +/** + * Check extension manifest for unsupported features. + */ +function checkManifest(manifest: ChromeManifest): CompatibilityIssue[] { + const issues: CompatibilityIssue[] = []; + + // Check permissions + const allPermissions = [ + ...(manifest.permissions ?? []), + ...(manifest.optional_permissions ?? []), + ]; + + for (const perm of allPermissions) { + if (UNSUPPORTED_PERMISSIONS.has(perm)) { + issues.push({ + type: "unsupported_permission", + severity: "warning", + message: `Permission "${perm}" is not supported in Electron`, + }); + } + } + + // Check chrome_url_overrides + if (manifest.chrome_url_overrides) { + issues.push({ + type: "unsupported_feature", + severity: "error", + message: + "Chrome URL overrides (new tab, history, bookmarks pages) are not supported", + }); + } + + // Check options_ui + if (manifest.options_ui || manifest.options_page) { + issues.push({ + type: "unsupported_feature", + severity: "warning", + message: "Options page may not work as expected", + detail: + "Extension options pages rely on chrome.runtime.openOptionsPage() which has limited support", + }); + } + + return issues; +} + +/** + * Scan the extension's JS files for usage of unsupported chrome.* APIs. + */ +async function scanJsForUnsupportedApis( + extensionDir: string, +): Promise { + const issues: CompatibilityIssue[] = []; + const seen = new Set(); + + const jsFiles = await glob("**/*.js", { + cwd: extensionDir, + absolute: true, + ignore: ["**/node_modules/**"], + }); + + for (const file of jsFiles) { + let content: string; + try { + content = await readFile(file, "utf-8"); + } catch { + continue; + } + + for (const api of UNSUPPORTED_API_PATTERNS) { + if (seen.has(api)) continue; + + // Escape dots for regex, match the API call pattern + const pattern = api.replace(/\./g, "\\."); + const regex = new RegExp(`${pattern}\\b`); + + if (regex.test(content)) { + seen.add(api); + issues.push({ + type: "unsupported_api", + severity: "warning", + message: `Uses "${api}" which is not supported in Electron`, + detail: `Found in ${path.basename(file)}`, + }); + } + } + } + + return issues; +} + +/** + * Run a full compatibility check on an unpacked extension. + */ +export async function checkCompatibility( + extensionDir: string, + manifest: ChromeManifest, +): Promise { + const manifestIssues = checkManifest(manifest); + const apiIssues = await scanJsForUnsupportedApis(extensionDir); + + const issues = [...manifestIssues, ...apiIssues]; + + const errorCount = issues.filter((i) => i.severity === "error").length; + const warningCount = issues.filter((i) => i.severity === "warning").length; + + let level: CompatibilityLevel; + if (errorCount > 0 || warningCount >= 5) { + level = "low"; + } else if (warningCount > 0) { + level = "partial"; + } else { + level = "full"; + } + + let summary: string; + switch (level) { + case "full": + summary = "This extension is expected to work well in Electron."; + break; + case "partial": + summary = `This extension may have limited functionality (${warningCount} potential issue${warningCount > 1 ? "s" : ""}).`; + break; + case "low": + summary = `This extension is likely incompatible (${errorCount} critical, ${warningCount} warning${warningCount > 1 ? "s" : ""}).`; + break; + } + + return { level, issues, summary }; +} diff --git a/apps/desktop/src/main/lib/extensions/crx-downloader.ts b/apps/desktop/src/main/lib/extensions/crx-downloader.ts new file mode 100644 index 00000000000..93fee16c671 --- /dev/null +++ b/apps/desktop/src/main/lib/extensions/crx-downloader.ts @@ -0,0 +1,254 @@ +import { existsSync, mkdirSync } from "node:fs"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { app, net } from "electron"; +import JSZip from "jszip"; + +/** Electron version string used in the CRX download URL */ +const ELECTRON_VERSION = process.versions.chrome ?? "130.0.0.0"; + +const CRX_DOWNLOAD_URL = + "https://clients2.google.com/service/update2/crx?response=redirect&prodversion=VERSION&acceptformat=crx2,crx3&x=id%3DID%26uc"; + +/** + * Parse a Chrome Web Store URL or raw extension ID into just the extension ID. + * + * Accepts: + * - Full URL: https://chromewebstore.google.com/detail/some-name/abcdefghijklmnopabcdefghijklmnop + * - Short URL: https://chrome.google.com/webstore/detail/abcdefghijklmnopabcdefghijklmnop + * - Raw 32-char extension ID: abcdefghijklmnopabcdefghijklmnop + */ +export function parseExtensionId(input: string): string | null { + const trimmed = input.trim(); + + // Raw extension ID (32 lowercase alpha chars) + if (/^[a-p]{32}$/.test(trimmed)) return trimmed; + + try { + const url = new URL(trimmed); + // New Chrome Web Store: /detail// or /detail/ + const segments = url.pathname.split("/").filter(Boolean); + for (const seg of segments) { + if (/^[a-p]{32}$/.test(seg)) return seg; + } + } catch { + // Not a URL + } + + return null; +} + +/** + * Build the CRX download URL from an extension ID. + */ +function buildCrxUrl(extensionId: string): string { + return CRX_DOWNLOAD_URL.replace("VERSION", ELECTRON_VERSION).replace( + "ID", + extensionId, + ); +} + +/** + * Get the root directory where user-installed extensions are stored. + */ +export function getExtensionsDir(): string { + return path.join(app.getPath("userData"), "extensions"); +} + +/** + * Download a CRX file from Google's update servers. + * Returns the path to the downloaded CRX file. + */ +async function downloadCrx(extensionId: string): Promise { + const tmpDir = path.join(os.tmpdir(), `superset-crx-${extensionId}`); + if (!existsSync(tmpDir)) mkdirSync(tmpDir, { recursive: true }); + + const crxPath = path.join(tmpDir, `${extensionId}.crx`); + const url = buildCrxUrl(extensionId); + + const response = await net.fetch(url, { redirect: "follow" }); + if (!response.ok) { + throw new Error( + `Failed to download extension ${extensionId}: HTTP ${response.status}`, + ); + } + + const body = response.body; + if (!body) throw new Error("Empty response body"); + + const arrayBuffer = await response.arrayBuffer(); + await writeFile(crxPath, Buffer.from(arrayBuffer)); + + return crxPath; +} + +/** + * Strip the CRX header and extract the ZIP payload. + * + * CRX3 format: + * [4 bytes] "Cr24" magic number + * [4 bytes] CRX version (3) + * [4 bytes] header length + * [header_length bytes] protobuf header + * [rest] ZIP data + * + * CRX2 format: + * [4 bytes] "Cr24" magic number + * [4 bytes] CRX version (2) + * [4 bytes] public key length + * [4 bytes] signature length + * [public_key_length bytes] public key + * [signature_length bytes] signature + * [rest] ZIP data + */ +function extractZipFromCrx(crxBuffer: Buffer): Buffer { + const magic = crxBuffer.toString("ascii", 0, 4); + if (magic !== "Cr24") { + // Maybe it's already a ZIP + if (crxBuffer[0] === 0x50 && crxBuffer[1] === 0x4b) { + return crxBuffer; + } + throw new Error(`Invalid CRX file: unexpected magic "${magic}"`); + } + + const version = crxBuffer.readUInt32LE(4); + + if (version === 3) { + const headerLength = crxBuffer.readUInt32LE(8); + const zipStart = 12 + headerLength; + return crxBuffer.subarray(zipStart); + } + + if (version === 2) { + const pubKeyLength = crxBuffer.readUInt32LE(8); + const sigLength = crxBuffer.readUInt32LE(12); + const zipStart = 16 + pubKeyLength + sigLength; + return crxBuffer.subarray(zipStart); + } + + throw new Error(`Unsupported CRX version: ${version}`); +} + +/** + * Unpack a ZIP buffer into the target directory. + */ +async function unpackZip(zipBuffer: Buffer, targetDir: string): Promise { + const zip = await JSZip.loadAsync(zipBuffer); + + await mkdir(targetDir, { recursive: true }); + + const entries = Object.entries(zip.files); + for (const [relativePath, file] of entries) { + const fullPath = path.join(targetDir, relativePath); + + if (file.dir) { + await mkdir(fullPath, { recursive: true }); + continue; + } + + // Ensure parent directory exists + await mkdir(path.dirname(fullPath), { recursive: true }); + + const content = await file.async("nodebuffer"); + await writeFile(fullPath, content); + } +} + +export interface CrxDownloadResult { + extensionId: string; + extensionDir: string; + manifest: ChromeManifest; +} + +export interface ChromeManifest { + manifest_version: number; + name: string; + version: string; + description?: string; + permissions?: string[]; + optional_permissions?: string[]; + host_permissions?: string[]; + background?: { + service_worker?: string; + scripts?: string[]; + page?: string; + }; + content_scripts?: Array<{ + matches: string[]; + js?: string[]; + css?: string[]; + run_at?: string; + }>; + action?: { + default_popup?: string; + default_icon?: string | Record; + default_title?: string; + }; + browser_action?: { + default_popup?: string; + default_icon?: string | Record; + default_title?: string; + }; + icons?: Record; + devtools_page?: string; + chrome_url_overrides?: Record; + options_ui?: { page: string; open_in_tab?: boolean }; + options_page?: string; +} + +/** + * Download and install an extension from the Chrome Web Store. + * + * 1. Download the CRX + * 2. Strip the CRX header to get the ZIP + * 3. Extract into userData/extensions/ + * 4. Return the extracted manifest + */ +export async function downloadAndExtractExtension( + extensionId: string, +): Promise { + const extensionsRoot = getExtensionsDir(); + const extensionDir = path.join(extensionsRoot, extensionId); + + // Clean up any previous install + if (existsSync(extensionDir)) { + await rm(extensionDir, { recursive: true, force: true }); + } + + let crxPath: string | null = null; + try { + // Download + crxPath = await downloadCrx(extensionId); + + // Extract ZIP from CRX + const crxBuffer = await readFile(crxPath); + const zipBuffer = extractZipFromCrx(crxBuffer); + + // Unpack + await unpackZip(zipBuffer, extensionDir); + + // Read manifest + const manifestPath = path.join(extensionDir, "manifest.json"); + if (!existsSync(manifestPath)) { + throw new Error("Extension does not contain a manifest.json"); + } + const manifest: ChromeManifest = JSON.parse( + await readFile(manifestPath, "utf-8"), + ); + + return { extensionId, extensionDir, manifest }; + } catch (error) { + // Clean up on failure + if (existsSync(extensionDir)) { + await rm(extensionDir, { recursive: true, force: true }).catch(() => {}); + } + throw error; + } finally { + // Clean up temp CRX + if (crxPath) { + const tmpDir = path.dirname(crxPath); + await rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + } + } +} diff --git a/apps/desktop/src/main/lib/extensions/extension-icon-protocol.ts b/apps/desktop/src/main/lib/extensions/extension-icon-protocol.ts new file mode 100644 index 00000000000..54d163c995d --- /dev/null +++ b/apps/desktop/src/main/lib/extensions/extension-icon-protocol.ts @@ -0,0 +1,89 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { net } from "electron"; +import type { ChromeManifest } from "./crx-downloader"; +import { getExtensionsDir } from "./crx-downloader"; + +/** + * Resolve the best icon file path from a manifest's action or icons field. + * + * Tries `action.default_icon` first (string or size map), then falls back + * to `manifest.icons`. Returns the absolute path to the icon file, or null. + */ +function resolveIconFile( + extensionDir: string, + manifest: ChromeManifest, + requestedSize: number, +): string | null { + const action = manifest.action ?? manifest.browser_action; + const iconSource = action?.default_icon ?? manifest.icons; + + if (!iconSource) return null; + + // Single string path + if (typeof iconSource === "string") { + const fullPath = path.join(extensionDir, iconSource); + return existsSync(fullPath) ? fullPath : null; + } + + // Record – find closest size + const sizes = Object.keys(iconSource) + .map(Number) + .filter(Number.isFinite) + .sort((a, b) => a - b); + + if (sizes.length === 0) return null; + + // Pick the smallest size >= requestedSize, or the largest available + const bestSize = + sizes.find((s) => s >= requestedSize) ?? sizes[sizes.length - 1]; + + const iconRelPath = iconSource[String(bestSize)]; + if (!iconRelPath) return null; + + const fullPath = path.join(extensionDir, iconRelPath); + return existsSync(fullPath) ? fullPath : null; +} + +/** + * Create a protocol handler that serves extension icon images. + * + * URL format: `superset-ext-icon://{extensionId}/{size}` + * e.g. `superset-ext-icon://abcdefghijklmnopabcdefghijklmnop/32` + * + * The handler reads the extension's manifest.json to locate the best + * matching icon file and returns it via `net.fetch`. + */ +export function createExtensionIconProtocolHandler(): ( + request: Request, +) => Response | Promise { + return async (request: Request) => { + try { + const url = new URL(request.url); + const extensionId = url.hostname; + const size = Number.parseInt(url.pathname.replace(/^\//, ""), 10) || 32; + + const extensionDir = path.join(getExtensionsDir(), extensionId); + const manifestPath = path.join(extensionDir, "manifest.json"); + + if (!existsSync(manifestPath)) { + return new Response("Extension not found", { status: 404 }); + } + + const { readFile } = await import("node:fs/promises"); + const manifest: ChromeManifest = JSON.parse( + await readFile(manifestPath, "utf-8"), + ); + + const iconPath = resolveIconFile(extensionDir, manifest, size); + if (!iconPath) { + return new Response("Icon not found", { status: 404 }); + } + + return net.fetch(pathToFileURL(iconPath).toString()); + } catch { + return new Response("Internal error", { status: 500 }); + } + }; +} diff --git a/apps/desktop/src/main/lib/extensions/extension-manager.ts b/apps/desktop/src/main/lib/extensions/extension-manager.ts new file mode 100644 index 00000000000..f55b25aa6ff --- /dev/null +++ b/apps/desktop/src/main/lib/extensions/extension-manager.ts @@ -0,0 +1,339 @@ +import { existsSync } from "node:fs"; +import { readFile, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { app, session } from "electron"; +import type { CompatibilityReport } from "./compatibility-checker"; +import { checkCompatibility } from "./compatibility-checker"; +import { + type ChromeManifest, + downloadAndExtractExtension, + getExtensionsDir, + parseExtensionId, +} from "./crx-downloader"; + +const APP_PARTITION = "persist:superset"; + +export interface InstalledExtension { + id: string; + /** Extension ID assigned by Electron (derived from path, may differ from Chrome Web Store ID) */ + electronId?: string; + name: string; + version: string; + description: string; + enabled: boolean; + installedAt: string; + compatibility: CompatibilityReport; + iconPath?: string; +} + +interface ExtensionStore { + extensions: InstalledExtension[]; +} + +function getStorePath(): string { + return path.join(app.getPath("userData"), "extension-store.json"); +} + +async function readStore(): Promise { + const storePath = getStorePath(); + try { + const data = await readFile(storePath, "utf-8"); + return JSON.parse(data) as ExtensionStore; + } catch { + return { extensions: [] }; + } +} + +async function writeStore(store: ExtensionStore): Promise { + const storePath = getStorePath(); + await writeFile(storePath, JSON.stringify(store, null, 2), "utf-8"); +} + +/** + * Resolve the best icon path from the manifest icons object. + */ +function resolveIconPath( + manifest: ChromeManifest, + extensionDir: string, +): string | undefined { + if (!manifest.icons) return undefined; + + const sizes = Object.keys(manifest.icons) + .map(Number) + .sort((a, b) => b - a); + + for (const size of sizes) { + const iconRelPath = manifest.icons[String(size)]; + if (iconRelPath) { + const fullPath = path.join(extensionDir, iconRelPath); + if (existsSync(fullPath)) return fullPath; + } + } + + return undefined; +} + +/** + * Load all enabled extensions into the Electron session. + * Called at app startup. + */ +export async function loadInstalledExtensions(): Promise { + const store = await readStore(); + const ses = session.fromPartition(APP_PARTITION); + let storeUpdated = false; + + for (const ext of store.extensions) { + if (!ext.enabled) continue; + + const extensionDir = path.join(getExtensionsDir(), ext.id); + if (!existsSync(path.join(extensionDir, "manifest.json"))) { + console.warn( + `[extensions] Extension ${ext.id} (${ext.name}) directory missing, skipping`, + ); + continue; + } + + try { + const loaded = await ses.extensions.loadExtension(extensionDir); + // Persist the Electron-assigned ID (may differ from Chrome Web Store ID) + if (loaded.id !== ext.electronId) { + ext.electronId = loaded.id; + storeUpdated = true; + } + console.log( + `[extensions] Loaded: ${ext.name} v${ext.version} (electronId=${loaded.id})`, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes("already loaded")) continue; + console.error(`[extensions] Failed to load ${ext.name}:`, error); + } + } + + if (storeUpdated) { + await writeStore(store); + } +} + +/** + * Install an extension from the Chrome Web Store. + */ +export async function installExtension( + input: string, +): Promise { + const extensionId = parseExtensionId(input); + if (!extensionId) { + throw new Error( + "Invalid input. Please provide a Chrome Web Store URL or extension ID.", + ); + } + + // Check if already installed + const store = await readStore(); + const existing = store.extensions.find((e) => e.id === extensionId); + if (existing) { + throw new Error(`Extension "${existing.name}" is already installed.`); + } + + // Download and extract + const result = await downloadAndExtractExtension(extensionId); + + // Run compatibility check + const compatibility = await checkCompatibility( + result.extensionDir, + result.manifest, + ); + + const iconPath = resolveIconPath(result.manifest, result.extensionDir); + + const installed: InstalledExtension = { + id: extensionId, + name: result.manifest.name, + version: result.manifest.version, + description: result.manifest.description ?? "", + enabled: true, + installedAt: new Date().toISOString(), + compatibility, + iconPath, + }; + + // Load into session and capture Electron-assigned ID + const ses = session.fromPartition(APP_PARTITION); + try { + const loaded = await ses.extensions.loadExtension(result.extensionDir); + installed.electronId = loaded.id; + console.log( + `[extensions] Installed and loaded: ${installed.name} v${installed.version} (electronId=${loaded.id})`, + ); + } catch (error) { + console.error( + `[extensions] Installed but failed to load ${installed.name}:`, + error, + ); + installed.enabled = false; + } + + // Persist + store.extensions.push(installed); + await writeStore(store); + + return installed; +} + +/** + * Uninstall an extension. + */ +export async function uninstallExtension(extensionId: string): Promise { + const store = await readStore(); + const idx = store.extensions.findIndex((e) => e.id === extensionId); + if (idx === -1) { + throw new Error("Extension not found."); + } + + const ext = store.extensions[idx]; + + // Unload from session (try both IDs) + const ses = session.fromPartition(APP_PARTITION); + for (const id of [ext.electronId, ext.id]) { + if (!id) continue; + try { + ses.extensions.removeExtension(id); + break; + } catch { + // May not be loaded with this ID + } + } + + // Remove files + const extensionDir = path.join(getExtensionsDir(), extensionId); + if (existsSync(extensionDir)) { + await rm(extensionDir, { recursive: true, force: true }); + } + + // Update store + store.extensions.splice(idx, 1); + await writeStore(store); + + console.log(`[extensions] Uninstalled: ${extensionId}`); +} + +/** + * Toggle an extension's enabled state. + */ +export async function toggleExtension( + extensionId: string, + enabled: boolean, +): Promise { + const store = await readStore(); + const ext = store.extensions.find((e) => e.id === extensionId); + if (!ext) { + throw new Error("Extension not found."); + } + + const ses = session.fromPartition(APP_PARTITION); + + if (enabled) { + const extensionDir = path.join(getExtensionsDir(), extensionId); + if (!existsSync(path.join(extensionDir, "manifest.json"))) { + throw new Error("Extension files are missing. Please reinstall."); + } + try { + const loaded = await ses.extensions.loadExtension(extensionDir); + ext.electronId = loaded.id; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!message.includes("already loaded")) { + throw new Error(`Failed to enable extension: ${message}`); + } + } + } else { + for (const id of [ext.electronId, ext.id]) { + if (!id) continue; + try { + ses.extensions.removeExtension(id); + break; + } catch { + // Already unloaded or wrong ID + } + } + } + + ext.enabled = enabled; + await writeStore(store); + + return ext; +} + +/** + * List all installed extensions. + */ +export async function listExtensions(): Promise { + const store = await readStore(); + return store.extensions; +} + +export interface ExtensionToolbarInfo { + id: string; + /** Electron-assigned extension ID (used for chrome-extension:// URLs) */ + electronId: string; + name: string; + enabled: boolean; + hasPopup: boolean; + popupPath: string | null; + actionTitle: string | null; +} + +/** + * Get toolbar-relevant info for all enabled extensions that have a popup action. + */ +export async function getExtensionsWithToolbarInfo(): Promise< + ExtensionToolbarInfo[] +> { + const store = await readStore(); + const ses = session.fromPartition(APP_PARTITION); + const results: ExtensionToolbarInfo[] = []; + + for (const ext of store.extensions) { + if (!ext.enabled) continue; + + const extensionDir = path.join(getExtensionsDir(), ext.id); + const manifestPath = path.join(extensionDir, "manifest.json"); + + if (!existsSync(manifestPath)) continue; + + let manifest: ChromeManifest; + try { + const data = await readFile(manifestPath, "utf-8"); + manifest = JSON.parse(data) as ChromeManifest; + } catch { + continue; + } + + const action = manifest.action ?? manifest.browser_action; + const hasPopup = !!action?.default_popup; + + if (!hasPopup) continue; + + // Resolve the Electron-assigned ID. + // If not cached, look it up from the session's loaded extensions. + let electronId = ext.electronId; + if (!electronId) { + const loaded = ses.extensions + .getAllExtensions() + .find((e) => e.path === extensionDir || e.name === ext.name); + electronId = loaded?.id ?? ext.id; + } + + results.push({ + id: ext.id, + electronId, + name: ext.name, + enabled: ext.enabled, + hasPopup, + popupPath: action?.default_popup ?? null, + actionTitle: action?.default_title ?? null, + }); + } + + return results; +} diff --git a/apps/desktop/src/main/lib/extensions/extension-popup-manager.ts b/apps/desktop/src/main/lib/extensions/extension-popup-manager.ts new file mode 100644 index 00000000000..6f26e6051f7 --- /dev/null +++ b/apps/desktop/src/main/lib/extensions/extension-popup-manager.ts @@ -0,0 +1,212 @@ +import path from "node:path"; +import { BrowserWindow, nativeTheme, screen, session } from "electron"; +import { getExtensionsDir } from "./crx-downloader"; + +const APP_PARTITION = "persist:superset"; + +/** Max popup dimensions */ +const MAX_WIDTH = 800; +const MAX_HEIGHT = 600; +const MIN_SIZE = 25; + +/** Gap between anchor icon and popup */ +const ANCHOR_GAP = 4; + +interface AnchorRect { + x: number; + y: number; + width: number; + height: number; +} + +/** + * Manages the lifecycle of extension popup BrowserWindows. + * + * Only one popup can be open at a time. Opening a new popup automatically + * closes the previous one. + */ +export class ExtensionPopupManager { + private currentPopup: BrowserWindow | null = null; + + /** + * Open an extension popup window anchored below a toolbar icon. + * + * @param parentWindow The main BrowserWindow (used as parent) + * @param extensionId Extension ID for the chrome-extension:// URL + * @param popupPath Relative path to the popup HTML (e.g. "popup.html") + * @param anchorRect Bounding rect of the icon *relative to the parent window content area* + */ + openPopup( + parentWindow: BrowserWindow, + extensionId: string, + popupPath: string, + anchorRect: AnchorRect, + ): void { + // Close any existing popup + this.closePopup(); + + // Convert content-relative coordinates to screen coordinates + const contentBounds = parentWindow.getContentBounds(); + + const screenAnchor = { + x: contentBounds.x + anchorRect.x, + y: contentBounds.y + anchorRect.y, + width: anchorRect.width, + height: anchorRect.height, + }; + + // Initial position: centered below the anchor + const initialWidth = 350; + const initialHeight = 400; + let popupX = + screenAnchor.x + + Math.round(screenAnchor.width / 2) - + Math.round(initialWidth / 2); + let popupY = screenAnchor.y + screenAnchor.height + ANCHOR_GAP; + + // Clamp to the display bounds + const display = screen.getDisplayNearestPoint({ + x: screenAnchor.x, + y: screenAnchor.y, + }); + const workArea = display.workArea; + + if (popupX + initialWidth > workArea.x + workArea.width) { + popupX = workArea.x + workArea.width - initialWidth; + } + if (popupX < workArea.x) { + popupX = workArea.x; + } + + // If not enough space below, show above the anchor + if (popupY + initialHeight > workArea.y + workArea.height) { + popupY = screenAnchor.y - initialHeight - ANCHOR_GAP; + } + if (popupY < workArea.y) { + popupY = workArea.y; + } + + const popup = new BrowserWindow({ + parent: parentWindow, + modal: false, + show: false, + frame: false, + transparent: false, + backgroundColor: nativeTheme.shouldUseDarkColors ? "#252525" : "#ffffff", + resizable: false, + movable: false, + minimizable: false, + maximizable: false, + fullscreenable: false, + skipTaskbar: true, + width: initialWidth, + height: initialHeight, + x: popupX, + y: popupY, + webPreferences: { + session: session.fromPartition(APP_PARTITION), + nodeIntegration: false, + contextIsolation: true, + // sandbox must be false — sandboxed renderers cannot load + // chrome-extension:// URLs (ERR_BLOCKED_BY_CLIENT) + sandbox: false, + enablePreferredSizeMode: true, + }, + }); + + this.currentPopup = popup; + + // Auto-resize when popup content changes size + popup.webContents.on("preferred-size-changed", (_event, preferredSize) => { + if (popup.isDestroyed()) return; + + const width = Math.min( + MAX_WIDTH, + Math.max(MIN_SIZE, preferredSize.width), + ); + const height = Math.min( + MAX_HEIGHT, + Math.max(MIN_SIZE, preferredSize.height), + ); + + // Re-center horizontally relative to anchor + let newX = + screenAnchor.x + + Math.round(screenAnchor.width / 2) - + Math.round(width / 2); + + // Clamp to work area + if (newX + width > workArea.x + workArea.width) { + newX = workArea.x + workArea.width - width; + } + if (newX < workArea.x) { + newX = workArea.x; + } + + popup.setBounds({ + x: newX, + y: popupY, + width, + height, + }); + }); + + // Show after the page loads to avoid flicker + popup.webContents.on("did-finish-load", () => { + if (!popup.isDestroyed()) { + popup.show(); + popup.focus(); + } + }); + + // Close when the popup loses focus + popup.on("blur", () => { + if (popup.isDestroyed()) return; + // Don't close if devtools is open (for debugging) + if (popup.webContents.isDevToolsOpened()) return; + this.closePopup(); + }); + + popup.on("closed", () => { + if (this.currentPopup === popup) { + this.currentPopup = null; + } + }); + + // Load the extension's popup page. + // Try chrome-extension:// first (enables full chrome.* API access). + // Fall back to loading from the local file path if blocked. + const popupUrl = `chrome-extension://${extensionId}/${popupPath}`; + popup.webContents.loadURL(popupUrl).catch((error) => { + const msg = error instanceof Error ? error.message : String(error); + console.warn( + `[extensions] chrome-extension:// load failed for ${extensionId}, trying file:// fallback:`, + msg, + ); + + // Fallback: load the popup HTML directly from disk + const filePath = path.join(getExtensionsDir(), extensionId, popupPath); + popup.webContents.loadFile(filePath).catch((fileError) => { + console.error( + `[extensions] Failed to load popup for ${extensionId}:`, + fileError, + ); + this.closePopup(); + }); + }); + } + + closePopup(): void { + if (this.currentPopup && !this.currentPopup.isDestroyed()) { + this.currentPopup.destroy(); + } + this.currentPopup = null; + } + + isOpen(): boolean { + return this.currentPopup !== null && !this.currentPopup.isDestroyed(); + } +} + +/** Singleton instance */ +export const extensionPopupManager = new ExtensionPopupManager(); diff --git a/apps/desktop/src/main/lib/language-services/diagnostics-store.ts b/apps/desktop/src/main/lib/language-services/diagnostics-store.ts new file mode 100644 index 00000000000..d728ce240ae --- /dev/null +++ b/apps/desktop/src/main/lib/language-services/diagnostics-store.ts @@ -0,0 +1,184 @@ +import { EventEmitter } from "node:events"; +import type { + LanguageServiceDiagnostic, + LanguageServiceWorkspaceSnapshot, +} from "./types"; + +const MAX_PROBLEMS = 500; + +function diagnosticSortValue(severity: string): number { + switch (severity) { + case "error": + return 0; + case "warning": + return 1; + case "info": + return 2; + default: + return 3; + } +} + +type WorkspaceDiagnostics = Map; + +export class LanguageDiagnosticsStore { + private readonly workspaces = new Map(); + + private readonly versions = new Map(); + + private readonly emitter = new EventEmitter(); + + setFileDiagnostics( + workspaceId: string, + fileKey: string, + diagnostics: LanguageServiceDiagnostic[], + ): void { + const workspaceDiagnostics = + this.workspaces.get(workspaceId) ?? + new Map(); + workspaceDiagnostics.set(fileKey, diagnostics); + this.workspaces.set(workspaceId, workspaceDiagnostics); + this.bump(workspaceId); + } + + clearFileDiagnostics(workspaceId: string, fileKey: string): void { + const workspaceDiagnostics = this.workspaces.get(workspaceId); + if (!workspaceDiagnostics) { + return; + } + + if (!workspaceDiagnostics.delete(fileKey)) { + return; + } + + if (workspaceDiagnostics.size === 0) { + this.workspaces.delete(workspaceId); + } + + this.bump(workspaceId); + } + + clearWorkspace(workspaceId: string): void { + if (!this.workspaces.delete(workspaceId)) { + return; + } + + this.bump(workspaceId); + } + + clearProviderDiagnostics(providerId: string, workspaceId?: string): void { + const fileKeyPrefix = `${providerId}::`; + const targetWorkspaceIds = workspaceId + ? [workspaceId] + : Array.from(this.workspaces.keys()); + + for (const targetWorkspaceId of targetWorkspaceIds) { + const workspaceDiagnostics = this.workspaces.get(targetWorkspaceId); + if (!workspaceDiagnostics) { + continue; + } + + let changed = false; + for (const fileKey of Array.from(workspaceDiagnostics.keys())) { + if (!fileKey.startsWith(fileKeyPrefix)) { + continue; + } + + workspaceDiagnostics.delete(fileKey); + changed = true; + } + + if (!changed) { + continue; + } + + if (workspaceDiagnostics.size === 0) { + this.workspaces.delete(targetWorkspaceId); + } + + this.bump(targetWorkspaceId); + } + } + + getVersion(workspaceId: string): number { + return this.versions.get(workspaceId) ?? 0; + } + + subscribe( + workspaceId: string, + listener: (payload: { version: number }) => void, + ) { + const eventName = this.eventName(workspaceId); + this.emitter.on(eventName, listener); + return () => { + this.emitter.off(eventName, listener); + }; + } + + createSnapshot(args: { + workspaceId: string; + workspacePath: string; + providers: LanguageServiceWorkspaceSnapshot["providers"]; + }): LanguageServiceWorkspaceSnapshot { + const flattened = Array.from( + this.workspaces.get(args.workspaceId)?.values() ?? [], + ) + .flat() + .sort((left, right) => { + const severityDelta = + diagnosticSortValue(left.severity) - + diagnosticSortValue(right.severity); + if (severityDelta !== 0) { + return severityDelta; + } + + const pathDelta = (left.relativePath ?? "").localeCompare( + right.relativePath ?? "", + ); + if (pathDelta !== 0) { + return pathDelta; + } + + const lineDelta = (left.line ?? 0) - (right.line ?? 0); + if (lineDelta !== 0) { + return lineDelta; + } + + return (left.column ?? 0) - (right.column ?? 0); + }); + + const problems = flattened.slice(0, MAX_PROBLEMS); + return { + status: "ready", + workspaceId: args.workspaceId, + workspacePath: args.workspacePath, + providers: args.providers, + problems, + totalCount: flattened.length, + truncated: flattened.length > problems.length, + summary: { + errorCount: flattened.filter((problem) => problem.severity === "error") + .length, + warningCount: flattened.filter( + (problem) => problem.severity === "warning", + ).length, + infoCount: flattened.filter((problem) => problem.severity === "info") + .length, + hintCount: flattened.filter((problem) => problem.severity === "hint") + .length, + }, + }; + } + + private bump(workspaceId: string): void { + const version = (this.versions.get(workspaceId) ?? 0) + 1; + this.versions.set(workspaceId, version); + this.emitter.emit(this.eventName(workspaceId), { version }); + } + + private eventName(workspaceId: string): string { + return `workspace:${workspaceId}`; + } +} + +export const languageDiagnosticsStore = new LanguageDiagnosticsStore(); diff --git a/apps/desktop/src/main/lib/language-services/lsp/ExternalLspLanguageProvider.ts b/apps/desktop/src/main/lib/language-services/lsp/ExternalLspLanguageProvider.ts new file mode 100644 index 00000000000..9bd41a2a599 --- /dev/null +++ b/apps/desktop/src/main/lib/language-services/lsp/ExternalLspLanguageProvider.ts @@ -0,0 +1,607 @@ +import { languageDiagnosticsStore } from "../diagnostics-store"; +import type { + LanguageServiceDiagnostic, + LanguageServiceDocument, + LanguageServiceProvider, + LanguageServiceProviderSummary, + LanguageServiceRelatedInformation, +} from "../types"; +import { + absolutePathToFileUri, + fileUriToAbsolutePath, + lspSeverityToLanguageServiceSeverity, + offsetToLspPosition, + toRelativeWorkspacePath, +} from "../utils"; +import type { ResolvedLspCommand } from "./command-resolvers"; +import { StdioJsonRpcClient } from "./StdioJsonRpcClient"; + +type OpenDocumentEntry = { + languageId: string; + version: number; + content: string; + uri: string; +}; + +type LspDiagnostic = { + range: { + start: { line: number; character: number }; + end: { line: number; character: number }; + }; + severity?: number; + code?: string | number | { value?: string | number }; + source?: string; + message: string; + relatedInformation?: Array<{ + location: { + uri: string; + range: { + start: { line: number; character: number }; + end: { line: number; character: number }; + }; + }; + message: string; + }>; +}; + +type WorkspaceSession = { + workspaceId: string; + workspacePath: string; + client: StdioJsonRpcClient; + openDocuments: Map; + lastError: string | null; + textDocumentSyncMode: "full" | "incremental"; +}; + +type ProviderArgs = { + workspaceId: string; + workspacePath: string; +}; + +type RefreshRequest = { + method: string; + params?: unknown | ((args: ProviderArgs) => unknown); +}; + +type ExternalLspProviderOptions = { + id: string; + label: string; + description: string; + languageIds: string[]; + resolveServerCommand: + | ((args: ProviderArgs) => Promise) + | ((args: ProviderArgs) => ResolvedLspCommand | null); + mapDocumentLanguageId?: (languageId: string) => string; + initializationOptions?: unknown | ((args: ProviderArgs) => unknown); + configuration?: unknown | ((args: ProviderArgs) => unknown); + refreshRequest?: RefreshRequest | null; + clientCapabilities?: unknown; + defaultSource?: string; +}; + +function resolveTextDocumentSyncMode(result: unknown): "full" | "incremental" { + const textDocumentSync = ( + result as { + capabilities?: { + textDocumentSync?: + | number + | { + change?: number; + }; + }; + } + )?.capabilities?.textDocumentSync; + + if (typeof textDocumentSync === "number") { + return textDocumentSync === 2 ? "incremental" : "full"; + } + + if ( + textDocumentSync && + typeof textDocumentSync === "object" && + textDocumentSync.change === 2 + ) { + return "incremental"; + } + + return "full"; +} + +function getSectionValue( + configuration: unknown, + section?: string | null, +): unknown { + if (!section) { + return configuration ?? null; + } + + const keys = section.split("."); + let current: unknown = configuration; + for (const key of keys) { + if (!current || typeof current !== "object") { + return null; + } + + current = (current as Record)[key]; + if (current === undefined) { + return null; + } + } + + return current; +} + +export class ExternalLspLanguageProvider implements LanguageServiceProvider { + readonly id: string; + + readonly label: string; + + readonly description: string; + + readonly languageIds: string[]; + + private readonly sessions = new Map(); + + private readonly workspaceErrors = new Map(); + + constructor(private readonly options: ExternalLspProviderOptions) { + this.id = options.id; + this.label = options.label; + this.description = options.description; + this.languageIds = options.languageIds; + } + + supportsLanguage(languageId: string): boolean { + return this.languageIds.includes(languageId); + } + + async openDocument(document: LanguageServiceDocument): Promise { + const session = await this.ensureSession( + document.workspaceId, + document.workspacePath, + ); + const uri = absolutePathToFileUri(document.absolutePath); + session.openDocuments.set(document.absolutePath, { + languageId: document.languageId, + version: document.version, + content: document.content, + uri, + }); + await session.client.notify("textDocument/didOpen", { + textDocument: { + uri, + languageId: this.mapDocumentLanguageId(document.languageId), + version: document.version, + text: document.content, + }, + }); + } + + async changeDocument(document: LanguageServiceDocument): Promise { + const session = await this.ensureSession( + document.workspaceId, + document.workspacePath, + ); + const previous = session.openDocuments.get(document.absolutePath); + if (!previous) { + await this.openDocument(document); + return; + } + + session.openDocuments.set(document.absolutePath, { + languageId: document.languageId, + version: document.version, + content: document.content, + uri: previous.uri, + }); + + await this.sendDidChange( + session, + previous, + document.version, + document.content, + ); + } + + async closeDocument(args: { + workspaceId: string; + workspacePath: string; + absolutePath: string; + languageId: string; + }): Promise { + const session = this.sessions.get(args.workspaceId); + if (!session) { + return; + } + + const existing = session.openDocuments.get(args.absolutePath); + session.openDocuments.delete(args.absolutePath); + languageDiagnosticsStore.clearFileDiagnostics( + args.workspaceId, + this.fileKey(args.absolutePath), + ); + + if (existing) { + await session.client.notify("textDocument/didClose", { + textDocument: { + uri: existing.uri, + }, + }); + } + + if (session.openDocuments.size === 0) { + await this.disposeWorkspace(args); + } + } + + async refreshWorkspace(args: { + workspaceId: string; + workspacePath: string; + }): Promise { + const session = this.sessions.get(args.workspaceId); + if (!session) { + return; + } + + try { + const configuration = this.resolveConfiguration(args); + if (configuration !== null) { + await session.client.notify("workspace/didChangeConfiguration", { + settings: configuration, + }); + } + + if (this.options.refreshRequest) { + const refreshParams = + typeof this.options.refreshRequest.params === "function" + ? this.options.refreshRequest.params(args) + : this.options.refreshRequest.params; + await session.client.request( + this.options.refreshRequest.method, + refreshParams, + ); + } else { + for (const entry of session.openDocuments.values()) { + await this.sendDidChange( + session, + entry, + entry.version, + entry.content, + ); + } + } + session.lastError = null; + this.workspaceErrors.delete(args.workspaceId); + } catch (error) { + session.lastError = + error instanceof Error ? error.message : String(error); + this.workspaceErrors.set(args.workspaceId, session.lastError); + } + } + + getWorkspaceSummary(args: { + workspaceId: string; + workspacePath: string; + enabled: boolean; + }): LanguageServiceProviderSummary { + const session = this.sessions.get(args.workspaceId); + const lastError = + session?.lastError ?? this.workspaceErrors.get(args.workspaceId) ?? null; + + if (!args.enabled) { + return { + providerId: this.id, + label: this.label, + status: "disabled", + details: null, + documentCount: 0, + }; + } + + if (!session) { + return { + providerId: this.id, + label: this.label, + status: lastError ? "error" : "idle", + details: lastError, + documentCount: 0, + }; + } + + return { + providerId: this.id, + label: this.label, + status: lastError ? "error" : "ready", + details: lastError, + documentCount: session.openDocuments.size, + }; + } + + async disposeWorkspace(args: { + workspaceId: string; + workspacePath: string; + }): Promise { + const session = this.sessions.get(args.workspaceId); + if (session) { + await session.client.stop(); + this.sessions.delete(args.workspaceId); + } + + this.workspaceErrors.delete(args.workspaceId); + } + + private async ensureSession( + workspaceId: string, + workspacePath: string, + ): Promise { + const existing = this.sessions.get(workspaceId); + if (existing) { + return existing; + } + + const resolvedCommand = await this.options.resolveServerCommand({ + workspaceId, + workspacePath, + }); + if (!resolvedCommand) { + const message = `${this.label} language server is not available in this environment.`; + this.workspaceErrors.set(workspaceId, message); + throw new Error(message); + } + + let session!: WorkspaceSession; + const client = new StdioJsonRpcClient({ + name: `${this.id}:${workspaceId}`, + command: resolvedCommand.command, + args: resolvedCommand.args, + cwd: resolvedCommand.cwd ?? workspacePath, + env: resolvedCommand.env ?? process.env, + shell: resolvedCommand.shell, + onNotification: (message) => { + this.handleNotification(session, message); + }, + onRequest: async (message) => + await this.handleServerRequest(session, message), + onExit: ({ code, signal }) => { + const error = `${this.label} language server exited (${code ?? "null"}${signal ? `, ${signal}` : ""})`; + session.lastError = error; + this.workspaceErrors.set(workspaceId, error); + this.sessions.delete(workspaceId); + }, + onStderr: (chunk) => { + console.error(`[language-services/${this.id}] stderr`, { + workspaceId, + chunk, + }); + }, + }); + + session = { + workspaceId, + workspacePath, + client, + openDocuments: new Map(), + lastError: null, + textDocumentSyncMode: "full", + }; + + try { + await client.start(); + const workspaceUri = absolutePathToFileUri(workspacePath); + const initializeResult = await client.request("initialize", { + processId: process.pid, + clientInfo: { + name: "Superset Desktop", + version: "1.4.6", + }, + rootUri: workspaceUri, + rootPath: workspacePath, + workspaceFolders: [ + { + uri: workspaceUri, + name: this.workspaceFolderName(workspacePath), + }, + ], + capabilities: this.options.clientCapabilities ?? { + workspace: { + configuration: true, + workspaceFolders: true, + }, + textDocument: { + publishDiagnostics: { + relatedInformation: true, + }, + }, + }, + initializationOptions: this.resolveInitializationOptions({ + workspaceId, + workspacePath, + }), + }); + await client.notify("initialized", {}); + session.textDocumentSyncMode = + resolveTextDocumentSyncMode(initializeResult); + session.lastError = null; + this.workspaceErrors.delete(workspaceId); + this.sessions.set(workspaceId, session); + return session; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + session.lastError = message; + this.workspaceErrors.set(workspaceId, message); + await client.stop(); + throw error; + } + } + + private async sendDidChange( + session: WorkspaceSession, + previous: OpenDocumentEntry, + version: number, + content: string, + ): Promise { + await session.client.notify("textDocument/didChange", { + textDocument: { + uri: previous.uri, + version, + }, + contentChanges: + session.textDocumentSyncMode === "incremental" + ? [ + { + range: { + start: { line: 0, character: 0 }, + end: offsetToLspPosition( + previous.content, + previous.content.length, + ), + }, + text: content, + }, + ] + : [ + { + text: content, + }, + ], + }); + } + + private handleNotification( + session: WorkspaceSession, + message: { + method: string; + params?: unknown; + }, + ): void { + if (message.method !== "textDocument/publishDiagnostics") { + return; + } + + const params = message.params as + | { + uri?: string; + diagnostics?: LspDiagnostic[]; + } + | undefined; + if (!params?.uri) { + return; + } + + const absolutePath = fileUriToAbsolutePath(params.uri); + if (!absolutePath) { + return; + } + + languageDiagnosticsStore.setFileDiagnostics( + session.workspaceId, + this.fileKey(absolutePath), + (params.diagnostics ?? []).map((diagnostic) => + this.mapDiagnostic(session.workspacePath, absolutePath, diagnostic), + ), + ); + } + + private async handleServerRequest( + session: WorkspaceSession, + message: { + method: string; + params?: unknown; + }, + ): Promise { + switch (message.method) { + case "workspace/configuration": { + const items = (( + message.params as { + items?: Array<{ section?: string | null }> | null; + } + )?.items ?? []) as Array<{ section?: string | null }>; + const configuration = this.resolveConfiguration({ + workspaceId: session.workspaceId, + workspacePath: session.workspacePath, + }); + return items.map((item) => + getSectionValue(configuration, item.section), + ); + } + case "workspace/workspaceFolders": + return [ + { + uri: absolutePathToFileUri(session.workspacePath), + name: this.workspaceFolderName(session.workspacePath), + }, + ]; + case "client/registerCapability": + case "client/unregisterCapability": + case "window/workDoneProgress/create": + return null; + default: + return undefined; + } + } + + private mapDiagnostic( + workspacePath: string, + absolutePath: string, + diagnostic: LspDiagnostic, + ): LanguageServiceDiagnostic { + const relatedInformation = ( + diagnostic.relatedInformation ?? [] + ).map((item) => { + const relatedAbsolutePath = fileUriToAbsolutePath(item.location.uri); + return { + absolutePath: relatedAbsolutePath, + relativePath: relatedAbsolutePath + ? toRelativeWorkspacePath(workspacePath, relatedAbsolutePath) + : null, + line: item.location.range.start.line + 1, + column: item.location.range.start.character + 1, + endLine: item.location.range.end.line + 1, + endColumn: item.location.range.end.character + 1, + message: item.message, + }; + }); + + return { + providerId: this.id, + source: diagnostic.source ?? this.options.defaultSource ?? this.id, + absolutePath, + relativePath: toRelativeWorkspacePath(workspacePath, absolutePath), + line: diagnostic.range.start.line + 1, + column: diagnostic.range.start.character + 1, + endLine: diagnostic.range.end.line + 1, + endColumn: diagnostic.range.end.character + 1, + message: diagnostic.message, + code: + typeof diagnostic.code === "object" + ? (diagnostic.code?.value ?? null) + : (diagnostic.code ?? null), + severity: lspSeverityToLanguageServiceSeverity(diagnostic.severity), + relatedInformation, + }; + } + + private resolveInitializationOptions(args: ProviderArgs): unknown { + return typeof this.options.initializationOptions === "function" + ? this.options.initializationOptions(args) + : this.options.initializationOptions; + } + + private resolveConfiguration(args: ProviderArgs): unknown { + return typeof this.options.configuration === "function" + ? this.options.configuration(args) + : (this.options.configuration ?? null); + } + + private mapDocumentLanguageId(languageId: string): string { + return this.options.mapDocumentLanguageId?.(languageId) ?? languageId; + } + + private workspaceFolderName(workspacePath: string): string { + return workspacePath.split(/[\\/]/).at(-1) || workspacePath; + } + + private fileKey(absolutePath: string): string { + return `${this.id}::${absolutePath}`; + } +} diff --git a/apps/desktop/src/main/lib/language-services/lsp/StdioJsonRpcClient.ts b/apps/desktop/src/main/lib/language-services/lsp/StdioJsonRpcClient.ts new file mode 100644 index 00000000000..61fba479bc0 --- /dev/null +++ b/apps/desktop/src/main/lib/language-services/lsp/StdioJsonRpcClient.ts @@ -0,0 +1,306 @@ +import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; + +type JsonRpcId = number | string | null; + +type JsonRpcRequestMessage = { + jsonrpc: "2.0"; + id: JsonRpcId; + method: string; + params?: unknown; +}; + +type JsonRpcNotificationMessage = { + jsonrpc: "2.0"; + method: string; + params?: unknown; +}; + +type JsonRpcResponseMessage = { + jsonrpc: "2.0"; + id: JsonRpcId; + result?: unknown; + error?: { + code: number; + message: string; + data?: unknown; + }; +}; + +type JsonRpcMessage = + | JsonRpcRequestMessage + | JsonRpcNotificationMessage + | JsonRpcResponseMessage; + +type PendingRequest = { + resolve: (value: unknown) => void; + reject: (error: Error) => void; +}; + +type StdioJsonRpcClientOptions = { + name: string; + command: string; + args?: string[]; + cwd?: string; + env?: NodeJS.ProcessEnv; + shell?: boolean; + onNotification?: (message: JsonRpcNotificationMessage) => void; + onRequest?: (message: JsonRpcRequestMessage) => Promise | unknown; + onExit?: (payload: { + code: number | null; + signal: NodeJS.Signals | null; + }) => void; + onStderr?: (chunk: string) => void; +}; + +function isJsonRpcResponseMessage( + message: JsonRpcMessage, +): message is JsonRpcResponseMessage { + return "id" in message && !("method" in message); +} + +function isJsonRpcRequestMessage( + message: JsonRpcMessage, +): message is JsonRpcRequestMessage { + return "id" in message && "method" in message; +} + +function consumeMessage( + buffer: Buffer, +): { body: string; rest: Buffer } | null { + const separatorIndex = buffer.indexOf("\r\n\r\n"); + if (separatorIndex === -1) { + return null; + } + + const header = buffer.subarray(0, separatorIndex).toString("utf8"); + const contentLengthMatch = /Content-Length:\s*(\d+)/i.exec(header); + if (!contentLengthMatch) { + return null; + } + + const contentLength = Number(contentLengthMatch[1]); + const bodyStart = separatorIndex + 4; + const bodyEnd = bodyStart + contentLength; + if (buffer.length < bodyEnd) { + return null; + } + + return { + body: buffer.subarray(bodyStart, bodyEnd).toString("utf8"), + rest: buffer.subarray(bodyEnd), + }; +} + +export class StdioJsonRpcClient { + private process: ChildProcessWithoutNullStreams | null = null; + + private nextId = 0; + + private buffer: Buffer = Buffer.alloc(0); + + private readonly pendingRequests = new Map(); + + constructor(private readonly options: StdioJsonRpcClientOptions) {} + + async start(): Promise { + if (this.process) { + return; + } + + const child = spawn(this.options.command, this.options.args ?? [], { + cwd: this.options.cwd, + env: this.options.env, + shell: this.options.shell, + stdio: ["pipe", "pipe", "pipe"], + }); + + this.process = child; + child.stdout.on("data", (chunk: Buffer) => { + this.handleStdout(chunk); + }); + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk: string) => { + this.options.onStderr?.(chunk); + }); + child.on("exit", (code, signal) => { + this.process = null; + for (const pendingRequest of this.pendingRequests.values()) { + pendingRequest.reject( + new Error( + `${this.options.name} exited (${code ?? "null"}${signal ? `, ${signal}` : ""})`, + ), + ); + } + this.pendingRequests.clear(); + this.options.onExit?.({ code, signal }); + }); + child.on("error", (error) => { + this.process = null; + for (const pendingRequest of this.pendingRequests.values()) { + pendingRequest.reject(error); + } + this.pendingRequests.clear(); + }); + } + + async request(method: string, params?: unknown): Promise { + const id = ++this.nextId; + return await new Promise((resolve, reject) => { + this.pendingRequests.set(id, { resolve, reject }); + void this.writeMessage({ + jsonrpc: "2.0", + id, + method, + params, + }).catch((error) => { + this.pendingRequests.delete(id); + reject(error); + }); + }); + } + + async notify(method: string, params?: unknown): Promise { + await this.writeMessage({ + jsonrpc: "2.0", + method, + params, + }); + } + + async stop(): Promise { + if (!this.process) { + return; + } + + const child = this.process; + this.process = null; + child.removeAllListeners(); + if (!child.killed) { + child.kill(); + } + + for (const pendingRequest of this.pendingRequests.values()) { + pendingRequest.reject(new Error(`${this.options.name} stopped`)); + } + this.pendingRequests.clear(); + } + + private handleStdout(chunk: Buffer): void { + this.buffer = Buffer.concat([this.buffer, chunk]); + while (true) { + const message = consumeMessage(this.buffer); + if (!message) { + return; + } + + this.buffer = message.rest; + if (!message.body.trim()) { + continue; + } + + try { + const parsed = JSON.parse(message.body) as JsonRpcMessage; + this.handleMessage(parsed); + } catch (error) { + console.error( + "[language-services/lsp] Failed to parse JSON-RPC payload", + { + name: this.options.name, + error, + body: message.body, + }, + ); + } + } + } + + private handleMessage(message: JsonRpcMessage): void { + if (isJsonRpcResponseMessage(message)) { + const requestId = Number(message.id); + const pendingRequest = Number.isNaN(requestId) + ? null + : this.pendingRequests.get(requestId); + if (!pendingRequest) { + return; + } + + this.pendingRequests.delete(requestId); + if (message.error) { + pendingRequest.reject(new Error(message.error.message)); + return; + } + + pendingRequest.resolve(message.result); + return; + } + + if (isJsonRpcRequestMessage(message)) { + void this.handleServerRequest(message); + return; + } + + this.options.onNotification?.(message); + } + + private async handleServerRequest( + message: JsonRpcRequestMessage, + ): Promise { + try { + const result = + (await this.options.onRequest?.(message)) ?? + this.defaultRequestResult(message.method); + await this.writeMessage({ + jsonrpc: "2.0", + id: message.id, + result: result ?? null, + }); + } catch (error) { + await this.writeMessage({ + jsonrpc: "2.0", + id: message.id, + error: { + code: -32603, + message: error instanceof Error ? error.message : String(error), + }, + }); + } + } + + private defaultRequestResult(method: string): unknown { + switch (method) { + case "client/registerCapability": + case "client/unregisterCapability": + case "window/workDoneProgress/create": + return null; + case "workspace/configuration": + return []; + default: + throw new Error(`Unhandled JSON-RPC request: ${method}`); + } + } + + private async writeMessage(message: JsonRpcMessage): Promise { + const child = this.process; + if (!child) { + throw new Error(`${this.options.name} is not running`); + } + + const payload = Buffer.from(JSON.stringify(message), "utf8"); + const header = Buffer.from( + `Content-Length: ${payload.byteLength}\r\n\r\n`, + "utf8", + ); + const combined = Buffer.concat([header, payload]); + + await new Promise((resolve, reject) => { + child.stdin.write(combined, (error) => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }); + } +} diff --git a/apps/desktop/src/main/lib/language-services/lsp/command-resolvers.ts b/apps/desktop/src/main/lib/language-services/lsp/command-resolvers.ts new file mode 100644 index 00000000000..65fb45a4c55 --- /dev/null +++ b/apps/desktop/src/main/lib/language-services/lsp/command-resolvers.ts @@ -0,0 +1,103 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs/promises"; +import { createRequire } from "node:module"; +import path from "node:path"; + +const require = createRequire(import.meta.url); + +export type ResolvedLspCommand = { + command: string; + args?: string[]; + cwd?: string; + env?: NodeJS.ProcessEnv; + shell?: boolean; +}; + +type NodePackageCommandOptions = { + packageName: string; + binName?: string; + args?: string[]; + cwd?: string; + env?: NodeJS.ProcessEnv; +}; + +type ExecutableCandidate = { + command: string; + args?: string[]; + probeArgs?: string[]; + cwd?: string; + env?: NodeJS.ProcessEnv; + shell?: boolean; +}; + +export async function resolveNodePackageBinCommand( + options: NodePackageCommandOptions, +): Promise { + const packageJsonPath = require.resolve( + `${options.packageName}/package.json`, + ); + const packageRoot = path.dirname(packageJsonPath); + const packageJson = JSON.parse( + await fs.readFile(packageJsonPath, "utf8"), + ) as { + bin?: string | Record; + }; + + const binEntry = + typeof packageJson.bin === "string" + ? packageJson.bin + : options.binName + ? packageJson.bin?.[options.binName] + : Object.values(packageJson.bin ?? {})[0]; + + if (!binEntry) { + throw new Error( + `Package ${options.packageName} does not expose a runnable binary`, + ); + } + + return { + command: process.execPath, + args: [path.join(packageRoot, binEntry), ...(options.args ?? [])], + cwd: options.cwd, + env: { + ...process.env, + ...options.env, + ELECTRON_RUN_AS_NODE: "1", + }, + shell: false, + }; +} + +export function resolveAvailableExecutable( + candidates: ExecutableCandidate[], +): ResolvedLspCommand | null { + for (const candidate of candidates) { + const probeResult = spawnSync( + candidate.command, + candidate.probeArgs ?? ["--version"], + { + cwd: candidate.cwd, + env: { + ...process.env, + ...candidate.env, + }, + shell: candidate.shell, + stdio: "ignore", + }, + ); + if (probeResult.status !== 0) { + continue; + } + + return { + command: candidate.command, + args: candidate.args, + cwd: candidate.cwd, + env: candidate.env, + shell: candidate.shell, + }; + } + + return null; +} diff --git a/apps/desktop/src/main/lib/language-services/manager.ts b/apps/desktop/src/main/lib/language-services/manager.ts new file mode 100644 index 00000000000..4bec13e01d0 --- /dev/null +++ b/apps/desktop/src/main/lib/language-services/manager.ts @@ -0,0 +1,199 @@ +import { languageDiagnosticsStore } from "./diagnostics-store"; +import { CssLanguageProvider } from "./providers/css/CssLanguageProvider"; +import { DartLanguageProvider } from "./providers/dart/DartLanguageProvider"; +import { DockerfileLanguageProvider } from "./providers/dockerfile/DockerfileLanguageProvider"; +import { GoLanguageProvider } from "./providers/go/GoLanguageProvider"; +import { GraphqlLanguageProvider } from "./providers/graphql/GraphqlLanguageProvider"; +import { HtmlLanguageProvider } from "./providers/html/HtmlLanguageProvider"; +import { JsonLanguageProvider } from "./providers/json/JsonLanguageProvider"; +import { PythonLanguageProvider } from "./providers/python/PythonLanguageProvider"; +import { RustLanguageProvider } from "./providers/rust/RustLanguageProvider"; +import { TomlLanguageProvider } from "./providers/toml/TomlLanguageProvider"; +import { TypeScriptLanguageProvider } from "./providers/typescript/TypeScriptLanguageProvider"; +import { YamlLanguageProvider } from "./providers/yaml/YamlLanguageProvider"; +import type { + LanguageServiceDocument, + LanguageServiceProvider, + LanguageServiceProviderDescriptor, + LanguageServiceWorkspaceSnapshot, +} from "./types"; + +export class LanguageServiceManager { + private readonly providers: LanguageServiceProvider[] = [ + new TypeScriptLanguageProvider(), + new JsonLanguageProvider(), + new YamlLanguageProvider(), + new HtmlLanguageProvider(), + new CssLanguageProvider(), + new TomlLanguageProvider(), + new DartLanguageProvider(), + new PythonLanguageProvider(), + new GoLanguageProvider(), + new RustLanguageProvider(), + new DockerfileLanguageProvider(), + new GraphqlLanguageProvider(), + ]; + + private readonly enabledProviders = new Map( + this.providers.map((provider) => [provider.id, true] as const), + ); + + private readonly knownWorkspaces = new Map(); + + async syncDocument(document: LanguageServiceDocument): Promise { + this.rememberWorkspace(document.workspaceId, document.workspacePath); + const provider = this.resolveProvider(document.languageId); + if (!provider || !this.isProviderEnabled(provider.id)) { + return; + } + + await provider.changeDocument(document); + } + + async openDocument(document: LanguageServiceDocument): Promise { + this.rememberWorkspace(document.workspaceId, document.workspacePath); + const provider = this.resolveProvider(document.languageId); + if (!provider || !this.isProviderEnabled(provider.id)) { + return; + } + + await provider.openDocument(document); + } + + async closeDocument(args: { + workspaceId: string; + workspacePath: string; + absolutePath: string; + languageId: string; + }): Promise { + const provider = this.resolveProvider(args.languageId); + if (!provider) { + return; + } + + await provider.closeDocument(args); + } + + async refreshWorkspace(args: { + workspaceId: string; + workspacePath: string; + }): Promise { + this.rememberWorkspace(args.workspaceId, args.workspacePath); + await Promise.all( + this.providers + .filter((provider) => this.isProviderEnabled(provider.id)) + .map((provider) => provider.refreshWorkspace(args)), + ); + } + + async disposeWorkspace(args: { + workspaceId: string; + workspacePath: string; + }): Promise { + this.knownWorkspaces.delete(args.workspaceId); + await Promise.all( + this.providers.map((provider) => provider.disposeWorkspace(args)), + ); + languageDiagnosticsStore.clearWorkspace(args.workspaceId); + } + + getWorkspaceSnapshot(args: { + workspaceId: string; + workspacePath: string; + }): LanguageServiceWorkspaceSnapshot { + this.rememberWorkspace(args.workspaceId, args.workspacePath); + return languageDiagnosticsStore.createSnapshot({ + workspaceId: args.workspaceId, + workspacePath: args.workspacePath, + providers: this.providers.map((provider) => + provider.getWorkspaceSummary({ + workspaceId: args.workspaceId, + workspacePath: args.workspacePath, + enabled: this.isProviderEnabled(provider.id), + }), + ), + }); + } + + getProviders(): LanguageServiceProviderDescriptor[] { + return this.providers.map((provider) => ({ + providerId: provider.id, + label: provider.label, + description: provider.description, + languageIds: provider.languageIds, + enabled: this.isProviderEnabled(provider.id), + })); + } + + async setProviderEnabled( + providerId: string, + enabled: boolean, + ): Promise { + const provider = this.providers.find( + (candidate) => candidate.id === providerId, + ); + if (!provider) { + return null; + } + + const previous = this.isProviderEnabled(providerId); + if (previous === enabled) { + return { + providerId: provider.id, + label: provider.label, + description: provider.description, + languageIds: provider.languageIds, + enabled, + }; + } + + this.enabledProviders.set(providerId, enabled); + + if (!enabled) { + await Promise.all( + Array.from(this.knownWorkspaces.entries()).map( + async ([workspaceId, workspacePath]) => { + await provider.disposeWorkspace({ + workspaceId, + workspacePath, + }); + }, + ), + ); + languageDiagnosticsStore.clearProviderDiagnostics(providerId); + } + + return { + providerId: provider.id, + label: provider.label, + description: provider.description, + languageIds: provider.languageIds, + enabled, + }; + } + + subscribeToWorkspace( + workspaceId: string, + listener: (payload: { version: number }) => void, + ) { + return languageDiagnosticsStore.subscribe(workspaceId, listener); + } + + private isProviderEnabled(providerId: string): boolean { + return this.enabledProviders.get(providerId) ?? false; + } + + private rememberWorkspace(workspaceId: string, workspacePath: string): void { + this.knownWorkspaces.set(workspaceId, workspacePath); + } + + private resolveProvider(languageId: string): LanguageServiceProvider | null { + return ( + this.providers.find((provider) => + provider.supportsLanguage(languageId), + ) ?? null + ); + } +} + +export const languageServiceManager = new LanguageServiceManager(); diff --git a/apps/desktop/src/main/lib/language-services/providers/css/CssLanguageProvider.ts b/apps/desktop/src/main/lib/language-services/providers/css/CssLanguageProvider.ts new file mode 100644 index 00000000000..ab7963edd63 --- /dev/null +++ b/apps/desktop/src/main/lib/language-services/providers/css/CssLanguageProvider.ts @@ -0,0 +1,251 @@ +import { + type Diagnostic, + getCSSLanguageService, + getLESSLanguageService, + getSCSSLanguageService, +} from "vscode-css-languageservice"; +import { TextDocument } from "vscode-languageserver-textdocument"; +import { languageDiagnosticsStore } from "../../diagnostics-store"; +import type { + LanguageServiceDiagnostic, + LanguageServiceDocument, + LanguageServiceProvider, + LanguageServiceProviderSummary, +} from "../../types"; +import { + absolutePathToFileUri, + lspSeverityToLanguageServiceSeverity, + toRelativeWorkspacePath, +} from "../../utils"; + +type OpenDocumentEntry = { + languageId: string; + version: number; + content: string; +}; + +type WorkspaceState = { + documents: Map; + lastError: string | null; +}; + +export class CssLanguageProvider implements LanguageServiceProvider { + readonly id = "css"; + + readonly label = "CSS"; + + readonly description = + "CSS, SCSS and LESS diagnostics via vscode-css-languageservice."; + + readonly languageIds = ["css", "scss", "less"]; + + private readonly workspaces = new Map(); + + private readonly cssService = getCSSLanguageService(); + + private readonly scssService = getSCSSLanguageService(); + + private readonly lessService = getLESSLanguageService(); + + supportsLanguage(languageId: string): boolean { + return this.languageIds.includes(languageId); + } + + async openDocument(document: LanguageServiceDocument): Promise { + const workspaceState = this.getOrCreateWorkspaceState(document.workspaceId); + workspaceState.documents.set(document.absolutePath, { + languageId: document.languageId, + version: document.version, + content: document.content, + }); + await this.validateDocument(document, workspaceState); + } + + async changeDocument(document: LanguageServiceDocument): Promise { + const workspaceState = this.getOrCreateWorkspaceState(document.workspaceId); + workspaceState.documents.set(document.absolutePath, { + languageId: document.languageId, + version: document.version, + content: document.content, + }); + await this.validateDocument(document, workspaceState); + } + + async closeDocument(args: { + workspaceId: string; + workspacePath: string; + absolutePath: string; + languageId: string; + }): Promise { + const workspaceState = this.workspaces.get(args.workspaceId); + if (!workspaceState) { + return; + } + + workspaceState.documents.delete(args.absolutePath); + languageDiagnosticsStore.clearFileDiagnostics( + args.workspaceId, + this.fileKey(args.absolutePath), + ); + + if (workspaceState.documents.size === 0) { + this.workspaces.delete(args.workspaceId); + } + } + + async refreshWorkspace(args: { + workspaceId: string; + workspacePath: string; + }): Promise { + const workspaceState = this.workspaces.get(args.workspaceId); + if (!workspaceState) { + return; + } + + for (const [absolutePath, entry] of workspaceState.documents.entries()) { + await this.validateDocument( + { + workspaceId: args.workspaceId, + workspacePath: args.workspacePath, + absolutePath, + languageId: entry.languageId, + content: entry.content, + version: entry.version, + }, + workspaceState, + ); + } + } + + getWorkspaceSummary(args: { + workspaceId: string; + workspacePath: string; + enabled: boolean; + }): LanguageServiceProviderSummary { + const workspaceState = this.workspaces.get(args.workspaceId); + if (!args.enabled) { + return { + providerId: this.id, + label: this.label, + status: "disabled", + details: null, + documentCount: 0, + }; + } + + if (!workspaceState) { + return { + providerId: this.id, + label: this.label, + status: "idle", + details: null, + documentCount: 0, + }; + } + + return { + providerId: this.id, + label: this.label, + status: workspaceState.lastError ? "error" : "ready", + details: workspaceState.lastError, + documentCount: workspaceState.documents.size, + }; + } + + async disposeWorkspace(args: { + workspaceId: string; + workspacePath: string; + }): Promise { + this.workspaces.delete(args.workspaceId); + } + + private getOrCreateWorkspaceState(workspaceId: string): WorkspaceState { + const existing = this.workspaces.get(workspaceId); + if (existing) { + return existing; + } + + const next: WorkspaceState = { + documents: new Map(), + lastError: null, + }; + this.workspaces.set(workspaceId, next); + return next; + } + + private async validateDocument( + document: LanguageServiceDocument, + workspaceState: WorkspaceState, + ): Promise { + try { + const textDocument = TextDocument.create( + absolutePathToFileUri(document.absolutePath), + document.languageId, + document.version, + document.content, + ); + const languageService = this.getLanguageService(document.languageId); + const stylesheet = languageService.parseStylesheet(textDocument); + const diagnostics = languageService.doValidation( + textDocument, + stylesheet, + ); + workspaceState.lastError = null; + languageDiagnosticsStore.setFileDiagnostics( + document.workspaceId, + this.fileKey(document.absolutePath), + diagnostics.map((diagnostic) => + this.mapDiagnostic( + document.workspacePath, + document.absolutePath, + diagnostic, + ), + ), + ); + } catch (error) { + workspaceState.lastError = + error instanceof Error ? error.message : String(error); + languageDiagnosticsStore.setFileDiagnostics( + document.workspaceId, + this.fileKey(document.absolutePath), + [], + ); + } + } + + private getLanguageService(languageId: string) { + switch (languageId) { + case "scss": + return this.scssService; + case "less": + return this.lessService; + default: + return this.cssService; + } + } + + private mapDiagnostic( + workspacePath: string, + absolutePath: string, + diagnostic: Diagnostic, + ): LanguageServiceDiagnostic { + return { + providerId: this.id, + source: diagnostic.source ?? "css", + absolutePath, + relativePath: toRelativeWorkspacePath(workspacePath, absolutePath), + line: diagnostic.range.start.line + 1, + column: diagnostic.range.start.character + 1, + endLine: diagnostic.range.end.line + 1, + endColumn: diagnostic.range.end.character + 1, + message: diagnostic.message, + code: diagnostic.code ?? null, + severity: lspSeverityToLanguageServiceSeverity(diagnostic.severity), + relatedInformation: [], + }; + } + + private fileKey(absolutePath: string): string { + return `${this.id}::${absolutePath}`; + } +} diff --git a/apps/desktop/src/main/lib/language-services/providers/dart/DartLanguageProvider.ts b/apps/desktop/src/main/lib/language-services/providers/dart/DartLanguageProvider.ts new file mode 100644 index 00000000000..011f4ae2a8a --- /dev/null +++ b/apps/desktop/src/main/lib/language-services/providers/dart/DartLanguageProvider.ts @@ -0,0 +1,572 @@ +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { languageDiagnosticsStore } from "../../diagnostics-store"; +import { StdioJsonRpcClient } from "../../lsp/StdioJsonRpcClient"; +import type { + LanguageServiceDiagnostic, + LanguageServiceDocument, + LanguageServiceProvider, + LanguageServiceProviderSummary, + LanguageServiceRelatedInformation, +} from "../../types"; +import { + absolutePathToFileUri, + fileUriToAbsolutePath, + lspSeverityToLanguageServiceSeverity, + offsetToLspPosition, + toRelativeWorkspacePath, +} from "../../utils"; + +type OpenDocumentEntry = { + languageId: string; + version: number; + content: string; + uri: string; +}; + +type DartDiagnostic = { + range: { + start: { line: number; character: number }; + end: { line: number; character: number }; + }; + severity?: number; + code?: string | number; + source?: string; + message: string; + relatedInformation?: Array<{ + location: { + uri: string; + range: { + start: { line: number; character: number }; + end: { line: number; character: number }; + }; + }; + message: string; + }>; +}; + +type WorkspaceSession = { + workspaceId: string; + workspacePath: string; + dartCommand: string; + client: StdioJsonRpcClient; + openDocuments: Map; + lastError: string | null; + textDocumentSyncMode: "full" | "incremental"; +}; + +type ResolvedDartCommand = { + command: string; + shell: boolean; +}; + +function canExecute(command: string, shell: boolean): boolean { + const probe = spawnSync(command, ["--version"], { + stdio: "ignore", + shell, + }); + return probe.status === 0; +} + +function getEnvCandidateCommands(): string[] { + const executableName = process.platform === "win32" ? "dart.exe" : "dart"; + const wrapperName = process.platform === "win32" ? "dart.bat" : "dart"; + return [ + process.env.DART_SDK + ? path.join(process.env.DART_SDK, "bin", executableName) + : null, + process.env.FLUTTER_ROOT + ? path.join(process.env.FLUTTER_ROOT, "bin", wrapperName) + : null, + process.env.FLUTTER_ROOT + ? path.join( + process.env.FLUTTER_ROOT, + "bin", + "cache", + "dart-sdk", + "bin", + executableName, + ) + : null, + ].filter((candidate): candidate is string => Boolean(candidate)); +} + +function resolveFlutterSdkCommands(): string[] { + const flutterCommand = + process.platform === "win32" ? "flutter.bat" : "flutter"; + const locateCommand = process.platform === "win32" ? "where" : "which"; + const locateResult = spawnSync(locateCommand, [flutterCommand], { + encoding: "utf8", + shell: process.platform === "win32", + }); + if (locateResult.status !== 0 || !locateResult.stdout) { + return []; + } + + const flutterExecutablePath = locateResult.stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean); + if (!flutterExecutablePath) { + return []; + } + + const flutterBinDir = path.dirname(flutterExecutablePath); + const executableName = process.platform === "win32" ? "dart.exe" : "dart"; + const wrapperName = process.platform === "win32" ? "dart.bat" : "dart"; + + return [ + path.join(flutterBinDir, wrapperName), + path.join(flutterBinDir, "cache", "dart-sdk", "bin", executableName), + ]; +} + +function resolveDartCommand(): ResolvedDartCommand | null { + const pathCommand = process.platform === "win32" ? "dart.bat" : "dart"; + const shell = process.platform === "win32"; + if (canExecute(pathCommand, shell)) { + return { + command: pathCommand, + shell, + }; + } + + for (const candidate of [ + ...getEnvCandidateCommands(), + ...resolveFlutterSdkCommands(), + ]) { + if (!canExecute(candidate, false)) { + continue; + } + + return { + command: candidate, + shell: false, + }; + } + + return null; +} + +function resolveTextDocumentSyncMode(result: unknown): "full" | "incremental" { + const textDocumentSync = ( + result as { + capabilities?: { + textDocumentSync?: + | number + | { + change?: number; + }; + }; + } + )?.capabilities?.textDocumentSync; + + if (typeof textDocumentSync === "number") { + return textDocumentSync === 2 ? "incremental" : "full"; + } + + if ( + textDocumentSync && + typeof textDocumentSync === "object" && + textDocumentSync.change === 2 + ) { + return "incremental"; + } + + return "full"; +} + +export class DartLanguageProvider implements LanguageServiceProvider { + readonly id = "dart"; + + readonly label = "Dart"; + + readonly description = + "Dart and Flutter diagnostics via the Dart language server."; + + readonly languageIds = ["dart"]; + + private readonly sessions = new Map(); + + private readonly workspaceErrors = new Map(); + + supportsLanguage(languageId: string): boolean { + return languageId === "dart"; + } + + async openDocument(document: LanguageServiceDocument): Promise { + const session = await this.ensureSession( + document.workspaceId, + document.workspacePath, + ); + const uri = absolutePathToFileUri(document.absolutePath); + session.openDocuments.set(document.absolutePath, { + languageId: document.languageId, + version: document.version, + content: document.content, + uri, + }); + await session.client.notify("textDocument/didOpen", { + textDocument: { + uri, + languageId: "dart", + version: document.version, + text: document.content, + }, + }); + } + + async changeDocument(document: LanguageServiceDocument): Promise { + const session = await this.ensureSession( + document.workspaceId, + document.workspacePath, + ); + const previous = session.openDocuments.get(document.absolutePath); + if (!previous) { + await this.openDocument(document); + return; + } + + session.openDocuments.set(document.absolutePath, { + languageId: document.languageId, + version: document.version, + content: document.content, + uri: previous.uri, + }); + + await session.client.notify("textDocument/didChange", { + textDocument: { + uri: previous.uri, + version: document.version, + }, + contentChanges: + session.textDocumentSyncMode === "incremental" + ? [ + { + range: { + start: { line: 0, character: 0 }, + end: offsetToLspPosition( + previous.content, + previous.content.length, + ), + }, + text: document.content, + }, + ] + : [ + { + text: document.content, + }, + ], + }); + } + + async closeDocument(args: { + workspaceId: string; + workspacePath: string; + absolutePath: string; + languageId: string; + }): Promise { + const session = this.sessions.get(args.workspaceId); + if (!session) { + return; + } + + const existing = session.openDocuments.get(args.absolutePath); + session.openDocuments.delete(args.absolutePath); + languageDiagnosticsStore.clearFileDiagnostics( + args.workspaceId, + this.fileKey(args.absolutePath), + ); + + if (existing) { + await session.client.notify("textDocument/didClose", { + textDocument: { + uri: existing.uri, + }, + }); + } + + if (session.openDocuments.size === 0) { + await this.disposeWorkspace(args); + } + } + + async refreshWorkspace(args: { + workspaceId: string; + workspacePath: string; + }): Promise { + const session = this.sessions.get(args.workspaceId); + if (!session) { + return; + } + + try { + await session.client.request("dart/reanalyze"); + session.lastError = null; + } catch (error) { + session.lastError = + error instanceof Error ? error.message : String(error); + this.workspaceErrors.set(args.workspaceId, session.lastError); + } + } + + getWorkspaceSummary(args: { + workspaceId: string; + workspacePath: string; + enabled: boolean; + }): LanguageServiceProviderSummary { + const session = this.sessions.get(args.workspaceId); + const lastError = + session?.lastError ?? this.workspaceErrors.get(args.workspaceId) ?? null; + + if (!args.enabled) { + return { + providerId: this.id, + label: this.label, + status: "disabled", + details: null, + documentCount: 0, + }; + } + + if (!session) { + return { + providerId: this.id, + label: this.label, + status: lastError ? "error" : "idle", + details: lastError, + documentCount: 0, + }; + } + + return { + providerId: this.id, + label: this.label, + status: lastError ? "error" : "ready", + details: lastError, + documentCount: session.openDocuments.size, + }; + } + + async disposeWorkspace(args: { + workspaceId: string; + workspacePath: string; + }): Promise { + const session = this.sessions.get(args.workspaceId); + if (session) { + await session.client.stop(); + this.sessions.delete(args.workspaceId); + } + + this.workspaceErrors.delete(args.workspaceId); + } + + private async ensureSession( + workspaceId: string, + workspacePath: string, + ): Promise { + const existing = this.sessions.get(workspaceId); + if (existing) { + return existing; + } + + const resolvedDartCommand = resolveDartCommand(); + if (!resolvedDartCommand) { + const error = + "dart command not found. Install Dart or Flutter, or set DART_SDK / FLUTTER_ROOT."; + this.workspaceErrors.set(workspaceId, error); + throw new Error(error); + } + + let session!: WorkspaceSession; + const client = new StdioJsonRpcClient({ + name: `dart:${workspaceId}`, + command: resolvedDartCommand.command, + args: [ + "language-server", + "--client-id", + "superset.desktop", + "--client-version", + "1.4.6", + ], + cwd: workspacePath, + env: process.env, + shell: resolvedDartCommand.shell, + onNotification: (message) => { + this.handleNotification(session, message); + }, + onRequest: async (message) => await this.handleServerRequest(message), + onExit: ({ code, signal }) => { + const error = `dart language-server exited (${code ?? "null"}${signal ? `, ${signal}` : ""})`; + session.lastError = error; + this.workspaceErrors.set(workspaceId, error); + this.sessions.delete(workspaceId); + }, + onStderr: (chunk) => { + console.error("[language-services/dart] stderr", { + workspaceId, + chunk, + }); + }, + }); + + session = { + workspaceId, + workspacePath, + dartCommand: resolvedDartCommand.command, + client, + openDocuments: new Map(), + lastError: null, + textDocumentSyncMode: "full", + }; + + try { + await client.start(); + const workspaceUri = absolutePathToFileUri(workspacePath); + const initializeResult = await client.request("initialize", { + processId: process.pid, + clientInfo: { + name: "Superset Desktop", + version: "1.4.6", + }, + rootUri: workspaceUri, + rootPath: workspacePath, + workspaceFolders: [ + { + uri: workspaceUri, + name: path.basename(workspacePath), + }, + ], + capabilities: { + workspace: { + configuration: true, + workspaceFolders: true, + }, + textDocument: { + publishDiagnostics: { + relatedInformation: true, + }, + }, + }, + initializationOptions: { + onlyAnalyzeProjectsWithOpenFiles: true, + }, + }); + await client.notify("initialized", {}); + session.textDocumentSyncMode = + resolveTextDocumentSyncMode(initializeResult); + session.lastError = null; + this.workspaceErrors.delete(workspaceId); + this.sessions.set(workspaceId, session); + return session; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + session.lastError = message; + this.workspaceErrors.set(workspaceId, message); + await client.stop(); + throw error; + } + } + + private handleNotification( + session: WorkspaceSession, + message: { + method: string; + params?: unknown; + }, + ): void { + if (message.method !== "textDocument/publishDiagnostics") { + return; + } + + const params = message.params as + | { + uri?: string; + diagnostics?: DartDiagnostic[]; + } + | undefined; + if (!params?.uri) { + return; + } + + const absolutePath = fileUriToAbsolutePath(params.uri); + if (!absolutePath) { + return; + } + + languageDiagnosticsStore.setFileDiagnostics( + session.workspaceId, + this.fileKey(absolutePath), + (params.diagnostics ?? []).map((diagnostic) => + this.mapDiagnostic(session.workspacePath, absolutePath, diagnostic), + ), + ); + } + + private async handleServerRequest(message: { + method: string; + params?: unknown; + }): Promise { + if (message.method !== "workspace/configuration") { + return undefined; + } + + const items = (( + message.params as { items?: Array<{ section?: string | null }> | null } + )?.items ?? []) as Array<{ section?: string | null }>; + return items.map((item) => { + if (item.section === "dart") { + return { + showTodos: false, + }; + } + + return null; + }); + } + + private mapDiagnostic( + workspacePath: string, + absolutePath: string, + diagnostic: DartDiagnostic, + ): LanguageServiceDiagnostic { + const relatedInformation = ( + diagnostic.relatedInformation ?? [] + ).map((item) => { + const relatedAbsolutePath = + fileUriToAbsolutePath(item.location.uri) ?? absolutePath; + return { + absolutePath: relatedAbsolutePath, + relativePath: toRelativeWorkspacePath( + workspacePath, + relatedAbsolutePath, + ), + line: item.location.range.start.line + 1, + column: item.location.range.start.character + 1, + endLine: item.location.range.end.line + 1, + endColumn: item.location.range.end.character + 1, + message: item.message, + }; + }); + + return { + providerId: this.id, + source: diagnostic.source ?? "dart", + absolutePath, + relativePath: toRelativeWorkspacePath(workspacePath, absolutePath), + line: diagnostic.range.start.line + 1, + column: diagnostic.range.start.character + 1, + endLine: diagnostic.range.end.line + 1, + endColumn: diagnostic.range.end.character + 1, + message: diagnostic.message, + code: diagnostic.code ?? null, + severity: lspSeverityToLanguageServiceSeverity(diagnostic.severity), + relatedInformation, + }; + } + + private fileKey(absolutePath: string): string { + return `${this.id}::${absolutePath}`; + } +} diff --git a/apps/desktop/src/main/lib/language-services/providers/dockerfile/DockerfileLanguageProvider.ts b/apps/desktop/src/main/lib/language-services/providers/dockerfile/DockerfileLanguageProvider.ts new file mode 100644 index 00000000000..93623e30d2b --- /dev/null +++ b/apps/desktop/src/main/lib/language-services/providers/dockerfile/DockerfileLanguageProvider.ts @@ -0,0 +1,22 @@ +import { resolveNodePackageBinCommand } from "../../lsp/command-resolvers"; +import { ExternalLspLanguageProvider } from "../../lsp/ExternalLspLanguageProvider"; + +export class DockerfileLanguageProvider extends ExternalLspLanguageProvider { + constructor() { + super({ + id: "dockerfile", + label: "Dockerfile", + description: + "Dockerfile diagnostics via dockerfile-language-server-nodejs.", + languageIds: ["dockerfile"], + defaultSource: "dockerfile", + resolveServerCommand: async ({ workspacePath }) => + await resolveNodePackageBinCommand({ + packageName: "dockerfile-language-server-nodejs", + binName: "docker-langserver", + args: ["--stdio"], + cwd: workspacePath, + }), + }); + } +} diff --git a/apps/desktop/src/main/lib/language-services/providers/go/GoLanguageProvider.ts b/apps/desktop/src/main/lib/language-services/providers/go/GoLanguageProvider.ts new file mode 100644 index 00000000000..1ae6c4cb006 --- /dev/null +++ b/apps/desktop/src/main/lib/language-services/providers/go/GoLanguageProvider.ts @@ -0,0 +1,23 @@ +import { resolveAvailableExecutable } from "../../lsp/command-resolvers"; +import { ExternalLspLanguageProvider } from "../../lsp/ExternalLspLanguageProvider"; + +export class GoLanguageProvider extends ExternalLspLanguageProvider { + constructor() { + super({ + id: "go", + label: "Go", + description: "Go diagnostics via gopls.", + languageIds: ["go"], + defaultSource: "gopls", + resolveServerCommand: () => + resolveAvailableExecutable([ + { + command: process.platform === "win32" ? "gopls.exe" : "gopls", + args: ["serve"], + probeArgs: ["version"], + shell: false, + }, + ]), + }); + } +} diff --git a/apps/desktop/src/main/lib/language-services/providers/graphql/GraphqlLanguageProvider.ts b/apps/desktop/src/main/lib/language-services/providers/graphql/GraphqlLanguageProvider.ts new file mode 100644 index 00000000000..bbdcc2ec7dd --- /dev/null +++ b/apps/desktop/src/main/lib/language-services/providers/graphql/GraphqlLanguageProvider.ts @@ -0,0 +1,28 @@ +import { resolveNodePackageBinCommand } from "../../lsp/command-resolvers"; +import { ExternalLspLanguageProvider } from "../../lsp/ExternalLspLanguageProvider"; + +export class GraphqlLanguageProvider extends ExternalLspLanguageProvider { + constructor() { + super({ + id: "graphql", + label: "GraphQL", + description: "GraphQL diagnostics via graphql-language-service-cli.", + languageIds: ["graphql"], + defaultSource: "graphql", + resolveServerCommand: async ({ workspacePath }) => + await resolveNodePackageBinCommand({ + packageName: "graphql-language-service-cli", + binName: "graphql-lsp", + args: ["server", "-m", "stream"], + cwd: workspacePath, + }), + configuration: { + "graphql-config": { + load: { + legacy: true, + }, + }, + }, + }); + } +} diff --git a/apps/desktop/src/main/lib/language-services/providers/html/HtmlLanguageProvider.ts b/apps/desktop/src/main/lib/language-services/providers/html/HtmlLanguageProvider.ts new file mode 100644 index 00000000000..81916bc5f4a --- /dev/null +++ b/apps/desktop/src/main/lib/language-services/providers/html/HtmlLanguageProvider.ts @@ -0,0 +1,21 @@ +import { resolveNodePackageBinCommand } from "../../lsp/command-resolvers"; +import { ExternalLspLanguageProvider } from "../../lsp/ExternalLspLanguageProvider"; + +export class HtmlLanguageProvider extends ExternalLspLanguageProvider { + constructor() { + super({ + id: "html", + label: "HTML", + description: "HTML diagnostics via vscode-html-language-server.", + languageIds: ["html"], + defaultSource: "html", + resolveServerCommand: async ({ workspacePath }) => + await resolveNodePackageBinCommand({ + packageName: "vscode-langservers-extracted", + binName: "vscode-html-language-server", + args: ["--stdio"], + cwd: workspacePath, + }), + }); + } +} diff --git a/apps/desktop/src/main/lib/language-services/providers/json/JsonLanguageProvider.ts b/apps/desktop/src/main/lib/language-services/providers/json/JsonLanguageProvider.ts new file mode 100644 index 00000000000..d8307306d69 --- /dev/null +++ b/apps/desktop/src/main/lib/language-services/providers/json/JsonLanguageProvider.ts @@ -0,0 +1,306 @@ +import fs from "node:fs/promises"; +import { + type Diagnostic, + getLanguageService, +} from "vscode-json-languageservice"; +import { TextDocument } from "vscode-languageserver-textdocument"; +import { languageDiagnosticsStore } from "../../diagnostics-store"; +import type { + LanguageServiceDiagnostic, + LanguageServiceDocument, + LanguageServiceProvider, + LanguageServiceProviderSummary, +} from "../../types"; +import { + absolutePathToFileUri, + fileUriToAbsolutePath, + lspSeverityToLanguageServiceSeverity, + toRelativeWorkspacePath, +} from "../../utils"; + +type OpenDocumentEntry = { + languageId: string; + version: number; + content: string; +}; + +type WorkspaceState = { + documents: Map; + lastError: string | null; +}; + +const KNOWN_JSON_SCHEMAS = [ + { + uri: "https://json.schemastore.org/package.json", + fileMatch: ["package.json"], + }, + { + uri: "https://json.schemastore.org/tsconfig.json", + fileMatch: ["tsconfig.json", "tsconfig.*.json"], + }, + { + uri: "https://json.schemastore.org/jsconfig.json", + fileMatch: ["jsconfig.json"], + }, + { + uri: "https://json.schemastore.org/bunfig.json", + fileMatch: ["bunfig.json", "bunfig.*.json"], + }, + { + uri: "https://json.schemastore.org/turbo.json", + fileMatch: ["turbo.json"], + }, +]; + +export class JsonLanguageProvider implements LanguageServiceProvider { + readonly id = "json"; + + readonly label = "JSON"; + + readonly description = + "JSON and JSONC diagnostics via vscode-json-languageservice."; + + readonly languageIds = ["json", "jsonc"]; + + private readonly workspaces = new Map(); + + private readonly jsonService = getLanguageService({ + schemaRequestService: async (uri) => { + if (uri.startsWith("file://")) { + return await fs.readFile(new URL(uri), "utf8"); + } + + const response = await fetch(uri); + if (!response.ok) { + throw new Error(`Failed to load schema: ${uri} (${response.status})`); + } + + return await response.text(); + }, + }); + + constructor() { + this.jsonService.configure({ + validate: true, + allowComments: false, + schemas: KNOWN_JSON_SCHEMAS, + }); + } + + supportsLanguage(languageId: string): boolean { + return languageId === "json" || languageId === "jsonc"; + } + + async openDocument(document: LanguageServiceDocument): Promise { + const workspaceState = this.getOrCreateWorkspaceState(document.workspaceId); + workspaceState.documents.set(document.absolutePath, { + languageId: document.languageId, + version: document.version, + content: document.content, + }); + await this.validateDocument(document, workspaceState); + } + + async changeDocument(document: LanguageServiceDocument): Promise { + const workspaceState = this.getOrCreateWorkspaceState(document.workspaceId); + workspaceState.documents.set(document.absolutePath, { + languageId: document.languageId, + version: document.version, + content: document.content, + }); + await this.validateDocument(document, workspaceState); + } + + async closeDocument(args: { + workspaceId: string; + workspacePath: string; + absolutePath: string; + languageId: string; + }): Promise { + const workspaceState = this.workspaces.get(args.workspaceId); + if (!workspaceState) { + return; + } + + workspaceState.documents.delete(args.absolutePath); + languageDiagnosticsStore.clearFileDiagnostics( + args.workspaceId, + this.fileKey(args.absolutePath), + ); + + if (workspaceState.documents.size === 0) { + this.workspaces.delete(args.workspaceId); + } + } + + async refreshWorkspace(args: { + workspaceId: string; + workspacePath: string; + }): Promise { + const workspaceState = this.workspaces.get(args.workspaceId); + if (!workspaceState) { + return; + } + + for (const [absolutePath, entry] of workspaceState.documents.entries()) { + await this.validateDocument( + { + workspaceId: args.workspaceId, + workspacePath: args.workspacePath, + absolutePath, + languageId: entry.languageId, + content: entry.content, + version: entry.version, + }, + workspaceState, + ); + } + } + + getWorkspaceSummary(args: { + workspaceId: string; + workspacePath: string; + enabled: boolean; + }): LanguageServiceProviderSummary { + const workspaceState = this.workspaces.get(args.workspaceId); + if (!args.enabled) { + return { + providerId: this.id, + label: this.label, + status: "disabled", + details: null, + documentCount: 0, + }; + } + + if (!workspaceState) { + return { + providerId: this.id, + label: this.label, + status: "idle", + details: null, + documentCount: 0, + }; + } + + return { + providerId: this.id, + label: this.label, + status: workspaceState.lastError ? "error" : "ready", + details: workspaceState.lastError, + documentCount: workspaceState.documents.size, + }; + } + + async disposeWorkspace(args: { + workspaceId: string; + workspacePath: string; + }): Promise { + this.workspaces.delete(args.workspaceId); + } + + private getOrCreateWorkspaceState(workspaceId: string): WorkspaceState { + const existing = this.workspaces.get(workspaceId); + if (existing) { + return existing; + } + + const next: WorkspaceState = { + documents: new Map(), + lastError: null, + }; + this.workspaces.set(workspaceId, next); + return next; + } + + private async validateDocument( + document: LanguageServiceDocument, + workspaceState: WorkspaceState, + ): Promise { + try { + const textDocument = TextDocument.create( + absolutePathToFileUri(document.absolutePath), + document.languageId, + document.version, + document.content, + ); + const jsonDocument = this.jsonService.parseJSONDocument(textDocument); + const diagnostics = await this.jsonService.doValidation( + textDocument, + jsonDocument, + document.languageId === "jsonc" + ? { + comments: "ignore", + trailingCommas: "ignore", + schemaRequest: "ignore", + } + : { + comments: "error", + trailingCommas: "error", + schemaRequest: "ignore", + }, + ); + workspaceState.lastError = null; + languageDiagnosticsStore.setFileDiagnostics( + document.workspaceId, + this.fileKey(document.absolutePath), + diagnostics.map((diagnostic) => + this.mapDiagnostic( + document.workspacePath, + document.absolutePath, + diagnostic, + ), + ), + ); + } catch (error) { + workspaceState.lastError = + error instanceof Error ? error.message : String(error); + languageDiagnosticsStore.setFileDiagnostics( + document.workspaceId, + this.fileKey(document.absolutePath), + [], + ); + } + } + + private mapDiagnostic( + workspacePath: string, + absolutePath: string, + diagnostic: Diagnostic, + ): LanguageServiceDiagnostic { + return { + providerId: this.id, + source: diagnostic.source ?? "json", + absolutePath, + relativePath: toRelativeWorkspacePath(workspacePath, absolutePath), + line: diagnostic.range.start.line + 1, + column: diagnostic.range.start.character + 1, + endLine: diagnostic.range.end.line + 1, + endColumn: diagnostic.range.end.character + 1, + message: diagnostic.message, + code: diagnostic.code ?? null, + severity: lspSeverityToLanguageServiceSeverity(diagnostic.severity), + relatedInformation: + diagnostic.relatedInformation?.map((item) => { + const relatedAbsolutePath = + fileUriToAbsolutePath(item.location.uri) ?? absolutePath; + return { + absolutePath: relatedAbsolutePath, + relativePath: toRelativeWorkspacePath( + workspacePath, + relatedAbsolutePath, + ), + line: item.location.range.start.line + 1, + column: item.location.range.start.character + 1, + endLine: item.location.range.end.line + 1, + endColumn: item.location.range.end.character + 1, + message: item.message, + }; + }) ?? [], + }; + } + + private fileKey(absolutePath: string): string { + return `${this.id}::${absolutePath}`; + } +} diff --git a/apps/desktop/src/main/lib/language-services/providers/python/PythonLanguageProvider.ts b/apps/desktop/src/main/lib/language-services/providers/python/PythonLanguageProvider.ts new file mode 100644 index 00000000000..af985fadb49 --- /dev/null +++ b/apps/desktop/src/main/lib/language-services/providers/python/PythonLanguageProvider.ts @@ -0,0 +1,33 @@ +import { resolveNodePackageBinCommand } from "../../lsp/command-resolvers"; +import { ExternalLspLanguageProvider } from "../../lsp/ExternalLspLanguageProvider"; + +export class PythonLanguageProvider extends ExternalLspLanguageProvider { + constructor() { + super({ + id: "python", + label: "Python", + description: "Python diagnostics via Pyright.", + languageIds: ["python"], + defaultSource: "pyright", + resolveServerCommand: async ({ workspacePath }) => + await resolveNodePackageBinCommand({ + packageName: "pyright", + binName: "pyright-langserver", + args: ["--stdio"], + cwd: workspacePath, + }), + configuration: { + python: { + analysis: { + autoSearchPaths: true, + useLibraryCodeForTypes: true, + diagnosticMode: "openFilesOnly", + }, + }, + pyright: { + disableLanguageServices: false, + }, + }, + }); + } +} diff --git a/apps/desktop/src/main/lib/language-services/providers/rust/RustLanguageProvider.ts b/apps/desktop/src/main/lib/language-services/providers/rust/RustLanguageProvider.ts new file mode 100644 index 00000000000..a1e9bc18a92 --- /dev/null +++ b/apps/desktop/src/main/lib/language-services/providers/rust/RustLanguageProvider.ts @@ -0,0 +1,26 @@ +import { resolveAvailableExecutable } from "../../lsp/command-resolvers"; +import { ExternalLspLanguageProvider } from "../../lsp/ExternalLspLanguageProvider"; + +export class RustLanguageProvider extends ExternalLspLanguageProvider { + constructor() { + super({ + id: "rust", + label: "Rust", + description: "Rust diagnostics via rust-analyzer.", + languageIds: ["rust"], + defaultSource: "rust-analyzer", + resolveServerCommand: () => + resolveAvailableExecutable([ + { + command: + process.platform === "win32" + ? "rust-analyzer.exe" + : "rust-analyzer", + args: [], + probeArgs: ["--version"], + shell: false, + }, + ]), + }); + } +} diff --git a/apps/desktop/src/main/lib/language-services/providers/toml/TomlLanguageProvider.ts b/apps/desktop/src/main/lib/language-services/providers/toml/TomlLanguageProvider.ts new file mode 100644 index 00000000000..4d73136bc66 --- /dev/null +++ b/apps/desktop/src/main/lib/language-services/providers/toml/TomlLanguageProvider.ts @@ -0,0 +1,270 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { TextDecoder } from "node:util"; +import { type LintError, Taplo } from "@taplo/lib"; +import { languageDiagnosticsStore } from "../../diagnostics-store"; +import type { + LanguageServiceDiagnostic, + LanguageServiceDocument, + LanguageServiceProvider, + LanguageServiceProviderSummary, +} from "../../types"; +import { offsetToLineColumn, toRelativeWorkspacePath } from "../../utils"; + +type OpenDocumentEntry = { + languageId: string; + version: number; + content: string; +}; + +type WorkspaceState = { + documents: Map; + taploPromise: Promise; + lastError: string | null; +}; + +const decoder = new TextDecoder(); + +function createTaploInstance(workspacePath: string): Promise { + return Taplo.initialize({ + cwd: () => workspacePath, + envVar: (key) => process.env[key] ?? "", + envVars: () => + Object.entries(process.env).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + findConfigFile: () => undefined, + glob: () => [], + isAbsolute: (candidate) => path.isAbsolute(candidate), + now: () => new Date(), + readFile: async (target) => await fs.readFile(target), + writeFile: async () => { + throw new Error("Taplo writeFile is not implemented"); + }, + stderr: async (chunk) => { + console.error( + "[language-services/toml] taplo stderr", + decoder.decode(chunk), + ); + return chunk.length; + }, + stdErrAtty: () => false, + stdin: async () => { + throw new Error("Taplo stdin is not implemented"); + }, + stdout: async (chunk) => chunk.length, + urlToFilePath: (uri) => fileURLToPath(uri), + }); +} + +export class TomlLanguageProvider implements LanguageServiceProvider { + readonly id = "toml"; + + readonly label = "TOML"; + + readonly description = "TOML diagnostics via Taplo."; + + readonly languageIds = ["toml"]; + + private readonly workspaces = new Map(); + + supportsLanguage(languageId: string): boolean { + return languageId === "toml"; + } + + async openDocument(document: LanguageServiceDocument): Promise { + const workspaceState = this.getOrCreateWorkspaceState( + document.workspaceId, + document.workspacePath, + ); + workspaceState.documents.set(document.absolutePath, { + languageId: document.languageId, + version: document.version, + content: document.content, + }); + await this.validateDocument(document, workspaceState); + } + + async changeDocument(document: LanguageServiceDocument): Promise { + const workspaceState = this.getOrCreateWorkspaceState( + document.workspaceId, + document.workspacePath, + ); + workspaceState.documents.set(document.absolutePath, { + languageId: document.languageId, + version: document.version, + content: document.content, + }); + await this.validateDocument(document, workspaceState); + } + + async closeDocument(args: { + workspaceId: string; + workspacePath: string; + absolutePath: string; + languageId: string; + }): Promise { + const workspaceState = this.workspaces.get(args.workspaceId); + if (!workspaceState) { + return; + } + + workspaceState.documents.delete(args.absolutePath); + languageDiagnosticsStore.clearFileDiagnostics( + args.workspaceId, + this.fileKey(args.absolutePath), + ); + + if (workspaceState.documents.size === 0) { + this.workspaces.delete(args.workspaceId); + } + } + + async refreshWorkspace(args: { + workspaceId: string; + workspacePath: string; + }): Promise { + const workspaceState = this.workspaces.get(args.workspaceId); + if (!workspaceState) { + return; + } + + for (const [absolutePath, entry] of workspaceState.documents.entries()) { + await this.validateDocument( + { + workspaceId: args.workspaceId, + workspacePath: args.workspacePath, + absolutePath, + languageId: entry.languageId, + content: entry.content, + version: entry.version, + }, + workspaceState, + ); + } + } + + getWorkspaceSummary(args: { + workspaceId: string; + workspacePath: string; + enabled: boolean; + }): LanguageServiceProviderSummary { + const workspaceState = this.workspaces.get(args.workspaceId); + if (!args.enabled) { + return { + providerId: this.id, + label: this.label, + status: "disabled", + details: null, + documentCount: 0, + }; + } + + if (!workspaceState) { + return { + providerId: this.id, + label: this.label, + status: "idle", + details: null, + documentCount: 0, + }; + } + + return { + providerId: this.id, + label: this.label, + status: workspaceState.lastError ? "error" : "ready", + details: workspaceState.lastError, + documentCount: workspaceState.documents.size, + }; + } + + async disposeWorkspace(args: { + workspaceId: string; + workspacePath: string; + }): Promise { + this.workspaces.delete(args.workspaceId); + } + + private getOrCreateWorkspaceState( + workspaceId: string, + workspacePath: string, + ): WorkspaceState { + const existing = this.workspaces.get(workspaceId); + if (existing) { + return existing; + } + + const next: WorkspaceState = { + documents: new Map(), + taploPromise: createTaploInstance(workspacePath), + lastError: null, + }; + this.workspaces.set(workspaceId, next); + return next; + } + + private async validateDocument( + document: LanguageServiceDocument, + workspaceState: WorkspaceState, + ): Promise { + try { + const taplo = await workspaceState.taploPromise; + const result = await taplo.lint(document.content); + workspaceState.lastError = null; + languageDiagnosticsStore.setFileDiagnostics( + document.workspaceId, + this.fileKey(document.absolutePath), + result.errors.map((error) => this.mapDiagnostic(document, error)), + ); + } catch (error) { + workspaceState.lastError = + error instanceof Error ? error.message : String(error); + languageDiagnosticsStore.setFileDiagnostics( + document.workspaceId, + this.fileKey(document.absolutePath), + [], + ); + } + } + + private mapDiagnostic( + document: LanguageServiceDocument, + error: LintError, + ): LanguageServiceDiagnostic { + const byteRange = error.range as + | { + start?: number; + end?: number; + } + | undefined; + const start = offsetToLineColumn( + document.content, + byteRange?.start ?? null, + ); + const end = offsetToLineColumn(document.content, byteRange?.end ?? null); + + return { + providerId: this.id, + source: "toml", + absolutePath: document.absolutePath, + relativePath: toRelativeWorkspacePath( + document.workspacePath, + document.absolutePath, + ), + line: start.line, + column: start.column, + endLine: end.line, + endColumn: end.column, + message: error.error, + code: null, + severity: "error", + relatedInformation: [], + }; + } + + private fileKey(absolutePath: string): string { + return `${this.id}::${absolutePath}`; + } +} diff --git a/apps/desktop/src/main/lib/language-services/providers/typescript/TypeScriptLanguageProvider.ts b/apps/desktop/src/main/lib/language-services/providers/typescript/TypeScriptLanguageProvider.ts new file mode 100644 index 00000000000..422574d7fab --- /dev/null +++ b/apps/desktop/src/main/lib/language-services/providers/typescript/TypeScriptLanguageProvider.ts @@ -0,0 +1,734 @@ +import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; +import fs from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { languageDiagnosticsStore } from "../../diagnostics-store"; +import type { + LanguageServiceDiagnostic, + LanguageServiceDocument, + LanguageServiceProvider, + LanguageServiceProviderSummary, + LanguageServiceRelatedInformation, + LanguageServiceSeverity, +} from "../../types"; + +const require = createRequire(import.meta.url); + +type TsServerRequest = { + seq: number; + type: "request"; + command: string; + arguments?: unknown; +}; + +type TsServerEvent = { + type: "event"; + event: string; + body?: unknown; +}; + +type TsServerResponse = { + type: "response"; + request_seq: number; + success: boolean; + command: string; + body?: unknown; + message?: string; +}; + +type TsServerMessage = TsServerEvent | TsServerResponse; + +type TsServerDiagnostic = { + start?: { line: number; offset: number }; + end?: { line: number; offset: number }; + text?: string; + message?: string; + code?: number; + category?: string; + relatedInformation?: Array<{ + span?: { + file?: string; + start?: { line: number; offset: number }; + end?: { line: number; offset: number }; + }; + message?: string; + text?: string; + }>; +}; + +type DiagnosticBucketKey = "syntax" | "semantic" | "suggestion" | "config"; + +type FileDiagnosticBuckets = { + syntax: LanguageServiceDiagnostic[]; + semantic: LanguageServiceDiagnostic[]; + suggestion: LanguageServiceDiagnostic[]; + config: LanguageServiceDiagnostic[]; +}; + +type OpenDocumentEntry = { + languageId: string; + version: number; + content: string; +}; + +type WorkspaceSession = { + workspaceId: string; + workspacePath: string; + tsserverPath: string; + process: ChildProcessWithoutNullStreams; + seq: number; + buffer: string; + requestResolvers: Map< + number, + { + resolve: (value: TsServerResponse) => void; + reject: (error: Error) => void; + } + >; + openDocuments: Map; + diagnosticBuckets: Map; + getErrTimer: ReturnType | null; + lastError: string | null; +}; + +function createEmptyBuckets(): FileDiagnosticBuckets { + return { + syntax: [], + semantic: [], + suggestion: [], + config: [], + }; +} + +function tryConsumeContentLengthMessage( + buffer: string, +): { body: string; rest: string } | null { + const normalizedBuffer = buffer.replace(/^(?:\r?\n)+/, ""); + if (normalizedBuffer !== buffer) { + return tryConsumeContentLengthMessage(normalizedBuffer); + } + + const separatorIndex = buffer.indexOf("\r\n\r\n"); + if (separatorIndex === -1) { + return null; + } + + const header = buffer.slice(0, separatorIndex); + const contentLengthMatch = /Content-Length: (\d+)/i.exec(header); + if (!contentLengthMatch) { + return null; + } + + const contentLength = Number(contentLengthMatch[1]); + const bodyStart = separatorIndex + 4; + const bodyEnd = bodyStart + contentLength; + if (buffer.length < bodyEnd) { + return null; + } + + return { + body: buffer.slice(bodyStart, bodyEnd), + rest: buffer.slice(bodyEnd), + }; +} + +function tryConsumeLineMessage( + buffer: string, +): { body: string; rest: string } | null { + const normalizedBuffer = buffer.replace(/^(?:\r?\n)+/, ""); + if (normalizedBuffer !== buffer) { + return tryConsumeLineMessage(normalizedBuffer); + } + + if (!normalizedBuffer.trimStart().startsWith("{")) { + return null; + } + + const newlineIndex = buffer.indexOf("\n"); + if (newlineIndex === -1) { + return null; + } + + return { + body: buffer.slice(0, newlineIndex).trim(), + rest: buffer.slice(newlineIndex + 1), + }; +} + +function toRelativeWorkspacePath( + workspacePath: string, + absolutePath: string, +): string | null { + const relativePath = path.relative(workspacePath, absolutePath); + if ( + !relativePath || + relativePath.startsWith("..") || + path.isAbsolute(relativePath) + ) { + return null; + } + + return relativePath.split(path.sep).join("/"); +} + +function toSeverity(category: string | undefined): LanguageServiceSeverity { + switch (category) { + case "error": + return "error"; + case "warning": + return "warning"; + case "suggestion": + return "hint"; + default: + return "info"; + } +} + +function resolveBundledTsServerPath(): string { + return require.resolve("typescript/lib/tsserver.js"); +} + +function resolveWorkspaceTsServerPath(workspacePath: string): string | null { + const candidate = path.join( + workspacePath, + "node_modules", + "typescript", + "lib", + "tsserver.js", + ); + return fs.existsSync(candidate) ? candidate : null; +} + +function computeEndPosition(content: string): { + endLine: number; + endOffset: number; +} { + const lines = content.split(/\r\n|\r|\n/); + return { + endLine: lines.length, + endOffset: (lines.at(-1)?.length ?? 0) + 1, + }; +} + +export class TypeScriptLanguageProvider implements LanguageServiceProvider { + readonly id = "typescript"; + + readonly label = "TypeScript"; + + readonly description = + "TypeScript, JavaScript, TSX, JSX diagnostics via tsserver."; + + readonly languageIds = [ + "typescript", + "typescriptreact", + "javascript", + "javascriptreact", + ]; + + private readonly sessions = new Map(); + + supportsLanguage(languageId: string): boolean { + return [ + "typescript", + "typescriptreact", + "javascript", + "javascriptreact", + ].includes(languageId); + } + + async openDocument(document: LanguageServiceDocument): Promise { + const session = await this.ensureSession( + document.workspaceId, + document.workspacePath, + ); + session.openDocuments.set(document.absolutePath, { + languageId: document.languageId, + version: document.version, + content: document.content, + }); + await this.sendRequest(session, "open", { + file: document.absolutePath, + fileContent: document.content, + projectRootPath: document.workspacePath, + }); + this.scheduleGetErr(session); + } + + async changeDocument(document: LanguageServiceDocument): Promise { + const session = await this.ensureSession( + document.workspaceId, + document.workspacePath, + ); + const previous = session.openDocuments.get(document.absolutePath); + if (!previous) { + await this.openDocument(document); + return; + } + + session.openDocuments.set(document.absolutePath, { + languageId: document.languageId, + version: document.version, + content: document.content, + }); + + await this.sendRequest(session, "change", { + file: document.absolutePath, + line: 1, + offset: 1, + ...computeEndPosition(previous.content), + insertString: document.content, + }); + this.scheduleGetErr(session); + } + + async closeDocument(args: { + workspaceId: string; + workspacePath: string; + absolutePath: string; + languageId: string; + }): Promise { + const session = this.sessions.get(args.workspaceId); + if (!session) { + return; + } + + session.openDocuments.delete(args.absolutePath); + session.diagnosticBuckets.delete(args.absolutePath); + languageDiagnosticsStore.clearFileDiagnostics( + args.workspaceId, + this.fileKey(args.absolutePath), + ); + + try { + await this.sendRequest(session, "close", { + file: args.absolutePath, + }); + } catch (error) { + console.error("[language-services/typescript] Failed to close document", { + workspaceId: args.workspaceId, + absolutePath: args.absolutePath, + error, + }); + } + + if (session.openDocuments.size === 0) { + await this.disposeWorkspace(args); + return; + } + + this.scheduleGetErr(session); + } + + async refreshWorkspace(args: { + workspaceId: string; + workspacePath: string; + }): Promise { + const session = this.sessions.get(args.workspaceId); + if (!session || session.openDocuments.size === 0) { + return; + } + + this.scheduleGetErr(session, 0); + } + + getWorkspaceSummary(args: { + workspaceId: string; + workspacePath: string; + enabled: boolean; + }): LanguageServiceProviderSummary { + const session = this.sessions.get(args.workspaceId); + if (!args.enabled) { + return { + providerId: this.id, + label: this.label, + status: "disabled", + details: null, + documentCount: 0, + }; + } + + if (!session) { + return { + providerId: this.id, + label: this.label, + status: "idle", + details: null, + documentCount: 0, + }; + } + + return { + providerId: this.id, + label: this.label, + status: session.lastError ? "error" : "ready", + details: session.lastError, + documentCount: session.openDocuments.size, + }; + } + + async disposeWorkspace(args: { + workspaceId: string; + workspacePath: string; + }): Promise { + const session = this.sessions.get(args.workspaceId); + if (!session) { + return; + } + + if (session.getErrTimer) { + clearTimeout(session.getErrTimer); + session.getErrTimer = null; + } + + for (const request of session.requestResolvers.values()) { + request.reject(new Error("TypeScript session disposed")); + } + session.requestResolvers.clear(); + + session.process.removeAllListeners(); + if (!session.process.killed) { + session.process.kill(); + } + + this.sessions.delete(args.workspaceId); + } + + private async ensureSession( + workspaceId: string, + workspacePath: string, + ): Promise { + const existing = this.sessions.get(workspaceId); + if (existing) { + return existing; + } + + const tsserverPath = + resolveWorkspaceTsServerPath(workspacePath) ?? + resolveBundledTsServerPath(); + const child = spawn(process.execPath, [tsserverPath, "--stdio"], { + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: "1", + }, + stdio: ["pipe", "pipe", "pipe"], + }); + + const session: WorkspaceSession = { + workspaceId, + workspacePath, + tsserverPath, + process: child, + seq: 0, + buffer: "", + requestResolvers: new Map(), + openDocuments: new Map(), + diagnosticBuckets: new Map(), + getErrTimer: null, + lastError: null, + }; + let isSessionClosed = false; + const closeSession = (message: string) => { + if (isSessionClosed) { + return; + } + isSessionClosed = true; + session.lastError = message; + if (session.getErrTimer) { + clearTimeout(session.getErrTimer); + session.getErrTimer = null; + } + for (const request of session.requestResolvers.values()) { + request.reject(new Error(message)); + } + session.requestResolvers.clear(); + this.sessions.delete(workspaceId); + }; + + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + this.handleStdout(session, chunk); + }); + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk: string) => { + console.error("[language-services/typescript] tsserver stderr", { + workspaceId, + chunk, + }); + }); + child.on("error", (error) => { + console.error("[language-services/typescript] tsserver process error", { + workspaceId, + error, + }); + closeSession( + error instanceof Error + ? `tsserver process error: ${error.message}` + : "tsserver process error", + ); + }); + child.on("exit", (code, signal) => { + closeSession( + `TypeScript server exited: ${code ?? "null"}${signal ? ` ${signal}` : ""}`, + ); + }); + + this.sessions.set(workspaceId, session); + await this.sendRequest(session, "configure", { + preferences: { + includeCompletionsForModuleExports: true, + includeCompletionsWithInsertText: true, + }, + }); + return session; + } + + private handleStdout(session: WorkspaceSession, chunk: string): void { + session.buffer += chunk; + while (true) { + const framedMessage = tryConsumeContentLengthMessage(session.buffer); + const lineMessage = + framedMessage === null ? tryConsumeLineMessage(session.buffer) : null; + const message = framedMessage ?? lineMessage; + if (!message) { + return; + } + + session.buffer = message.rest; + const body = message.body.trim(); + if (!body) { + continue; + } + + try { + const message = JSON.parse(body) as TsServerMessage; + this.handleMessage(session, message); + } catch (error) { + console.error( + "[language-services/typescript] Failed to parse tsserver payload", + { + workspaceId: session.workspaceId, + error, + body, + }, + ); + } + } + } + + private handleMessage( + session: WorkspaceSession, + message: TsServerMessage, + ): void { + if (message.type === "response") { + const resolver = session.requestResolvers.get(message.request_seq); + if (!resolver) { + return; + } + session.requestResolvers.delete(message.request_seq); + if (message.success) { + session.lastError = null; + resolver.resolve(message); + } else { + const error = new Error( + message.message ?? `tsserver command failed: ${message.command}`, + ); + session.lastError = error.message; + resolver.reject(error); + } + return; + } + + switch (message.event) { + case "syntaxDiag": + this.applyDiagnosticsEvent(session, "syntax", message.body); + return; + case "semanticDiag": + this.applyDiagnosticsEvent(session, "semantic", message.body); + return; + case "suggestionDiag": + this.applyDiagnosticsEvent(session, "suggestion", message.body); + return; + case "configFileDiag": + this.applyConfigDiagnosticsEvent(session, message.body); + return; + default: + return; + } + } + + private applyDiagnosticsEvent( + session: WorkspaceSession, + bucketKey: DiagnosticBucketKey, + body: unknown, + ): void { + const payload = body as + | { file?: string; diagnostics?: TsServerDiagnostic[] } + | undefined; + if (!payload?.file) { + return; + } + + const absolutePath = payload.file; + const buckets = + session.diagnosticBuckets.get(absolutePath) ?? createEmptyBuckets(); + buckets[bucketKey] = (payload.diagnostics ?? []).map((diagnostic) => + this.mapDiagnostic(session.workspacePath, absolutePath, diagnostic), + ); + session.diagnosticBuckets.set(absolutePath, buckets); + this.publishDiagnostics(session, absolutePath, buckets); + } + + private applyConfigDiagnosticsEvent( + session: WorkspaceSession, + body: unknown, + ): void { + const payload = body as + | { + triggerFile?: string; + configFile?: string; + diagnostics?: TsServerDiagnostic[]; + } + | undefined; + const absolutePath = payload?.configFile ?? payload?.triggerFile; + if (!absolutePath) { + return; + } + if (!payload) { + return; + } + + const buckets = + session.diagnosticBuckets.get(absolutePath) ?? createEmptyBuckets(); + buckets.config = (payload.diagnostics ?? []).map((diagnostic) => + this.mapDiagnostic(session.workspacePath, absolutePath, diagnostic), + ); + session.diagnosticBuckets.set(absolutePath, buckets); + this.publishDiagnostics(session, absolutePath, buckets); + } + + private publishDiagnostics( + session: WorkspaceSession, + absolutePath: string, + buckets: FileDiagnosticBuckets, + ): void { + const diagnostics = [ + ...buckets.syntax, + ...buckets.semantic, + ...buckets.suggestion, + ...buckets.config, + ]; + languageDiagnosticsStore.setFileDiagnostics( + session.workspaceId, + this.fileKey(absolutePath), + diagnostics, + ); + } + + private mapDiagnostic( + workspacePath: string, + absolutePath: string, + diagnostic: TsServerDiagnostic, + ): LanguageServiceDiagnostic { + const relatedInformation = diagnostic.relatedInformation + ?.map((item) => + this.mapRelatedInformation(workspacePath, absolutePath, item), + ) + .filter( + (item): item is LanguageServiceRelatedInformation => item !== null, + ); + + return { + providerId: this.id, + source: "typescript", + absolutePath, + relativePath: toRelativeWorkspacePath(workspacePath, absolutePath), + line: diagnostic.start?.line ?? null, + column: diagnostic.start?.offset ?? null, + endLine: diagnostic.end?.line ?? null, + endColumn: diagnostic.end?.offset ?? null, + message: + diagnostic.text ?? diagnostic.message ?? "Unknown TypeScript error", + code: diagnostic.code ?? null, + severity: toSeverity(diagnostic.category), + relatedInformation, + }; + } + + private mapRelatedInformation( + workspacePath: string, + fallbackAbsolutePath: string, + item: NonNullable[number], + ): LanguageServiceRelatedInformation | null { + const absolutePath = item.span?.file ?? fallbackAbsolutePath; + const message = item.text ?? item.message ?? ""; + if (!message) { + return null; + } + + return { + absolutePath, + relativePath: toRelativeWorkspacePath(workspacePath, absolutePath), + line: item.span?.start?.line ?? null, + column: item.span?.start?.offset ?? null, + endLine: item.span?.end?.line ?? null, + endColumn: item.span?.end?.offset ?? null, + message, + }; + } + + private scheduleGetErr(session: WorkspaceSession, delay = 150): void { + if (session.getErrTimer) { + clearTimeout(session.getErrTimer); + } + + session.getErrTimer = setTimeout(() => { + session.getErrTimer = null; + if (session.openDocuments.size === 0) { + return; + } + + void this.sendRequest(session, "geterr", { + files: Array.from(session.openDocuments.keys()), + delay: 0, + }).catch((error) => { + session.lastError = + error instanceof Error ? error.message : String(error); + console.error("[language-services/typescript] geterr failed", { + workspaceId: session.workspaceId, + error, + }); + }); + }, delay); + } + + private async sendRequest( + session: WorkspaceSession, + command: string, + args?: unknown, + ): Promise { + const seq = ++session.seq; + const payload: TsServerRequest = { + seq, + type: "request", + command, + arguments: args, + }; + const content = `${JSON.stringify(payload)}\n`; + + return await new Promise((resolve, reject) => { + session.requestResolvers.set(seq, { resolve, reject }); + session.process.stdin.write(content, "utf8", (error) => { + if (!error) { + return; + } + + session.requestResolvers.delete(seq); + reject(error); + }); + }); + } + + private fileKey(absolutePath: string): string { + return `${this.id}::${absolutePath}`; + } +} diff --git a/apps/desktop/src/main/lib/language-services/providers/yaml/YamlLanguageProvider.ts b/apps/desktop/src/main/lib/language-services/providers/yaml/YamlLanguageProvider.ts new file mode 100644 index 00000000000..98080c74afe --- /dev/null +++ b/apps/desktop/src/main/lib/language-services/providers/yaml/YamlLanguageProvider.ts @@ -0,0 +1,32 @@ +import { resolveNodePackageBinCommand } from "../../lsp/command-resolvers"; +import { ExternalLspLanguageProvider } from "../../lsp/ExternalLspLanguageProvider"; + +export class YamlLanguageProvider extends ExternalLspLanguageProvider { + constructor() { + super({ + id: "yaml", + label: "YAML", + description: "YAML diagnostics via yaml-language-server.", + languageIds: ["yaml"], + defaultSource: "yaml", + resolveServerCommand: async ({ workspacePath }) => + await resolveNodePackageBinCommand({ + packageName: "yaml-language-server", + binName: "yaml-language-server", + args: ["--stdio"], + cwd: workspacePath, + }), + configuration: { + yaml: { + validate: true, + schemaStore: { + enable: true, + url: "https://www.schemastore.org/api/json/catalog.json", + }, + hover: false, + completion: false, + }, + }, + }); + } +} diff --git a/apps/desktop/src/main/lib/language-services/types.ts b/apps/desktop/src/main/lib/language-services/types.ts new file mode 100644 index 00000000000..df3aeb7cffe --- /dev/null +++ b/apps/desktop/src/main/lib/language-services/types.ts @@ -0,0 +1,96 @@ +export type LanguageServiceSeverity = "error" | "warning" | "info" | "hint"; + +export interface LanguageServiceRelatedInformation { + absolutePath: string | null; + relativePath: string | null; + line: number | null; + column: number | null; + endLine: number | null; + endColumn: number | null; + message: string; +} + +export interface LanguageServiceDocument { + workspaceId: string; + workspacePath: string; + absolutePath: string; + languageId: string; + content: string; + version: number; +} + +export interface LanguageServiceDiagnostic { + providerId: string; + source: string; + absolutePath: string | null; + relativePath: string | null; + line: number | null; + column: number | null; + endLine: number | null; + endColumn: number | null; + message: string; + code: string | number | null; + severity: LanguageServiceSeverity; + relatedInformation?: LanguageServiceRelatedInformation[]; +} + +export interface LanguageServiceProviderSummary { + providerId: string; + label: string; + status: "ready" | "disabled" | "idle" | "error"; + details?: string | null; + documentCount: number; +} + +export interface LanguageServiceProviderDescriptor { + providerId: string; + label: string; + description: string; + languageIds: string[]; + enabled: boolean; +} + +export interface LanguageServiceWorkspaceSnapshot { + status: "ready"; + workspaceId: string; + workspacePath: string; + providers: LanguageServiceProviderSummary[]; + problems: LanguageServiceDiagnostic[]; + totalCount: number; + truncated: boolean; + summary: { + errorCount: number; + warningCount: number; + infoCount: number; + hintCount: number; + }; +} + +export interface LanguageServiceProvider { + readonly id: string; + readonly label: string; + readonly description: string; + readonly languageIds: string[]; + supportsLanguage(languageId: string): boolean; + openDocument(document: LanguageServiceDocument): Promise; + changeDocument(document: LanguageServiceDocument): Promise; + closeDocument(args: { + workspaceId: string; + workspacePath: string; + absolutePath: string; + languageId: string; + }): Promise; + refreshWorkspace(args: { + workspaceId: string; + workspacePath: string; + }): Promise; + getWorkspaceSummary(args: { + workspaceId: string; + workspacePath: string; + enabled: boolean; + }): LanguageServiceProviderSummary; + disposeWorkspace(args: { + workspaceId: string; + workspacePath: string; + }): Promise; +} diff --git a/apps/desktop/src/main/lib/language-services/utils.ts b/apps/desktop/src/main/lib/language-services/utils.ts new file mode 100644 index 00000000000..11db9d2f396 --- /dev/null +++ b/apps/desktop/src/main/lib/language-services/utils.ts @@ -0,0 +1,105 @@ +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import type { LanguageServiceSeverity } from "./types"; + +export function toRelativeWorkspacePath( + workspacePath: string, + absolutePath: string, +): string | null { + const relativePath = path.relative(workspacePath, absolutePath); + if ( + !relativePath || + relativePath.startsWith("..") || + path.isAbsolute(relativePath) + ) { + return null; + } + + return relativePath.split(path.sep).join("/"); +} + +export function absolutePathToFileUri(absolutePath: string): string { + return pathToFileURL(absolutePath).toString(); +} + +export function fileUriToAbsolutePath(uri: string): string | null { + if (!uri.startsWith("file://")) { + return null; + } + + try { + return fileURLToPath(uri); + } catch { + return null; + } +} + +export function offsetToLineColumn( + content: string, + offset: number | null | undefined, +): { line: number | null; column: number | null } { + if (offset === null || offset === undefined || Number.isNaN(offset)) { + return { + line: null, + column: null, + }; + } + + const boundedOffset = Math.max(0, Math.min(offset, content.length)); + let line = 1; + let column = 1; + + for (let index = 0; index < boundedOffset; index += 1) { + const char = content[index]; + if (char === "\n") { + line += 1; + column = 1; + continue; + } + + if (char === "\r") { + if (content[index + 1] === "\n") { + index += 1; + } + line += 1; + column = 1; + continue; + } + + column += 1; + } + + return { + line, + column, + }; +} + +export function offsetToLspPosition( + content: string, + offset: number, +): { + line: number; + character: number; +} { + const position = offsetToLineColumn(content, offset); + return { + line: Math.max((position.line ?? 1) - 1, 0), + character: Math.max((position.column ?? 1) - 1, 0), + }; +} + +export function lspSeverityToLanguageServiceSeverity( + severity: number | null | undefined, +): LanguageServiceSeverity { + switch (severity) { + case 1: + return "error"; + case 2: + return "warning"; + case 3: + return "info"; + default: + return "hint"; + } +} diff --git a/apps/desktop/src/main/lib/local-db/index.ts b/apps/desktop/src/main/lib/local-db/index.ts index cf7dc89cab1..9e18be10411 100644 --- a/apps/desktop/src/main/lib/local-db/index.ts +++ b/apps/desktop/src/main/lib/local-db/index.ts @@ -103,4 +103,12 @@ try { console.log("[local-db] Migrations complete"); +export function closeLocalDb(): void { + try { + sqlite.close(); + } catch (error) { + console.error("[local-db] Failed to close database:", error); + } +} + export type LocalDb = typeof localDb; diff --git a/apps/desktop/src/main/lib/menu-events.ts b/apps/desktop/src/main/lib/menu-events.ts index 7798b468b57..aa2dfe21021 100644 --- a/apps/desktop/src/main/lib/menu-events.ts +++ b/apps/desktop/src/main/lib/menu-events.ts @@ -1,10 +1,12 @@ import { EventEmitter } from "node:events"; +import type { BrowserShortcutAction } from "shared/browser-shortcuts"; export type SettingsSection = | "project" | "workspace" | "appearance" | "keyboard" | "behavior" + | "diagnostics" | "git" | "terminal" | "integrations"; @@ -17,4 +19,8 @@ export interface OpenWorkspaceEvent { workspaceId: string; } +export interface BrowserActionEvent { + action: BrowserShortcutAction; +} + export const menuEmitter = new EventEmitter(); diff --git a/apps/desktop/src/main/lib/menu.ts b/apps/desktop/src/main/lib/menu.ts index a312d4124ac..bedd0517719 100644 --- a/apps/desktop/src/main/lib/menu.ts +++ b/apps/desktop/src/main/lib/menu.ts @@ -1,9 +1,10 @@ import { COMPANY } from "@superset/shared/constants"; -import { app, BrowserWindow, Menu, shell } from "electron"; +import { app, BrowserWindow, Menu, shell, webContents } from "electron"; import { env } from "main/env.main"; import { appState } from "main/lib/app-state"; import { hotkeysEmitter } from "main/lib/hotkeys-events"; import { resetTerminalStateDev } from "main/lib/terminal/dev-reset"; +import type { BrowserShortcutAction } from "shared/browser-shortcuts"; import { getCurrentPlatform, getEffectiveHotkey, @@ -20,6 +21,26 @@ import { menuEmitter } from "./menu-events"; let isHotkeyListenerRegistered = false; +function getFocusedWebview() { + return webContents + .getAllWebContents() + .find((wc) => wc.getType() === "webview" && wc.isFocused()); +} + +function triggerBrowserShortcut(action: BrowserShortcutAction) { + const focusedGuest = getFocusedWebview(); + if (focusedGuest) { + if (action === "hard-reload") { + focusedGuest.reloadIgnoringCache(); + } else { + focusedGuest.reload(); + } + return; + } + + menuEmitter.emit("browser-action", action); +} + function getMenuAccelerator(id: HotkeyId): string | undefined { const platform = getCurrentPlatform(); const overrides = appState.data.hotkeysState.byPlatform[platform]; @@ -38,6 +59,10 @@ export function registerMenuHotkeyUpdates() { export function createApplicationMenu() { const reloadAccelerator = getMenuAccelerator("RELOAD_WINDOW"); + const browserReloadAccelerator = getMenuAccelerator("BROWSER_RELOAD"); + const browserHardReloadAccelerator = getMenuAccelerator( + "BROWSER_HARD_RELOAD", + ); const closeAccelerator = getMenuAccelerator("CLOSE_WINDOW"); const showHotkeysAccelerator = getMenuAccelerator("SHOW_HOTKEYS"); const openSettingsAccelerator = getMenuAccelerator("OPEN_SETTINGS"); @@ -75,6 +100,25 @@ export function createApplicationMenu() { { role: "togglefullscreen" }, ], }, + { + label: "Browser", + submenu: [ + { + label: "Reload Browser", + accelerator: browserReloadAccelerator, + click: () => { + triggerBrowserShortcut("reload"); + }, + }, + { + label: "Hard Reload Browser", + accelerator: browserHardReloadAccelerator, + click: () => { + triggerBrowserShortcut("hard-reload"); + }, + }, + ], + }, { label: "Window", submenu: [ diff --git a/apps/desktop/src/main/lib/shell-history.ts b/apps/desktop/src/main/lib/shell-history.ts new file mode 100644 index 00000000000..b547c731c13 --- /dev/null +++ b/apps/desktop/src/main/lib/shell-history.ts @@ -0,0 +1,261 @@ +import { constants } from "node:fs"; +import { + access, + chmod, + readFile, + rename, + stat, + writeFile, +} from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; + +export interface ShellHistoryEntry { + command: string; + lastRunAt: number | null; +} + +let cachedHistory: ShellHistoryEntry[] | null = null; +let lastReadTime = 0; +const CACHE_TTL_MS = 30_000; + +const META_MARKER = 0x83; + +function decodeMetafied(buffer: Buffer): string { + const decoded: number[] = []; + for (let i = 0; i < buffer.length; i++) { + if (buffer[i] === META_MARKER && i + 1 < buffer.length) { + decoded.push(buffer[i + 1] ^ 0x20); + i++; + } else { + decoded.push(buffer[i]); + } + } + return Buffer.from(decoded).toString("utf-8"); +} + +function parseZshHistory(content: string): ShellHistoryEntry[] { + const entries: ShellHistoryEntry[] = []; + for (const line of content.split("\n")) { + if (!line.trim()) continue; + // Extended format: : timestamp:0;command + const match = line.match(/^:\s*(\d+):\d+;(.+)$/); + const command = match ? match[2] : line; + const timestamp = match ? Number.parseInt(match[1], 10) * 1000 : null; + // Skip multi-line continuations + if (command.endsWith("\\")) continue; + const trimmed = command.trim(); + if (trimmed) { + entries.push({ command: trimmed, lastRunAt: timestamp }); + } + } + return entries; +} + +function parseBashHistory(content: string): ShellHistoryEntry[] { + return content + .split("\n") + .filter((line) => line.trim() && !line.startsWith("#")) + .map((line) => ({ + command: line.trim(), + lastRunAt: null, + })); +} + +async function readHistoryFile(): Promise { + const home = homedir(); + + // Try zsh first (more common on macOS) + const zshPath = `${home}/.zsh_history`; + try { + await access(zshPath, constants.R_OK); + const buffer = await readFile(zshPath); + const content = buffer.includes(META_MARKER) + ? decodeMetafied(buffer) + : buffer.toString("utf-8"); + return parseZshHistory(content); + } catch { + // zsh history not available + } + + // Fall back to bash + const bashPath = `${home}/.bash_history`; + try { + await access(bashPath, constants.R_OK); + const content = await readFile(bashPath, "utf-8"); + return parseBashHistory(content); + } catch { + // bash history not available + } + + return []; +} + +async function getHistory(): Promise { + const now = Date.now(); + if (cachedHistory && now - lastReadTime < CACHE_TTL_MS) { + return cachedHistory; + } + + const entries = await readHistoryFile(); + + // Deduplicate, most-recent-first + const seen = new Set(); + const result: ShellHistoryEntry[] = []; + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i]; + if (!seen.has(entry.command)) { + seen.add(entry.command); + result.push(entry); + } + } + + cachedHistory = result.slice(0, 10_000); + lastReadTime = now; + return cachedHistory; +} + +const PAGE_SIZE = 8; + +export async function getSuggestions( + prefix: string, + offset = 0, +): Promise { + const history = await getHistory(); + const results: ShellHistoryEntry[] = []; + let skipped = 0; + + for (const entry of history) { + if (entry.command.startsWith(prefix) && entry.command !== prefix) { + if (skipped < offset) { + skipped++; + continue; + } + results.push(entry); + if (results.length >= PAGE_SIZE) break; + } + } + + return results; +} + +function encodeMetafied(text: string): Buffer { + const src = Buffer.from(text, "utf-8"); + const out: number[] = []; + for (let i = 0; i < src.length; i++) { + const b = src[i]; + // zsh encodes any byte with high bit set (>= 0x80) + if (b >= 0x80) { + out.push(META_MARKER, b ^ 0x20); + } else { + out.push(b); + } + } + return Buffer.from(out); +} + +function filterZshLines(lines: string[], commandToDelete: string): string[] { + const filtered: string[] = []; + let i = 0; + while (i < lines.length) { + const line = lines[i]; + const match = line.match(/^:\s*\d+:\d+;(.+)$/); + const cmd = match ? match[1] : null; + + if (cmd !== null) { + // Collect continuation lines (ending with \) + let fullCmd = cmd; + let blockLen = 1; + while (fullCmd.endsWith("\\") && i + blockLen < lines.length) { + fullCmd = fullCmd.slice(0, -1) + lines[i + blockLen]; + blockLen++; + } + if (fullCmd.trim() === commandToDelete.trim()) { + i += blockLen; + continue; + } + } + filtered.push(line); + i++; + } + return filtered; +} + +function filterBashLines(lines: string[], commandToDelete: string): string[] { + return lines.filter((line) => line.trim() !== commandToDelete.trim()); +} + +async function atomicWriteFile( + filePath: string, + content: Buffer, +): Promise { + const tmp = join( + dirname(filePath), + `.superset-hist-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + await writeFile(tmp, content, { mode: 0o600 }); + try { + const orig = await stat(filePath); + await chmod(tmp, orig.mode); + } catch { + // keep default 0o600 + } + await rename(tmp, filePath); +} + +export async function deleteHistoryEntry(command: string): Promise { + const home = homedir(); + + // Try zsh first + const zshPath = `${home}/.zsh_history`; + try { + await access(zshPath, constants.R_OK | constants.W_OK); + const buffer = await readFile(zshPath); + const isMetafiedFile = buffer.includes(META_MARKER); + const content = isMetafiedFile + ? decodeMetafied(buffer) + : buffer.toString("utf-8"); + + const lines = content.split("\n"); + const filtered = filterZshLines(lines, command); + if (filtered.length === lines.length) { + // Nothing deleted + cachedHistory = null; + return; + } + + const newContent = filtered.join("\n"); + const newBuffer = isMetafiedFile + ? encodeMetafied(newContent) + : Buffer.from(newContent, "utf-8"); + await atomicWriteFile(zshPath, newBuffer); + cachedHistory = null; + return; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== "ENOENT" && code !== "EACCES") { + console.warn("[shell-history] Failed to delete from zsh history:", err); + } + } + + // Fall back to bash + const bashPath = `${home}/.bash_history`; + try { + await access(bashPath, constants.R_OK | constants.W_OK); + const content = await readFile(bashPath, "utf-8"); + const lines = content.split("\n"); + const filtered = filterBashLines(lines, command); + if (filtered.length < lines.length) { + await atomicWriteFile( + bashPath, + Buffer.from(filtered.join("\n"), "utf-8"), + ); + } + cachedHistory = null; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== "ENOENT" && code !== "EACCES") { + console.warn("[shell-history] Failed to delete from bash history:", err); + } + } +} diff --git a/apps/desktop/src/main/lib/terminal-host/client.ts b/apps/desktop/src/main/lib/terminal-host/client.ts index 7c335cf516d..334b77f05c4 100644 --- a/apps/desktop/src/main/lib/terminal-host/client.ts +++ b/apps/desktop/src/main/lib/terminal-host/client.ts @@ -1308,6 +1308,13 @@ export class TerminalHostClient extends EventEmitter { }); } + private isCreateOrAttachTimeoutError(error: unknown): boolean { + return ( + error instanceof Error && + error.message === "Request timeout: createOrAttach" + ); + } + /** * Send a notification (no pending request / no timeout). * @@ -1374,19 +1381,10 @@ export class TerminalHostClient extends EventEmitter { return `${sessionId}:${requestId}`; } - // =========================================================================== - // Public API - // =========================================================================== - - /** - * Create or attach to a terminal session - */ - async createOrAttach( + private throwIfCreateOrAttachCanceled( request: CreateOrAttachRequest, signal?: AbortSignal, - ): Promise { - throwIfAborted(signal); - await this.ensureConnected(); + ): void { throwIfAborted(signal); if ( request.requestId && @@ -1399,10 +1397,40 @@ export class TerminalHostClient extends EventEmitter { ) { throw new TerminalAttachCanceledError(); } - const response = await this.sendRequest( - "createOrAttach", - request, - ); + } + + // =========================================================================== + // Public API + // =========================================================================== + + /** + * Create or attach to a terminal session + */ + async createOrAttach( + request: CreateOrAttachRequest, + signal?: AbortSignal, + ): Promise { + this.throwIfCreateOrAttachCanceled(request, signal); + await this.ensureConnected(); + this.throwIfCreateOrAttachCanceled(request, signal); + let response: CreateOrAttachResponse; + try { + response = await this.sendRequest( + "createOrAttach", + request, + ); + } catch (error) { + if (!this.isCreateOrAttachTimeoutError(error)) { + throw error; + } + this.resetConnectionState({ emitDisconnected: false }); + await this.ensureConnected(); + this.throwIfCreateOrAttachCanceled(request, signal); + response = await this.sendRequest( + "createOrAttach", + request, + ); + } // Version skew: older daemons may not return pid - normalize undefined → null return { ...response, pid: response.pid ?? null }; } diff --git a/apps/desktop/src/main/lib/terminal/env.ts b/apps/desktop/src/main/lib/terminal/env.ts index 883582ba935..4d2c1d46001 100644 --- a/apps/desktop/src/main/lib/terminal/env.ts +++ b/apps/desktop/src/main/lib/terminal/env.ts @@ -1,9 +1,12 @@ import { exec } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; +import { settings } from "@superset/local-db"; import defaultShell from "default-shell"; +import { DEFAULT_PREVENT_AGENT_SLEEP } from "shared/constants"; import { env } from "shared/env.shared"; import { getShellEnv } from "../agent-setup/shell-wrappers"; +import { localDb } from "../local-db"; const MACOS_SYSTEM_CERT_FILE = "/etc/ssl/cert.pem"; let cachedUtf8Locale: string | null = null; @@ -460,6 +463,9 @@ export function buildTerminalEnv(params: { // COLORFGBG: "foreground;background" ANSI color indices — TUI apps use this to detect light/dark const colorFgBg = themeType === "light" ? "0;15" : "15;0"; + const preventAgentSleepSetting = + localDb.select().from(settings).get()?.preventAgentSleep ?? + DEFAULT_PREVENT_AGENT_SLEEP; const terminalEnv: Record = { ...baseEnv, @@ -480,6 +486,7 @@ export function buildTerminalEnv(params: { SUPERSET_ENV: env.NODE_ENV === "development" ? "development" : "production", // Hook protocol version for forward compatibility SUPERSET_HOOK_VERSION: HOOK_PROTOCOL_VERSION, + SUPERSET_PREVENT_AGENT_SLEEP: preventAgentSleepSetting ? "1" : "0", }; delete terminalEnv.GOOGLE_API_KEY; diff --git a/apps/desktop/src/main/lib/window-manager/index.ts b/apps/desktop/src/main/lib/window-manager/index.ts new file mode 100644 index 00000000000..ea146a92c65 --- /dev/null +++ b/apps/desktop/src/main/lib/window-manager/index.ts @@ -0,0 +1,146 @@ +import { join } from "node:path"; +import { type BrowserWindow, ipcMain, nativeTheme } from "electron"; +import { createWindow } from "lib/electron-app/factories/windows/create"; + +interface TearoffWindowOptions { + windowId: string; + screenX: number; + screenY: number; + width?: number; + height?: number; +} + +interface TearoffTabData { + tab: unknown; + panes: Record; + workspaceId: string; +} + +type IpcHandler = { + attachWindow: (window: BrowserWindow) => void; + detachWindow: (window: BrowserWindow) => void; +}; + +export class WindowManager { + private windows = new Map(); + private ipcHandler: IpcHandler | null = null; + private ipcRegistered = false; + private pendingTearoffData = new Map(); + + setIpcHandler(handler: IpcHandler): void { + this.ipcHandler = handler; + this.registerIpcHandlers(); + } + + private registerIpcHandlers(): void { + if (this.ipcRegistered) return; + this.ipcRegistered = true; + + // Synchronous IPC: preload fetches tearoff data before React starts + ipcMain.on("get-tearoff-data", (event, windowId: string) => { + const data = this.pendingTearoffData.get(windowId); + if (data) this.pendingTearoffData.delete(windowId); + event.returnValue = data ?? null; + }); + + // Tearoff window closing: return all tabs to main window (single message) + ipcMain.on( + "tearoff-return-tabs", + ( + _event, + data: Array<{ tab: unknown; panes: Record }>, + ) => { + const mainWindow = this.getMain(); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send("tearoff-tab-returned", data); + } else { + console.warn( + "[window-manager] Main window unavailable; returned tabs lost:", + data.length, + ); + } + }, + ); + } + + setPendingTearoffData(windowId: string, data: TearoffTabData): void { + this.pendingTearoffData.set(windowId, data); + setTimeout(() => this.pendingTearoffData.delete(windowId), 30_000); + } + + register(windowId: string, window: BrowserWindow): void { + this.windows.set(windowId, window); + } + + unregister(windowId: string): void { + this.windows.delete(windowId); + } + + get(windowId: string): BrowserWindow | null { + return this.windows.get(windowId) ?? null; + } + + getMain(): BrowserWindow | null { + return this.windows.get("main") ?? null; + } + + getAll(): Map { + return new Map(this.windows); + } + + createTearoffWindow(options: TearoffWindowOptions): { + windowId: string; + window: BrowserWindow; + } { + const { windowId } = options; + + const window = createWindow({ + id: "tearoff", + title: "Superset", + width: options.width ?? 900, + height: options.height ?? 600, + x: Math.round(options.screenX - 100), + y: Math.round(options.screenY - 20), + minWidth: 400, + minHeight: 400, + show: false, + backgroundColor: nativeTheme.shouldUseDarkColors ? "#252525" : "#ffffff", + frame: false, + titleBarStyle: "hidden", + trafficLightPosition: { x: 16, y: 16 }, + webPreferences: { + preload: join(__dirname, "../preload/index.js"), + webviewTag: true, + partition: "persist:superset", + additionalArguments: [`--tearoff-window-id=${windowId}`], + }, + }); + + this.register(windowId, window); + this.ipcHandler?.attachWindow(window); + + // Detach IPC BEFORE window is destroyed (close fires before closed) + window.on("close", () => { + this.ipcHandler?.detachWindow(window); + }); + window.on("closed", () => { + this.windows.delete(windowId); + }); + + window.webContents.once("did-finish-load", () => { + window.show(); + }); + + return { windowId, window }; + } + + broadcast(channel: string, ...args: unknown[]): void { + for (const window of this.windows.values()) { + if (!window.isDestroyed()) { + window.webContents.send(channel, ...args); + } + } + } +} + +export const windowManager = new WindowManager(); diff --git a/apps/desktop/src/main/lib/window-state/bounds-validation.test.ts b/apps/desktop/src/main/lib/window-state/bounds-validation.test.ts index 9bba586c621..687e47eeb39 100644 --- a/apps/desktop/src/main/lib/window-state/bounds-validation.test.ts +++ b/apps/desktop/src/main/lib/window-state/bounds-validation.test.ts @@ -302,6 +302,28 @@ describe("getInitialWindowBounds", () => { }); }); + describe("position restore disabled", () => { + it("should center while preserving saved size when position restore is disabled", () => { + const result = getInitialWindowBounds( + { + x: 100, + y: 200, + width: 800, + height: 600, + isMaximized: false, + }, + { restorePosition: false }, + ); + + expect(result).toEqual({ + width: 800, + height: 600, + center: true, + isMaximized: false, + }); + }); + }); + describe("dimension clamping", () => { it("should clamp width to work area size", () => { const result = getInitialWindowBounds({ diff --git a/apps/desktop/src/main/lib/window-state/bounds-validation.ts b/apps/desktop/src/main/lib/window-state/bounds-validation.ts index fa70718d97b..4d291dfbf85 100644 --- a/apps/desktop/src/main/lib/window-state/bounds-validation.ts +++ b/apps/desktop/src/main/lib/window-state/bounds-validation.ts @@ -77,6 +77,10 @@ export interface InitialWindowBounds { isMaximized: boolean; } +interface GetInitialWindowBoundsOptions { + restorePosition?: boolean; +} + /** * Computes initial window bounds from saved state, with fallbacks. * @@ -86,6 +90,7 @@ export interface InitialWindowBounds { */ export function getInitialWindowBounds( savedState: WindowState | null, + options: GetInitialWindowBoundsOptions = {}, ): InitialWindowBounds { const { workAreaSize } = getScreen().getPrimaryDisplay(); @@ -104,6 +109,15 @@ export function getInitialWindowBounds( savedState.height, ); + if (options.restorePosition === false) { + return { + width, + height, + center: true, + isMaximized: savedState.isMaximized, + }; + } + const savedBounds: Rectangle = { x: savedState.x, y: savedState.y, diff --git a/apps/desktop/src/main/lib/window-state/index.ts b/apps/desktop/src/main/lib/window-state/index.ts index dabdb477f1b..3ff0f897042 100644 --- a/apps/desktop/src/main/lib/window-state/index.ts +++ b/apps/desktop/src/main/lib/window-state/index.ts @@ -3,6 +3,10 @@ export { type InitialWindowBounds, isVisibleOnAnyDisplay, } from "./bounds-validation"; +export { + isWindowPositionPersistenceEnabled, + setWindowStateEnvironmentForTesting, +} from "./position-persistence"; export { isValidWindowState, loadWindowState, diff --git a/apps/desktop/src/main/lib/window-state/position-persistence.test.ts b/apps/desktop/src/main/lib/window-state/position-persistence.test.ts new file mode 100644 index 00000000000..dc38fe19db7 --- /dev/null +++ b/apps/desktop/src/main/lib/window-state/position-persistence.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { + isWindowPositionPersistenceEnabled, + setWindowStateEnvironmentForTesting, +} from "./position-persistence"; + +afterEach(() => { + setWindowStateEnvironmentForTesting(null); +}); + +describe("isWindowPositionPersistenceEnabled", () => { + it("should disable position persistence on Linux Wayland", () => { + setWindowStateEnvironmentForTesting({ + platform: "linux", + env: { + XDG_SESSION_TYPE: "wayland", + }, + }); + + expect(isWindowPositionPersistenceEnabled()).toBe(false); + }); + + it("should disable position persistence when WAYLAND_DISPLAY is set", () => { + setWindowStateEnvironmentForTesting({ + platform: "linux", + env: { + WAYLAND_DISPLAY: "wayland-1", + }, + }); + + expect(isWindowPositionPersistenceEnabled()).toBe(false); + }); + + it("should keep position persistence on Linux X11", () => { + setWindowStateEnvironmentForTesting({ + platform: "linux", + env: { + XDG_SESSION_TYPE: "x11", + }, + }); + + expect(isWindowPositionPersistenceEnabled()).toBe(true); + }); + + it("should keep position persistence on non-Linux platforms", () => { + setWindowStateEnvironmentForTesting({ + platform: "darwin", + env: { + XDG_SESSION_TYPE: "wayland", + }, + }); + + expect(isWindowPositionPersistenceEnabled()).toBe(true); + }); +}); diff --git a/apps/desktop/src/main/lib/window-state/position-persistence.ts b/apps/desktop/src/main/lib/window-state/position-persistence.ts new file mode 100644 index 00000000000..255bd113308 --- /dev/null +++ b/apps/desktop/src/main/lib/window-state/position-persistence.ts @@ -0,0 +1,27 @@ +let platformOverride: NodeJS.Platform | null = null; +let envOverride: NodeJS.ProcessEnv | null = null; + +function getPlatform(): NodeJS.Platform { + return platformOverride ?? process.platform; +} + +function getEnv(): NodeJS.ProcessEnv { + return envOverride ?? process.env; +} + +export function setWindowStateEnvironmentForTesting( + override: { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + } | null, +): void { + platformOverride = override?.platform ?? null; + envOverride = override?.env ?? null; +} + +export function isWindowPositionPersistenceEnabled(): boolean { + if (getPlatform() !== "linux") return true; + + const env = getEnv(); + return env.XDG_SESSION_TYPE !== "wayland" && !env.WAYLAND_DISPLAY; +} diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index e932fc634e2..f280484d7ca 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -1,8 +1,9 @@ import { join } from "node:path"; +import * as Sentry from "@sentry/electron/main"; import { workspaces, worktrees } from "@superset/local-db"; import { eq } from "drizzle-orm"; import type { BrowserWindow } from "electron"; -import { app, Notification, nativeTheme } from "electron"; +import { app, Notification, nativeTheme, webContents } from "electron"; import { createWindow } from "lib/electron-app/factories/windows/create"; import { createAppRouter } from "lib/trpc/routers"; import { localDb } from "main/lib/local-db"; @@ -28,8 +29,10 @@ import { getNotificationTitle, getWorkspaceName, } from "../lib/notifications/utils"; +import { windowManager } from "../lib/window-manager"; import { getInitialWindowBounds, + isWindowPositionPersistenceEnabled, loadWindowState, saveWindowState, } from "../lib/window-state"; @@ -62,6 +65,18 @@ function getWorkspaceNameFromDb(workspaceId: string | undefined): string { let currentWindow: BrowserWindow | null = null; +function addWindowLifecycleBreadcrumb( + message: string, + data?: Record, +): void { + Sentry.addBreadcrumb({ + category: "window.lifecycle", + level: "info", + message, + data, + }); +} + // Routers receive this getter so they always see the current window, not a stale reference const getWindow = () => currentWindow; @@ -70,12 +85,23 @@ const getWindow = () => currentWindow; const forceRepaint = (win: BrowserWindow) => { if (win.isDestroyed()) return; win.webContents.invalidate(); - if (win.isMaximized() || win.isFullScreen()) return; - const [width, height] = win.getSize(); - win.setSize(width + 1, height); - setTimeout(() => { - if (!win.isDestroyed()) win.setSize(width, height); - }, 32); + if (win.isFullScreen()) { + win.setFullScreen(false); + setTimeout(() => { + if (!win.isDestroyed()) win.setFullScreen(true); + }, 100); + } else if (win.isMaximized()) { + win.unmaximize(); + setTimeout(() => { + if (!win.isDestroyed()) win.maximize(); + }, 100); + } else { + const [width, height] = win.getSize(); + win.setSize(width + 1, height); + setTimeout(() => { + if (!win.isDestroyed()) win.setSize(width, height); + }, 32); + } }; // GPU process restarts don't repaint existing compositor layers automatically. @@ -88,8 +114,11 @@ app.on("child-process-gone", (_event, details) => { }); export async function MainWindow() { + const shouldPersistWindowPosition = isWindowPositionPersistenceEnabled(); const savedWindowState = loadWindowState(); - const initialBounds = getInitialWindowBounds(savedWindowState); + const initialBounds = getInitialWindowBounds(savedWindowState, { + restorePosition: shouldPersistWindowPosition, + }); let persistedZoomLevel = savedWindowState?.zoomLevel; const isDev = env.NODE_ENV === "development"; @@ -130,6 +159,7 @@ export async function MainWindow() { registerMenuHotkeyUpdates(); currentWindow = window; + windowManager.register("main", window); // macOS Sequoia+: background throttling can corrupt GPU compositor layers if (PLATFORM.IS_MAC) { @@ -140,9 +170,10 @@ export async function MainWindow() { ipcHandler.attachWindow(window); } else { ipcHandler = createIPCHandler({ - router: createAppRouter(getWindow), + router: createAppRouter(getWindow, windowManager), windows: [window], }); + windowManager.setIpcHandler(ipcHandler); } const server = notificationsApp.listen( @@ -214,9 +245,11 @@ export async function MainWindow() { // macOS Sequoia+: occluded/minimized windows can lose compositor layers if (PLATFORM.IS_MAC) { window.on("restore", () => { + addWindowLifecycleBreadcrumb("main window restored"); window.webContents.invalidate(); }); window.on("show", () => { + addWindowLifecycleBreadcrumb("main window shown"); window.webContents.invalidate(); }); } @@ -228,28 +261,34 @@ export async function MainWindow() { let initialized = false; let hasCompletedFirstLoad = false; let saveTimeout: ReturnType | null = null; + + const getWindowStateSnapshot = () => { + const isMaximized = window.isMaximized(); + const bounds = isMaximized ? window.getNormalBounds() : window.getBounds(); + const zoomLevel = window.webContents.getZoomLevel(); + return { + x: shouldPersistWindowPosition ? bounds.x : 0, + y: shouldPersistWindowPosition ? bounds.y : 0, + width: bounds.width, + height: bounds.height, + isMaximized, + zoomLevel, + }; + }; + const debouncedSave = () => { if (!initialized || window.isDestroyed()) return; if (saveTimeout) clearTimeout(saveTimeout); saveTimeout = setTimeout(() => { if (window.isDestroyed()) return; - const isMaximized = window.isMaximized(); - const bounds = isMaximized - ? window.getNormalBounds() - : window.getBounds(); - const zoomLevel = window.webContents.getZoomLevel(); - saveWindowState({ - x: bounds.x, - y: bounds.y, - width: bounds.width, - height: bounds.height, - isMaximized, - zoomLevel, - }); - persistedZoomLevel = zoomLevel; + const state = getWindowStateSnapshot(); + saveWindowState(state); + persistedZoomLevel = state.zoomLevel; }, 500); }; - window.on("move", debouncedSave); + if (shouldPersistWindowPosition) { + window.on("move", debouncedSave); + } window.on("resize", debouncedSave); window.webContents.on("zoom-changed", () => { setTimeout(() => { @@ -289,7 +328,19 @@ export async function MainWindow() { ); window.webContents.on("render-process-gone", (_event, details) => { + addWindowLifecycleBreadcrumb("renderer process gone", { + reason: details.reason, + exitCode: details.exitCode, + }); console.error("[main-window] Renderer process gone:", details); + if (window.isDestroyed()) return; + + if (details.reason === "oom") { + app.relaunch(); + app.exit(0); + } else if (details.reason !== "clean-exit") { + window.webContents.reload(); + } }); window.webContents.on("preload-error", (_event, preloadPath, error) => { @@ -298,20 +349,31 @@ export async function MainWindow() { console.error(` Error:`, error); }); + // Handle mouse back/forward buttons for webview panes (Windows/Linux). + // `app-command` is not supported on macOS; macOS mouse buttons are handled + // via executeJavaScript injection in usePersistentWebview's dom-ready handler. + window.on("app-command", (_event, command) => { + const focusedGuest = webContents + .getAllWebContents() + .find((wc) => wc.getType() === "webview" && wc.isFocused()); + if (!focusedGuest) return; + + if (command === "browser-backward") { + focusedGuest.navigationHistory.goBack(); + } else if (command === "browser-forward") { + focusedGuest.navigationHistory.goForward(); + } + }); + window.on("close", () => { - // Save window state first, before any cleanup - const isMaximized = window.isMaximized(); - const bounds = isMaximized ? window.getNormalBounds() : window.getBounds(); - const zoomLevel = window.webContents.getZoomLevel(); - saveWindowState({ - x: bounds.x, - y: bounds.y, - width: bounds.width, - height: bounds.height, - isMaximized, - zoomLevel, + addWindowLifecycleBreadcrumb("main window closing", { + isDestroyed: window.isDestroyed(), + isVisible: window.isVisible(), }); - persistedZoomLevel = zoomLevel; + // Save window state first, before any cleanup + const state = getWindowStateSnapshot(); + saveWindowState(state); + persistedZoomLevel = state.zoomLevel; browserManager.unregisterAll(); server.close(); @@ -321,6 +383,7 @@ export async function MainWindow() { getWorkspaceRuntimeRegistry().getDefault().terminal.detachAllListeners(); // Detach window from IPC handler (handler stays alive for window reopen) ipcHandler?.detachWindow(window); + windowManager.unregister("main"); currentWindow = null; }); diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 8a8ffcb6f28..96c3d2a6604 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -15,10 +15,22 @@ declare global { } } +// Tearoff: synchronously fetch tab data BEFORE React/Zustand initialize +const tearoffWindowId = (() => { + const arg = process.argv.find((a) => a.startsWith("--tearoff-window-id=")); + return arg ? arg.split("=")[1] : null; +})(); +// biome-ignore lint/suspicious/noExplicitAny: tearoff data is untyped at preload level +const tearoffData: any = tearoffWindowId + ? ipcRenderer.sendSync("get-tearoff-data", tearoffWindowId) + : null; + const API = { sayHelloFromBridge: () => console.log("\nHello from bridgeAPI! 👋\n\n"), username: process.env.USER, appVersion: __APP_VERSION__, + tearoffWindowId, + tearoffData, }; // Store mapping of user listeners to wrapped listeners for proper cleanup diff --git a/apps/desktop/src/renderer/components/MarkdownRenderer/MarkdownRenderer.tsx b/apps/desktop/src/renderer/components/MarkdownRenderer/MarkdownRenderer.tsx index 7ca1d194c71..4d2567d72f0 100644 --- a/apps/desktop/src/renderer/components/MarkdownRenderer/MarkdownRenderer.tsx +++ b/apps/desktop/src/renderer/components/MarkdownRenderer/MarkdownRenderer.tsx @@ -18,12 +18,14 @@ interface MarkdownRendererProps { content: string; style?: keyof typeof styleConfigs; className?: string; + scrollable?: boolean; } export function MarkdownRenderer({ content, style: styleProp, className, + scrollable = true, }: MarkdownRendererProps) { const globalStyle = useMarkdownStyle(); const style = styleProp ?? globalStyle; @@ -34,7 +36,8 @@ export function MarkdownRenderer({
Update available {version - ? `Version ${version} is ready to install` - : "Ready to install"} - - - Your terminal sessions won't be interrupted. + ? `Version ${version} is available upstream` + : "A new version is available"} )} @@ -88,12 +85,8 @@ export function UpdateToast({ -
)} diff --git a/apps/desktop/src/renderer/env.renderer.ts b/apps/desktop/src/renderer/env.renderer.ts index b51c759296e..d96e12378e2 100644 --- a/apps/desktop/src/renderer/env.renderer.ts +++ b/apps/desktop/src/renderer/env.renderer.ts @@ -18,6 +18,7 @@ const envSchema = z.object({ NEXT_PUBLIC_API_URL: z.url().default("https://api.superset.sh"), NEXT_PUBLIC_WEB_URL: z.url().default("https://app.superset.sh"), NEXT_PUBLIC_MARKETING_URL: z.url().default("https://superset.sh"), + NEXT_PUBLIC_OPEN_LINK_URL: z.url().default("https://superset.m4gu.dev"), NEXT_PUBLIC_ELECTRIC_URL: z .url() .default("https://electric-proxy.avi-6ac.workers.dev"), @@ -38,6 +39,7 @@ const rawEnv = { NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, NEXT_PUBLIC_WEB_URL: process.env.NEXT_PUBLIC_WEB_URL, NEXT_PUBLIC_MARKETING_URL: process.env.NEXT_PUBLIC_MARKETING_URL, + NEXT_PUBLIC_OPEN_LINK_URL: process.env.NEXT_PUBLIC_OPEN_LINK_URL, NEXT_PUBLIC_ELECTRIC_URL: process.env.NEXT_PUBLIC_ELECTRIC_URL, NEXT_PUBLIC_POSTHOG_KEY: import.meta.env.NEXT_PUBLIC_POSTHOG_KEY as | string diff --git a/apps/desktop/src/renderer/globals.css b/apps/desktop/src/renderer/globals.css index a21a41cd21f..6a8860fbe4f 100644 --- a/apps/desktop/src/renderer/globals.css +++ b/apps/desktop/src/renderer/globals.css @@ -280,4 +280,143 @@ [data-sonner-toast]:has(.update-toast) { transform: translateY(0); } + + /* Review comment markdown body */ + .review-comment-body p { + margin: 0.25rem 0; + } + .review-comment-body h1, + .review-comment-body h2, + .review-comment-body h3, + .review-comment-body h4 { + margin: 0.5rem 0 0.25rem; + font-weight: 600; + } + .review-comment-body h1 { + font-size: 1em; + } + .review-comment-body h2 { + font-size: 0.95em; + } + .review-comment-body h3 { + font-size: 0.9em; + } + .review-comment-body ul, + .review-comment-body ol { + margin: 0.25rem 0; + padding-left: 1.25rem; + } + .review-comment-body li { + margin: 0.125rem 0; + } + .review-comment-body ul { + list-style-type: disc; + } + .review-comment-body ol { + list-style-type: decimal; + } + .review-comment-body code { + font-size: 0.9em; + padding: 0.1em 0.3em; + border-radius: 3px; + background-color: var(--muted); + } + .review-comment-body pre { + margin: 0.375rem 0; + padding: 0.5rem; + border-radius: 4px; + background-color: var(--muted); + overflow-x: auto; + } + .review-comment-body pre code { + padding: 0; + background: none; + } + .review-comment-body blockquote { + margin: 0.25rem 0; + padding-left: 0.75rem; + border-left: 2px solid var(--border); + color: var(--muted-foreground); + } + .review-comment-body a { + color: #60a5fa; + text-decoration: underline; + } + .review-comment-body hr { + margin: 0.5rem 0; + border-color: var(--border); + } + .review-comment-body table { + border-collapse: collapse; + margin: 0.25rem 0; + } + .review-comment-body th, + .review-comment-body td { + border: 1px solid var(--border); + padding: 0.25rem 0.5rem; + font-size: 0.9em; + } + .review-comment-body img { + max-width: 100%; + border-radius: 4px; + } + .review-comment-body input[type="checkbox"] { + margin-right: 0.35rem; + } + + /* GitHub-style alerts (> [!NOTE], > [!TIP], etc.) */ + .review-comment-body .markdown-alert { + border-left: 3px solid; + padding: 0.375rem 0.75rem; + margin: 0.375rem 0; + border-radius: 0 4px 4px 0; + } + .review-comment-body .markdown-alert-title { + display: flex; + align-items: center; + gap: 0.375rem; + font-weight: 600; + font-size: 0.85em; + margin-bottom: 0.125rem; + } + .review-comment-body .markdown-alert-title svg { + width: 14px; + height: 14px; + fill: currentColor; + } + .review-comment-body .markdown-alert-note { + border-left-color: #58a6ff; + background-color: rgba(88, 166, 255, 0.06); + } + .review-comment-body .markdown-alert-note .markdown-alert-title { + color: #58a6ff; + } + .review-comment-body .markdown-alert-tip { + border-left-color: #3fb950; + background-color: rgba(63, 185, 80, 0.06); + } + .review-comment-body .markdown-alert-tip .markdown-alert-title { + color: #3fb950; + } + .review-comment-body .markdown-alert-important { + border-left-color: #a371f7; + background-color: rgba(163, 113, 247, 0.06); + } + .review-comment-body .markdown-alert-important .markdown-alert-title { + color: #a371f7; + } + .review-comment-body .markdown-alert-warning { + border-left-color: #d29922; + background-color: rgba(210, 153, 34, 0.06); + } + .review-comment-body .markdown-alert-warning .markdown-alert-title { + color: #d29922; + } + .review-comment-body .markdown-alert-caution { + border-left-color: #f85149; + background-color: rgba(248, 81, 73, 0.06); + } + .review-comment-body .markdown-alert-caution .markdown-alert-title { + color: #f85149; + } } diff --git a/apps/desktop/src/renderer/hooks/useBrowserNewWindowHandler.ts b/apps/desktop/src/renderer/hooks/useBrowserNewWindowHandler.ts new file mode 100644 index 00000000000..797c68deb8e --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useBrowserNewWindowHandler.ts @@ -0,0 +1,23 @@ +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { useTabsStore } from "renderer/stores/tabs/store"; + +/** + * Global handler for new-window events from any browser pane. + * + * This must be mounted in a component that is **always rendered** (e.g. the + * dashboard layout) because webviews persist in a hidden container even when + * their BrowserPane component is unmounted. Without a persistent listener, + * target="_blank" clicks in hidden webviews would be silently lost. + */ +export function useBrowserNewWindowHandler() { + electronTrpc.browser.onAnyNewWindow.useSubscription(undefined, { + onData: ({ paneId, url }) => { + const state = useTabsStore.getState(); + const pane = state.panes[paneId]; + if (!pane) return; + const tab = state.tabs.find((t) => t.id === pane.tabId); + if (!tab) return; + state.addBrowserTab(tab.workspaceId, url); + }, + }); +} diff --git a/apps/desktop/src/renderer/hooks/useTearoffInit/index.ts b/apps/desktop/src/renderer/hooks/useTearoffInit/index.ts new file mode 100644 index 00000000000..15becde358b --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useTearoffInit/index.ts @@ -0,0 +1,6 @@ +export { + getTearoffWindowId, + isTearoffWindow, + useReturnedTabListener, + useTearoffInit, +} from "./useTearoffInit"; diff --git a/apps/desktop/src/renderer/hooks/useTearoffInit/useTearoffInit.ts b/apps/desktop/src/renderer/hooks/useTearoffInit/useTearoffInit.ts new file mode 100644 index 00000000000..21e4073aa9a --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useTearoffInit/useTearoffInit.ts @@ -0,0 +1,81 @@ +import { useNavigate } from "@tanstack/react-router"; +import { useEffect, useRef } from "react"; +import { useTabsStore } from "renderer/stores/tabs/store"; +import type { Tab } from "renderer/stores/tabs/types"; +import type { Pane } from "shared/tabs-types"; + +// Cached at module load from preload-injected data +const _cachedWindowId: string | null = + typeof window !== "undefined" ? (window.App?.tearoffWindowId ?? null) : null; + +export function getTearoffWindowId(): string | null { + return _cachedWindowId; +} + +export function isTearoffWindow(): boolean { + return _cachedWindowId !== null; +} + +export function useTearoffInit() { + const initialized = useRef(false); + const navigate = useNavigate(); + const tabs = useTabsStore((s) => s.tabs); + + // Navigate to the workspace for the tearoff tab + useEffect(() => { + if (!_cachedWindowId || initialized.current || tabs.length === 0) return; + initialized.current = true; + const tab = tabs[0]; + navigate({ to: `/workspace/${tab.workspaceId}`, replace: true }); + }, [tabs, navigate]); + + // Return ALL tabs to main window when this tearoff window closes + useEffect(() => { + if (!_cachedWindowId) return; + const handleBeforeUnload = () => { + const state = useTabsStore.getState(); + if (state.tabs.length === 0) return; + + // Collect all tabs + their panes into a single message + const tabsWithPanes = state.tabs.map((tab) => { + const panes: Record = {}; + for (const [id, pane] of Object.entries(state.panes)) { + if (pane.tabId === tab.id) { + panes[id] = pane; + } + } + return { tab, panes }; + }); + + // Send as ONE message to avoid race conditions + window.ipcRenderer.send("tearoff-return-tabs", tabsWithPanes); + }; + window.addEventListener("beforeunload", handleBeforeUnload); + return () => window.removeEventListener("beforeunload", handleBeforeUnload); + }, []); +} + +export function useReturnedTabListener() { + useEffect(() => { + if (isTearoffWindow()) return; + const handler = ( + entries: Array<{ tab: unknown; panes: Record }>, + ) => { + const store = useTabsStore.getState(); + const existingTabIds = new Set(store.tabs.map((t) => t.id)); + + for (const entry of entries) { + const tab = entry.tab as Tab; + // Skip if tab already exists (prevent duplicates) + if (existingTabIds.has(tab.id)) continue; + const panes = entry.panes as Record; + store.hydrateReturnedTab(tab, panes); + existingTabIds.add(tab.id); + } + }; + window.ipcRenderer.on("tearoff-tab-returned", handler); + return () => { + window.ipcRenderer.off("tearoff-tab-returned", handler); + }; + }, []); +} diff --git a/apps/desktop/src/renderer/lib/browser-shortcut-events.ts b/apps/desktop/src/renderer/lib/browser-shortcut-events.ts new file mode 100644 index 00000000000..dac403f71cf --- /dev/null +++ b/apps/desktop/src/renderer/lib/browser-shortcut-events.ts @@ -0,0 +1,42 @@ +import type { BrowserShortcutAction } from "shared/browser-shortcuts"; + +const BROWSER_SHORTCUT_EVENT = "superset:browser-shortcut"; + +interface BrowserShortcutEventDetail { + action: BrowserShortcutAction; +} + +export function dispatchBrowserShortcutEvent( + action: BrowserShortcutAction, +): void { + if (typeof window === "undefined") return; + + window.dispatchEvent( + new CustomEvent(BROWSER_SHORTCUT_EVENT, { + detail: { action }, + }), + ); +} + +export function addBrowserShortcutListener( + listener: (action: BrowserShortcutAction) => void, +): () => void { + if ( + typeof window === "undefined" || + typeof window.addEventListener !== "function" + ) { + return () => {}; + } + + const handleEvent = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (!detail) return; + listener(detail.action); + }; + + window.addEventListener(BROWSER_SHORTCUT_EVENT, handleEvent); + + return () => { + window.removeEventListener(BROWSER_SHORTCUT_EVENT, handleEvent); + }; +} diff --git a/apps/desktop/src/renderer/lib/superset-open-links.ts b/apps/desktop/src/renderer/lib/superset-open-links.ts new file mode 100644 index 00000000000..0a7d0b4aa72 --- /dev/null +++ b/apps/desktop/src/renderer/lib/superset-open-links.ts @@ -0,0 +1,93 @@ +import { env } from "renderer/env.renderer"; +import { toRelativeWorkspacePath } from "shared/absolute-paths"; + +export interface SupersetLinkProject { + githubOwner: string | null; + githubRepoName: string | null; + mainRepoPath: string; +} + +interface BuildSupersetOpenLinkOptions { + project: SupersetLinkProject; + branch?: string | null; + worktreePath?: string | null; + filePath?: string | null; + line?: number | null; + column?: number | null; +} + +function normalizePathSegment(value: string): string { + return value.replace(/\\/g, "/").trim(); +} + +function getRepoName(project: SupersetLinkProject): string | null { + const githubRepoName = project.githubRepoName?.trim(); + if (githubRepoName) { + return githubRepoName; + } + + const segments = normalizePathSegment(project.mainRepoPath) + .split("/") + .filter(Boolean); + return segments.at(-1) ?? null; +} + +function normalizePositiveInteger( + value: number | null | undefined, +): string | null { + if (value == null) { + return null; + } + + const normalized = Math.trunc(value); + return normalized > 0 ? String(normalized) : null; +} + +export function buildSupersetOpenLink({ + project, + branch, + worktreePath, + filePath, + line, + column, +}: BuildSupersetOpenLinkOptions): string | null { + const repoName = getRepoName(project); + if (!repoName) { + return null; + } + + const repo = project.githubOwner?.trim() + ? `${project.githubOwner.trim()}/${repoName}` + : repoName; + const url = new URL("/open", env.NEXT_PUBLIC_OPEN_LINK_URL); + + url.searchParams.set("repo", repo); + + const normalizedBranch = branch?.trim(); + if (normalizedBranch) { + url.searchParams.set("branch", normalizedBranch); + } + + const normalizedFilePath = filePath + ? normalizePathSegment( + worktreePath + ? toRelativeWorkspacePath(worktreePath, filePath) + : filePath, + ) + : null; + if (normalizedFilePath) { + url.searchParams.set("file", normalizedFilePath); + } + + const normalizedLine = normalizePositiveInteger(line); + if (normalizedLine) { + url.searchParams.set("line", normalizedLine); + } + + const normalizedColumn = normalizePositiveInteger(column); + if (normalizedColumn) { + url.searchParams.set("column", normalizedColumn); + } + + return url.toString(); +} diff --git a/apps/desktop/src/renderer/lib/trpc-storage.ts b/apps/desktop/src/renderer/lib/trpc-storage.ts index 833eb84c76d..44c7c92305e 100644 --- a/apps/desktop/src/renderer/lib/trpc-storage.ts +++ b/apps/desktop/src/renderer/lib/trpc-storage.ts @@ -2,6 +2,10 @@ import type { HotkeysState } from "shared/hotkeys"; import { createJSONStorage, type StateStorage } from "zustand/middleware"; import { electronTrpcClient } from "./trpc-client"; +/** Cached at module load: true if the current window is a tear-off window. */ +const _isTearoffWindow = + typeof window !== "undefined" && !!window.App?.tearoffWindowId; + /** * Flag to skip the next hotkeys persist operation. * Used when syncing from remote to avoid echo writes. @@ -150,6 +154,8 @@ function createTrpcStorageAdapter(config: TrpcStorageConfig): StateStorage { return { getItem: async (name: string): Promise => { + // Tear-off windows skip persist hydration entirely + if (_isTearoffWindow) return null; try { const state = await config.get(); const version = Number.parseInt( @@ -206,6 +212,8 @@ function createTrpcStorageAdapter(config: TrpcStorageConfig): StateStorage { } }, setItem: async (name: string, value: string): Promise => { + // Tear-off windows must not persist + if (_isTearoffWindow) return; if (value === pendingValue || value === lastFlushedValue) { return; } diff --git a/apps/desktop/src/renderer/providers/LanguageServicesProvider/LanguageServicesProvider.tsx b/apps/desktop/src/renderer/providers/LanguageServicesProvider/LanguageServicesProvider.tsx new file mode 100644 index 00000000000..c3660c15948 --- /dev/null +++ b/apps/desktop/src/renderer/providers/LanguageServicesProvider/LanguageServicesProvider.tsx @@ -0,0 +1,290 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; +import { + getDocumentCurrentContent, + hasInitializedDocumentBuffer, +} from "renderer/stores/editor-state/editorBufferRegistry"; +import { useEditorDocumentsStore } from "renderer/stores/editor-state/useEditorDocumentsStore"; +import { + type LanguageServiceProviderId, + useLanguageServicePreferencesStore, +} from "renderer/stores/language-service-preferences"; + +type TrackedDocument = { + documentKey: string; + workspaceId: string; + absolutePath: string; + languageId: string; + content: string; + version: number; +}; + +function resolveLanguageId(absolutePath: string): string | null { + const normalizedPath = absolutePath.toLowerCase().replaceAll("\\", "/"); + const fileName = normalizedPath.split("/").at(-1) ?? normalizedPath; + if (normalizedPath.endsWith(".tsx")) { + return "typescriptreact"; + } + if ( + normalizedPath.endsWith(".ts") || + normalizedPath.endsWith(".mts") || + normalizedPath.endsWith(".cts") + ) { + return "typescript"; + } + if (normalizedPath.endsWith(".jsx")) { + return "javascriptreact"; + } + if ( + normalizedPath.endsWith(".js") || + normalizedPath.endsWith(".mjs") || + normalizedPath.endsWith(".cjs") + ) { + return "javascript"; + } + if ( + normalizedPath.endsWith(".jsonc") || + fileName === "jsconfig.json" || + fileName === "settings.json" || + fileName === "extensions.json" || + fileName === "launch.json" || + fileName === "tasks.json" || + fileName === "keybindings.json" || + /^tsconfig\..+\.json$/.test(fileName) || + fileName === "tsconfig.json" + ) { + return "jsonc"; + } + if (normalizedPath.endsWith(".json")) { + return "json"; + } + if (normalizedPath.endsWith(".toml")) { + return "toml"; + } + if (normalizedPath.endsWith(".py") || normalizedPath.endsWith(".pyi")) { + return "python"; + } + if (normalizedPath.endsWith(".go")) { + return "go"; + } + if (normalizedPath.endsWith(".rs")) { + return "rust"; + } + if (normalizedPath.endsWith(".dart")) { + return "dart"; + } + if (normalizedPath.endsWith(".yaml") || normalizedPath.endsWith(".yml")) { + return "yaml"; + } + if (normalizedPath.endsWith(".html") || normalizedPath.endsWith(".htm")) { + return "html"; + } + if (normalizedPath.endsWith(".scss")) { + return "scss"; + } + if (normalizedPath.endsWith(".less")) { + return "less"; + } + if (normalizedPath.endsWith(".css")) { + return "css"; + } + if ( + fileName === "dockerfile" || + fileName === "containerfile" || + normalizedPath.endsWith(".dockerfile") + ) { + return "dockerfile"; + } + if ( + normalizedPath.endsWith(".graphql") || + normalizedPath.endsWith(".gql") || + normalizedPath.endsWith(".graphqls") + ) { + return "graphql"; + } + return null; +} + +function resolveProviderId( + languageId: string, +): LanguageServiceProviderId | null { + switch (languageId) { + case "typescript": + case "typescriptreact": + case "javascript": + case "javascriptreact": + return "typescript"; + case "json": + case "jsonc": + return "json"; + case "yaml": + return "yaml"; + case "html": + return "html"; + case "css": + case "scss": + case "less": + return "css"; + case "toml": + return "toml"; + case "dart": + return "dart"; + case "python": + return "python"; + case "go": + return "go"; + case "rust": + return "rust"; + case "dockerfile": + return "dockerfile"; + case "graphql": + return "graphql"; + default: + return null; + } +} + +export function LanguageServicesProvider() { + const documentsByKey = useEditorDocumentsStore((state) => state.documents); + const enabledProviders = useLanguageServicePreferencesStore( + (state) => state.enabledProviders, + ); + const hasHydratedPreferences = useLanguageServicePreferencesStore( + (state) => state.hasHydrated, + ); + const previousRef = useRef>(new Map()); + const hasAppliedInitialProviderPreferencesRef = useRef(false); + const [isProviderPreferenceSyncReady, setIsProviderPreferenceSyncReady] = + useState(false); + + useEffect(() => { + if ( + !hasHydratedPreferences || + hasAppliedInitialProviderPreferencesRef.current + ) { + return; + } + + hasAppliedInitialProviderPreferencesRef.current = true; + void Promise.allSettled( + Object.entries(enabledProviders).map(([providerId, enabled]) => + electronTrpcClient.languageServices.setProviderEnabled.mutate({ + providerId, + enabled, + }), + ), + ).finally(() => { + setIsProviderPreferenceSyncReady(true); + }); + }, [enabledProviders, hasHydratedPreferences]); + + const trackedDocuments = useMemo(() => { + const next = new Map(); + if (!hasHydratedPreferences || !isProviderPreferenceSyncReady) { + return next; + } + + for (const document of Object.values(documentsByKey)) { + if ( + document.sessionPaneIds.length === 0 || + document.status === "loading" || + !hasInitializedDocumentBuffer(document.documentKey) + ) { + continue; + } + + const languageId = resolveLanguageId(document.filePath); + if (!languageId) { + continue; + } + + const providerId = resolveProviderId(languageId); + if (providerId && enabledProviders[providerId] === false) { + continue; + } + + next.set(document.documentKey, { + documentKey: document.documentKey, + workspaceId: document.workspaceId, + absolutePath: document.filePath, + languageId, + content: getDocumentCurrentContent(document.documentKey), + version: document.contentVersion, + }); + } + + for (const [documentKey, tracked] of next.entries()) { + if (tracked.version === 0 && tracked.content.length === 0) { + next.delete(documentKey); + } + } + + return next; + }, [ + documentsByKey, + enabledProviders, + hasHydratedPreferences, + isProviderPreferenceSyncReady, + ]); + + useEffect(() => { + const previous = previousRef.current; + + for (const [documentKey, tracked] of trackedDocuments.entries()) { + const prev = previous.get(documentKey); + if (!prev) { + void electronTrpcClient.languageServices.openDocument.mutate({ + workspaceId: tracked.workspaceId, + absolutePath: tracked.absolutePath, + languageId: tracked.languageId, + content: tracked.content, + version: tracked.version, + }); + continue; + } + + if ( + prev.version !== tracked.version || + prev.absolutePath !== tracked.absolutePath || + prev.languageId !== tracked.languageId || + prev.workspaceId !== tracked.workspaceId + ) { + void electronTrpcClient.languageServices.changeDocument.mutate({ + workspaceId: tracked.workspaceId, + absolutePath: tracked.absolutePath, + languageId: tracked.languageId, + content: tracked.content, + version: tracked.version, + }); + } + } + + for (const [documentKey, tracked] of previous.entries()) { + if (trackedDocuments.has(documentKey)) { + continue; + } + + void electronTrpcClient.languageServices.closeDocument.mutate({ + workspaceId: tracked.workspaceId, + absolutePath: tracked.absolutePath, + languageId: tracked.languageId, + }); + } + + previousRef.current = trackedDocuments; + }, [trackedDocuments]); + + useEffect(() => { + return () => { + for (const tracked of previousRef.current.values()) { + void electronTrpcClient.languageServices.closeDocument.mutate({ + workspaceId: tracked.workspaceId, + absolutePath: tracked.absolutePath, + languageId: tracked.languageId, + }); + } + }; + }, []); + + return null; +} diff --git a/apps/desktop/src/renderer/providers/LanguageServicesProvider/index.ts b/apps/desktop/src/renderer/providers/LanguageServicesProvider/index.ts new file mode 100644 index 00000000000..94c7bd1ef0a --- /dev/null +++ b/apps/desktop/src/renderer/providers/LanguageServicesProvider/index.ts @@ -0,0 +1 @@ +export { LanguageServicesProvider } from "./LanguageServicesProvider"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/KeepAliveWorkspaces.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/KeepAliveWorkspaces.tsx new file mode 100644 index 00000000000..5877b6f4a97 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/KeepAliveWorkspaces.tsx @@ -0,0 +1,98 @@ +import { Outlet, useMatchRoute } from "@tanstack/react-router"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { WorkspacePage } from "renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page"; + +/** + * Replaces a plain for workspace routes, keeping previously visited + * workspace pages mounted (but hidden) so that Electron elements + * inside BrowserPanes are never removed from the DOM. + * + * For non-workspace routes (settings, welcome, etc.) it renders the normal + * . + * + * Automatically evicts deleted workspaces from the keep-alive list by comparing + * visited IDs against the current workspace list from the database. + */ +export function KeepAliveWorkspaces() { + const matchRoute = useMatchRoute(); + const workspaceMatch = matchRoute({ + to: "/workspace/$workspaceId", + fuzzy: true, + }); + const activeWorkspaceId = + workspaceMatch !== false ? workspaceMatch.workspaceId : null; + + // Track every workspace that has been visited so we can keep them alive. + const [visitedIds, setVisitedIds] = useState([]); + const visitedSetRef = useRef(new Set()); + + useEffect(() => { + if (activeWorkspaceId && !visitedSetRef.current.has(activeWorkspaceId)) { + visitedSetRef.current.add(activeWorkspaceId); + setVisitedIds(Array.from(visitedSetRef.current)); + } + }, [activeWorkspaceId]); + + // Evict deleted workspaces: compare visited IDs against the live list. + const { data: workspaceGroups } = + electronTrpc.workspaces.getAllGrouped.useQuery(); + + const existingWorkspaceIds = useMemo(() => { + if (!workspaceGroups) return null; + const ids = new Set(); + for (const group of workspaceGroups) { + for (const ws of group.workspaces) { + ids.add(ws.id); + } + } + return ids; + }, [workspaceGroups]); + + useEffect(() => { + if (!existingWorkspaceIds) return; + let changed = false; + for (const id of visitedSetRef.current) { + if (!existingWorkspaceIds.has(id)) { + visitedSetRef.current.delete(id); + changed = true; + } + } + if (changed) { + setVisitedIds(Array.from(visitedSetRef.current)); + } + }, [existingWorkspaceIds]); + + // Non-workspace route — fall through to the normal Outlet. + if (!activeWorkspaceId) { + return ; + } + + return ( + <> + {visitedIds.map((id) => { + const isActive = id === activeWorkspaceId; + return ( +
+ +
+ ); + })} + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/ResourceConsumption.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/ResourceConsumption.tsx index 46b7d39e635..0a834419eef 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/ResourceConsumption.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/ResourceConsumption.tsx @@ -179,9 +179,11 @@ export function ResourceConsumption() { > {normalizedSnapshot && ( - - {formatMemory(normalizedSnapshot.totalMemory)} - +
+ {formatCpu(normalizedSnapshot.totalCpu)} + / + {formatMemory(normalizedSnapshot.totalMemory)} +
)} @@ -193,7 +195,10 @@ export function ResourceConsumption() { showArrow={false} className="md:hidden" > - {formatMemory(normalizedSnapshot.totalMemory)} +
+ CPU {formatCpu(normalizedSnapshot.totalCpu)} + Memory {formatMemory(normalizedSnapshot.totalMemory)} +
)} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx index 8b4d8d172c8..899fbf0fb3c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx @@ -1,12 +1,17 @@ import { FEATURE_FLAGS } from "@superset/shared/constants"; import { createFileRoute, - Outlet, useMatchRoute, useNavigate, } from "@tanstack/react-router"; import { useFeatureFlagEnabled } from "posthog-js/react"; import { useState } from "react"; +import { useBrowserNewWindowHandler } from "renderer/hooks/useBrowserNewWindowHandler"; +import { + isTearoffWindow, + useReturnedTabListener, + useTearoffInit, +} from "renderer/hooks/useTearoffInit"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { DashboardSidebar } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar"; import { ResizablePanel } from "renderer/screens/main/components/ResizablePanel"; @@ -20,6 +25,7 @@ import { MAX_WORKSPACE_SIDEBAR_WIDTH, useWorkspaceSidebarStore, } from "renderer/stores/workspace-sidebar-state"; +import { KeepAliveWorkspaces } from "./components/KeepAliveWorkspaces"; import { TopBar } from "./components/TopBar"; export const Route = createFileRoute("/_authenticated/_dashboard")({ @@ -95,6 +101,14 @@ function DashboardLayout() { [openNewWorkspaceModal, currentWorkspace?.projectId], ); + // Global listener for target="_blank" / window.open in any browser pane. + // Must live here (always-mounted) because webviews persist in a hidden + // container even when their BrowserPane component is unmounted. + useBrowserNewWindowHandler(); + useTearoffInit(); + useReturnedTabListener(); + const isTearoff = isTearoffWindow(); + const [deleteTarget, setDeleteTarget] = useState<{ workspaceId: string; workspaceName: string; @@ -118,9 +132,9 @@ function DashboardLayout() { return (
- + {!isTearoff && }
- {isWorkspaceSidebarOpen && ( + {!isTearoff && isWorkspaceSidebarOpen && ( )}
- +
{deleteTarget && ( + ); + } if (isImageFile(filePath) && document.state.kind === "bytes") { return ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx index d210db7fa6b..6dd40f10c56 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx @@ -97,6 +97,7 @@ export function usePaneRegistry( return (