diff --git a/README.md b/README.md index 2d41607c3f5..6299e931af2 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,75 @@ 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 | + +## 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/package.json b/apps/desktop/package.json index fe7a91707ff..a6c534b241d 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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", @@ -154,12 +155,14 @@ "clsx": "^2.1.1", "culori": "^4.0.2", "date-fns": "^4.1.0", + "diff": "^7.0.0", "default-shell": "^2.2.0", "detect-libc": "2.0.4", "dnd-core": "^16.0.1", "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", @@ -185,6 +188,7 @@ "os-locale": "^6.0.2", "pidtree": "^0.6.0", "pidusage": "^4.0.1", + "jszip": "^3.10.1", "posthog-js": "1.310.1", "posthog-node": "^5.24.7", "prebuild-install": "^7.1.1", @@ -227,6 +231,7 @@ "@tanstack/router-cli": "^1.149.0", "@tanstack/router-plugin": "^1.149.0", "@types/better-sqlite3": "^7.6.13", + "@types/diff": "^6.0.0", "@types/bun": "^1.2.17", "@types/culori": "^4.0.1", "@types/http-proxy": "^1.17.17", 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/file-contents.ts b/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts index f625d716294..50e24545bdb 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts @@ -1,3 +1,5 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; import type { FileContents } from "shared/changes-types"; import { detectLanguage } from "shared/detect-language"; import type { SimpleGit } from "simple-git"; @@ -5,8 +7,11 @@ import { z } from "zod"; import { publicProcedure, router } from "../.."; import { toRegisteredWorktreeRelativePath } from "../workspace-fs-service"; import { getSimpleGitWithShellPath } from "../workspaces/utils/git-client"; +import { getProcessEnvWithShellPath } from "../workspaces/utils/shell-env"; const MAX_FILE_SIZE = 2 * 1024 * 1024; +const MAX_BINARY_FILE_SIZE = 10 * 1024 * 1024; +const execFileAsync = promisify(execFile); export const createFileContentsRouter = () => { return router({ @@ -51,6 +56,50 @@ 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 env = await getProcessEnvWithShellPath(); + const { stdout } = await execFileAsync( + "git", + ["cat-file", "-p", spec], + { + cwd: input.worktreePath, + encoding: "buffer", + maxBuffer: MAX_BINARY_FILE_SIZE, + env, + }, + ); + return { content: (stdout as unknown as Buffer).toString("base64") }; + } catch { + return { content: null }; + } + }), + getGitOriginalContent: publicProcedure .input( z.object({ 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..27d5b924511 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,10 @@ +import { + generateTitleFromMessage, + generateTitleFromMessageWithStreamingModel, +} from "@superset/chat/server/desktop"; import { TRPCError } from "@trpc/server"; +import { callSmallModel } from "lib/ai/call-small-model"; +import type { RemoteWithRefs, SimpleGit } from "simple-git"; import { z } from "zod"; import { publicProcedure, router } from "../.."; import { getCurrentBranch } from "../workspaces/utils/git"; @@ -337,5 +343,233 @@ export const createGitOperationsRouter = () => { } }, ), + + generateCommitMessage: publicProcedure + .input(z.object({ worktreePath: z.string() })) + .mutation(async ({ input }): Promise<{ message: string | null }> => { + assertRegisteredWorktree(input.worktreePath); + + const git = await getGitWithShellPath(input.worktreePath); + + // --------------------------------------------------------------------------- + // Hierarchical summarization (gptcommit-style): + // Phase 1 — Summarize each changed file independently (parallel) + // Phase 2 — Combine all summaries into a single commit message + // This avoids token-limit issues with large diffs and produces the + // most accurate results because no file content is truncated. + // --------------------------------------------------------------------------- + + // Collect per-file diffs from staged, unstaged, and untracked sources + const [stagedStat, unstagedStat, statusSummary] = await Promise.all([ + git.diff(["--cached", "--stat", "--stat-width=200"]), + git.diff(["--stat", "--stat-width=200"]), + git.status(), + ]); + + interface FileChange { + path: string; + source: "staged" | "unstaged" | "untracked"; + diff: string | null; // null for untracked / binary + } + + const files: FileChange[] = []; + + // Staged files + const stagedFiles = statusSummary.staged; + if (stagedFiles.length > 0) { + const diffs = await Promise.all( + stagedFiles.map((f) => + git + .diff(["--cached", "--", f]) + .then((d) => d.trim() || null) + .catch(() => null), + ), + ); + for (let i = 0; i < stagedFiles.length; i++) { + files.push({ + path: stagedFiles[i], + source: "staged", + diff: diffs[i], + }); + } + } + + // Unstaged files (modified tracked files) + const unstagedFiles = statusSummary.modified.filter( + (f) => !stagedFiles.includes(f), + ); + if (unstagedFiles.length > 0) { + const diffs = await Promise.all( + unstagedFiles.map((f) => + git + .diff(["--", f]) + .then((d) => d.trim() || null) + .catch(() => null), + ), + ); + for (let i = 0; i < unstagedFiles.length; i++) { + files.push({ + path: unstagedFiles[i], + source: "unstaged", + diff: diffs[i], + }); + } + } + + // Untracked files (new, not yet added) + for (const f of statusSummary.not_added) { + files.push({ path: f, source: "untracked", diff: null }); + } + + if (files.length === 0) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "No changes to generate a commit message for.", + }); + } + + // Skip patterns — files that waste tokens without useful context + const SKIP_PATTERNS = [ + /\.lock$/, + /package-lock\.json$/, + /bun\.lock(b)?$/, + /yarn\.lock$/, + /pnpm-lock\.yaml$/, + /\.min\.(js|css)$/, + ]; + const isBinary = (path: string) => + /\.(png|jpe?g|gif|ico|svg|webp|woff2?|ttf|eot|mp[34]|mov|zip|tar|gz|pdf)$/i.test( + path, + ); + + const summarizableFiles: FileChange[] = []; + const skippedFileNames: string[] = []; + + for (const f of files) { + if ( + SKIP_PATTERNS.some((p) => p.test(f.path)) || + isBinary(f.path) + ) { + skippedFileNames.push(f.path); + } else { + summarizableFiles.push(f); + } + } + + // ---- Phase 1: Summarize each file in parallel ------------------------- + + const PHASE1_INSTRUCTIONS = + "与えられたdiffを1行の日本語で要約してください。何が変わったかを簡潔に。要約のみを返してください。"; + const PER_FILE_MAX_CHARS = 4000; + + const summarizeFile = async ( + f: FileChange, + ): Promise => { + // Files without diff (untracked) — just report the file name + if (!f.diff) { + return `${f.path}: 新規ファイル`; + } + + // Small diffs — no need to call LLM, include directly + if (f.diff.length < 300) { + return `${f.path}: ${f.diff}`; + } + + const truncatedDiff = + f.diff.length > PER_FILE_MAX_CHARS + ? `${f.diff.slice(0, PER_FILE_MAX_CHARS)}\n... (truncated)` + : f.diff; + + const { result } = await callSmallModel({ + invoke: async ({ + model, + credentials, + providerId, + providerName, + }) => { + if ( + providerId === "openai" && + credentials.kind === "oauth" + ) { + return generateTitleFromMessageWithStreamingModel( + { + message: `File: ${f.path}\n\n${truncatedDiff}`, + model: model as never, + instructions: PHASE1_INSTRUCTIONS, + }, + ); + } + return generateTitleFromMessage({ + message: `File: ${f.path}\n\n${truncatedDiff}`, + agentModel: model, + agentId: `commit-file-summary-${providerId}`, + agentName: "File Summarizer", + instructions: PHASE1_INSTRUCTIONS, + tracingContext: { + surface: "commit-file-summary", + provider: providerName, + }, + }); + }, + }); + + return `${f.path}: ${result ?? "変更あり"}`; + }; + + const fileSummaries = await Promise.all( + summarizableFiles.map(summarizeFile), + ); + + // ---- Phase 2: Generate final commit message from summaries ------------ + + let phase2Input = "変更されたファイルの要約:\n"; + phase2Input += fileSummaries.join("\n"); + if (skippedFileNames.length > 0) { + phase2Input += `\n\nその他の変更ファイル(依存関係・バイナリ):\n${skippedFileNames.join("\n")}`; + } + phase2Input += `\n\n変更の統計:\n${stagedStat || unstagedStat || "(統計なし)"}`; + + const PHASE2_PROMPT = `以下のファイル変更要約に基づいて、簡潔なconventional commitメッセージを日本語で生成してください。\nフォーマット: type(scope): 日本語の説明\ntypeは feat, fix, refactor, chore, docs, test, style, perf のいずれか。\n72文字以内。コミットメッセージのみを返してください。\n\n${phase2Input}`; + const PHASE2_INSTRUCTIONS = + "日本語で簡潔なconventional commitメッセージを生成してください。コミットメッセージの行のみを返してください。"; + + const { result, attempts } = await callSmallModel({ + invoke: async ({ + model, + credentials, + providerId, + providerName, + }) => { + if (providerId === "openai" && credentials.kind === "oauth") { + return generateTitleFromMessageWithStreamingModel({ + message: PHASE2_PROMPT, + model: model as never, + instructions: PHASE2_INSTRUCTIONS, + }); + } + + return generateTitleFromMessage({ + message: PHASE2_PROMPT, + agentModel: model, + agentId: `commit-message-${providerId}`, + agentName: "Commit Message Generator", + instructions: PHASE2_INSTRUCTIONS, + tracingContext: { + surface: "commit-message-generation", + provider: providerName, + }, + }); + }, + }); + + if (!result) { + console.warn( + "[generateCommitMessage] All providers failed:", + JSON.stringify(attempts, null, 2), + ); + } + + return { message: result }; + }), }); }; 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..5f0087a6fe5 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/extensions/index.ts @@ -0,0 +1,73 @@ +import type { BrowserWindow } from "electron"; +import { z } from "zod"; +import { publicProcedure, router } from "../.."; +import { + getExtensionsWithToolbarInfo, + installExtension, + listExtensions, + toggleExtension, + uninstallExtension, +} from "main/lib/extensions/extension-manager"; +import { extensionPopupManager } from "main/lib/extensions/extension-popup-manager"; + +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/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts index 0aa455d02c4..20e78344bc9 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"; @@ -26,9 +27,14 @@ import { createSettingsRouter } from "./settings"; import { createTerminalRouter } from "./terminal"; import { createUiStateRouter } from "./ui-state"; import { createWindowRouter } from "./window"; +import { createTabTearoffRouter } from "./tab-tearoff"; +import { createExtensionsRouter } from "./extensions"; import { createWorkspacesRouter } from "./workspaces"; -export const createAppRouter = (getWindow: () => BrowserWindow | null) => { +export const createAppRouter = ( + getWindow: () => BrowserWindow | null, + wm: WindowManager, +) => { return router({ chatRuntimeService: createChatRuntimeServiceRouter(), chatService: createChatServiceRouter(), @@ -57,6 +63,8 @@ export const createAppRouter = (getWindow: () => BrowserWindow | null) => { uiState: createUiStateRouter(), ringtone: createRingtoneRouter(getWindow), hostServiceManager: createHostServiceManagerRouter(), + tabTearoff: createTabTearoffRouter(wm), + extensions: createExtensionsRouter(getWindow), }); }; diff --git a/apps/desktop/src/lib/trpc/routers/ports/ports.ts b/apps/desktop/src/lib/trpc/routers/ports/ports.ts index d4432be08a9..089a91c9032 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/tab-tearoff.ts b/apps/desktop/src/lib/trpc/routers/tab-tearoff.ts new file mode 100644 index 00000000000..2a3f30aa72f --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/tab-tearoff.ts @@ -0,0 +1,36 @@ +import { z } from "zod"; +import type { WindowManager } from "main/lib/window-manager"; +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..868403c5a5e 100644 --- a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts +++ b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts @@ -419,6 +419,13 @@ 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); + }), + getSession: publicProcedure .input(z.string()) .query(async ({ input: paneId }) => { 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 07843a68cf6..f16c0e7490b 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 @@ -183,13 +183,22 @@ 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); + const freshStatus = await fetchGitHubPRStatus(repoPath); if ( + worktree && freshStatus && hasMeaningfulGitHubStatusChange({ current: worktree.githubStatus, @@ -217,14 +226,20 @@ 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; + const cachedGitHubStatus = worktree?.githubStatus ?? null; return fetchGitHubPRComments({ - worktreePath: worktree.path, + worktreePath: repoPath, pullRequest: resolveCommentsPullRequestTarget({ input, githubStatus: cachedGitHubStatus, 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..4b436d18863 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -28,6 +28,8 @@ 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 { ensureProjectIconsDir, getProjectIconPath } from "./lib/project-icons"; @@ -262,6 +264,15 @@ protocol.registerSchemesAsPrivileged([ supportFetchAPI: true, }, }, + { + scheme: "superset-ext-icon", + privileges: { + standard: true, + secure: true, + bypassCSP: true, + supportFetchAPI: true, + }, + }, ]); const gotTheLock = app.requestSingleInstanceLock(); @@ -328,12 +339,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/shell-wrappers.ts b/apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts index 30a73a4f631..8c838dfddcc 100644 --- a/apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts +++ b/apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts @@ -191,6 +191,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 diff --git a/apps/desktop/src/main/lib/auto-updater.ts b/apps/desktop/src/main/lib/auto-updater.ts index 1d95ca60805..9ebf7137f75 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,124 @@ 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 +253,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 +310,8 @@ export function checkForUpdatesInteractive(): void { }); } +// ── Dev simulation helpers ────────────────────────────────────────────────── + export function simulateUpdateReady(): void { if (env.NODE_ENV !== "development") return; isDismissed = false; @@ -199,13 +334,42 @@ 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..e4a6933c7eb --- /dev/null +++ b/apps/desktop/src/main/lib/extensions/compatibility-checker.ts @@ -0,0 +1,261 @@ +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..4835cf25b7b --- /dev/null +++ b/apps/desktop/src/main/lib/extensions/crx-downloader.ts @@ -0,0 +1,261 @@ +import { createWriteStream, 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 { pipeline } from "node:stream/promises"; +import { Readable } from "node:stream"; +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..020f47d1755 --- /dev/null +++ b/apps/desktop/src/main/lib/extensions/extension-icon-protocol.ts @@ -0,0 +1,89 @@ +import { existsSync } from "node:fs"; +import { pathToFileURL } from "node:url"; +import path from "node:path"; +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..dce40bb7e44 --- /dev/null +++ b/apps/desktop/src/main/lib/extensions/extension-popup-manager.ts @@ -0,0 +1,215 @@ +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/shell-history.ts b/apps/desktop/src/main/lib/shell-history.ts new file mode 100644 index 00000000000..b0d4abaee53 --- /dev/null +++ b/apps/desktop/src/main/lib/shell-history.ts @@ -0,0 +1,105 @@ +import { constants } from "node:fs"; +import { access, readFile } from "node:fs/promises"; +import { homedir } from "node:os"; + +let cachedHistory: string[] | null = null; +let lastReadTime = 0; +const CACHE_TTL_MS = 30_000; + +function parseZshHistory(content: string): string[] { + const entries: string[] = []; + 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[1] : line; + // Skip multi-line continuations + if (command.endsWith("\\")) continue; + const trimmed = command.trim(); + if (trimmed) entries.push(trimmed); + } + return entries; +} + +function parseBashHistory(content: string): string[] { + return content + .split("\n") + .filter((line) => line.trim() && !line.startsWith("#")) + .map((line) => line.trim()); +} + +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 content = await readFile(zshPath, "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: string[] = []; + for (let i = entries.length - 1; i >= 0; i--) { + const cmd = entries[i]; + if (!seen.has(cmd)) { + seen.add(cmd); + result.push(cmd); + } + } + + cachedHistory = result.slice(0, 10_000); + lastReadTime = now; + return cachedHistory; +} + +const PAGE_SIZE = 8; + +export async function getSuggestions( + prefix: string, + offset = 0, +): Promise { + if (!prefix || prefix.length < 2) return []; + + const history = await getHistory(); + const results: string[] = []; + let skipped = 0; + + for (const cmd of history) { + if (cmd.startsWith(prefix) && cmd !== prefix) { + if (skipped < offset) { + skipped++; + continue; + } + results.push(cmd); + if (results.length >= PAGE_SIZE) break; + } + } + + return results; +} 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..5eb908c7f0a --- /dev/null +++ b/apps/desktop/src/main/lib/window-manager/index.ts @@ -0,0 +1,146 @@ +import { join } from "node:path"; +import { 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/windows/main.ts b/apps/desktop/src/main/windows/main.ts index e932fc634e2..328f930197f 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -2,7 +2,7 @@ import { join } from "node:path"; 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"; @@ -15,6 +15,7 @@ import type { AgentLifecycleEvent } from "shared/notification-types"; import { createIPCHandler } from "trpc-electron/main"; import { productName } from "~/package.json"; import { appState } from "../lib/app-state"; +import { windowManager } from "../lib/window-manager"; import { browserManager } from "../lib/browser/browser-manager"; import { createApplicationMenu, registerMenuHotkeyUpdates } from "../lib/menu"; import { playNotificationSound } from "../lib/notification-sound"; @@ -70,12 +71,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. @@ -130,6 +142,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 +153,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( @@ -290,6 +304,14 @@ export async function MainWindow() { window.webContents.on("render-process-gone", (_event, details) => { 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,6 +320,22 @@ 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(); @@ -321,6 +359,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/UpdateToast/UpdateToast.tsx b/apps/desktop/src/renderer/components/UpdateToast/UpdateToast.tsx index 85f54a6d631..8de59278bc3 100644 --- a/apps/desktop/src/renderer/components/UpdateToast/UpdateToast.tsx +++ b/apps/desktop/src/renderer/components/UpdateToast/UpdateToast.tsx @@ -74,11 +74,8 @@ export function UpdateToast({ 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/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..445b05ccd0b --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useTearoffInit/index.ts @@ -0,0 +1,6 @@ +export { + useTearoffInit, + useReturnedTabListener, + getTearoffWindowId, + isTearoffWindow, +} 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..30337aa44c0 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useTearoffInit/useTearoffInit.ts @@ -0,0 +1,81 @@ +import { useEffect, useRef } from "react"; +import { useNavigate } from "@tanstack/react-router"; +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/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/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/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 && ( + ); + } return (
Binary files are not previewed yet diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/hooks/usePresetHotkeys.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/hooks/usePresetHotkeys.ts index 6232b8a8a74..b3689fb1b55 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/hooks/usePresetHotkeys.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/hooks/usePresetHotkeys.ts @@ -15,32 +15,33 @@ export const PRESET_HOTKEY_IDS: HotkeyId[] = [ export function usePresetHotkeys( openTabWithPreset: (presetIndex: number) => void, + options?: { enabled?: boolean }, ) { - useAppHotkey(PRESET_HOTKEY_IDS[0], () => openTabWithPreset(0), undefined, [ + useAppHotkey(PRESET_HOTKEY_IDS[0], () => openTabWithPreset(0), options, [ openTabWithPreset, ]); - useAppHotkey(PRESET_HOTKEY_IDS[1], () => openTabWithPreset(1), undefined, [ + useAppHotkey(PRESET_HOTKEY_IDS[1], () => openTabWithPreset(1), options, [ openTabWithPreset, ]); - useAppHotkey(PRESET_HOTKEY_IDS[2], () => openTabWithPreset(2), undefined, [ + useAppHotkey(PRESET_HOTKEY_IDS[2], () => openTabWithPreset(2), options, [ openTabWithPreset, ]); - useAppHotkey(PRESET_HOTKEY_IDS[3], () => openTabWithPreset(3), undefined, [ + useAppHotkey(PRESET_HOTKEY_IDS[3], () => openTabWithPreset(3), options, [ openTabWithPreset, ]); - useAppHotkey(PRESET_HOTKEY_IDS[4], () => openTabWithPreset(4), undefined, [ + useAppHotkey(PRESET_HOTKEY_IDS[4], () => openTabWithPreset(4), options, [ openTabWithPreset, ]); - useAppHotkey(PRESET_HOTKEY_IDS[5], () => openTabWithPreset(5), undefined, [ + useAppHotkey(PRESET_HOTKEY_IDS[5], () => openTabWithPreset(5), options, [ openTabWithPreset, ]); - useAppHotkey(PRESET_HOTKEY_IDS[6], () => openTabWithPreset(6), undefined, [ + useAppHotkey(PRESET_HOTKEY_IDS[6], () => openTabWithPreset(6), options, [ openTabWithPreset, ]); - useAppHotkey(PRESET_HOTKEY_IDS[7], () => openTabWithPreset(7), undefined, [ + useAppHotkey(PRESET_HOTKEY_IDS[7], () => openTabWithPreset(7), options, [ openTabWithPreset, ]); - useAppHotkey(PRESET_HOTKEY_IDS[8], () => openTabWithPreset(8), undefined, [ + useAppHotkey(PRESET_HOTKEY_IDS[8], () => openTabWithPreset(8), options, [ openTabWithPreset, ]); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx index 877e1056c4e..51c4399bb9c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx @@ -1,5 +1,11 @@ import type { ExternalApp } from "@superset/local-db"; -import { createFileRoute, notFound, useNavigate } from "@tanstack/react-router"; +import { + createFileRoute, + notFound, + useNavigate, + useParams, + useSearch, +} from "@tanstack/react-router"; import { useCallback, useEffect, useMemo } from "react"; import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard"; import { useFileOpenMode } from "renderer/hooks/useFileOpenMode"; @@ -20,6 +26,7 @@ import { UnsavedChangesDialog } from "renderer/screens/main/components/Workspace import { useWorkspaceFileEventBridge } from "renderer/screens/main/components/WorkspaceView/hooks/useWorkspaceFileEvents"; import { useWorkspaceRenameReconciliation } from "renderer/screens/main/components/WorkspaceView/hooks/useWorkspaceRenameReconciliation"; import { WorkspaceInitializingView } from "renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView"; +import { WorkspaceIdProvider } from "renderer/screens/main/components/WorkspaceView/WorkspaceIdContext"; import { WorkspaceLayout } from "renderer/screens/main/components/WorkspaceView/WorkspaceLayout"; import { useCreateOrOpenPR, usePRStatus } from "renderer/screens/main/hooks"; import { @@ -82,8 +89,14 @@ export const Route = createFileRoute( }, }); -function WorkspacePage() { - const { workspaceId } = Route.useParams(); +export function WorkspacePage({ + workspaceIdOverride, + isActive = true, +}: { workspaceIdOverride?: string; isActive?: boolean } = {}) { + const routeParams = useParams({ strict: false }) as { + workspaceId?: string; + }; + const workspaceId = workspaceIdOverride ?? routeParams.workspaceId ?? ""; const { data: workspace } = electronTrpc.workspaces.get.useQuery({ id: workspaceId, }); @@ -98,8 +111,9 @@ function WorkspacePage() { enabled: Boolean(workspace?.worktreePath), }); const navigate = useNavigate(); - const routeNavigate = Route.useNavigate(); - const { tabId: searchTabId, paneId: searchPaneId } = Route.useSearch(); + const searchParams = useSearch({ strict: false }) as Partial; + const searchTabId = searchParams?.tabId; + const searchPaneId = searchParams?.paneId; // Keep the file open mode cache warm for addFileViewerPane useFileOpenMode(); @@ -120,8 +134,13 @@ function WorkspacePage() { state.setFocusedPane(searchTabId, searchPaneId); } - routeNavigate({ search: {}, replace: true }); - }, [searchTabId, searchPaneId, workspaceId, routeNavigate]); + navigate({ + to: "/workspace/$workspaceId", + params: { workspaceId }, + search: {}, + replace: true, + }); + }, [searchTabId, searchPaneId, workspaceId, navigate]); // Check if workspace is initializing or failed const isInitializing = useIsWorkspaceInitializing(workspaceId); @@ -209,11 +228,12 @@ function WorkspacePage() { [presets, workspaceId, addTab, openPreset], ); - useAppHotkey("NEW_GROUP", () => addTab(workspaceId), undefined, [ + const hotkeyOptions = { enabled: isActive }; + useAppHotkey("NEW_GROUP", () => addTab(workspaceId), hotkeyOptions, [ workspaceId, addTab, ]); - useAppHotkey("NEW_CHAT", () => addChatTab(workspaceId), undefined, [ + useAppHotkey("NEW_CHAT", () => addChatTab(workspaceId), hotkeyOptions, [ workspaceId, addChatTab, ]); @@ -224,16 +244,16 @@ function WorkspacePage() { addChatTab(workspaceId); } }, - undefined, + hotkeyOptions, [workspaceId, reopenClosedTab, addChatTab], ); - useAppHotkey("NEW_BROWSER", () => addBrowserTab(workspaceId), undefined, [ + useAppHotkey("NEW_BROWSER", () => addBrowserTab(workspaceId), hotkeyOptions, [ workspaceId, addBrowserTab, ]); - usePresetHotkeys(openTabWithPreset); + usePresetHotkeys(openTabWithPreset, hotkeyOptions); - useAppHotkey("RUN_WORKSPACE_COMMAND", () => toggleWorkspaceRun(), undefined, [ + useAppHotkey("RUN_WORKSPACE_COMMAND", () => toggleWorkspaceRun(), hotkeyOptions, [ toggleWorkspaceRun, ]); @@ -244,7 +264,7 @@ function WorkspacePage() { requestPaneClose(focusedPaneId); } }, - undefined, + hotkeyOptions, [focusedPaneId], ); useAppHotkey( @@ -254,7 +274,7 @@ function WorkspacePage() { requestTabClose(activeTabId); } }, - undefined, + hotkeyOptions, [activeTabId], ); @@ -266,7 +286,7 @@ function WorkspacePage() { const prevIndex = index <= 0 ? tabs.length - 1 : index - 1; setActiveTab(workspaceId, tabs[prevIndex].id); }, - undefined, + hotkeyOptions, [workspaceId, activeTabId, tabs, setActiveTab], ); @@ -279,7 +299,7 @@ function WorkspacePage() { index >= tabs.length - 1 || index === -1 ? 0 : index + 1; setActiveTab(workspaceId, tabs[nextIndex].id); }, - undefined, + hotkeyOptions, [workspaceId, activeTabId, tabs, setActiveTab], ); @@ -291,7 +311,7 @@ function WorkspacePage() { const prevIndex = index <= 0 ? tabs.length - 1 : index - 1; setActiveTab(workspaceId, tabs[prevIndex].id); }, - undefined, + hotkeyOptions, [workspaceId, activeTabId, tabs, setActiveTab], ); @@ -304,7 +324,7 @@ function WorkspacePage() { index >= tabs.length - 1 || index === -1 ? 0 : index + 1; setActiveTab(workspaceId, tabs[nextIndex].id); }, - undefined, + hotkeyOptions, [workspaceId, activeTabId, tabs, setActiveTab], ); @@ -318,15 +338,15 @@ function WorkspacePage() { [tabs, workspaceId, setActiveTab], ); - useAppHotkey("JUMP_TO_TAB_1", () => switchToTab(0), undefined, [switchToTab]); - useAppHotkey("JUMP_TO_TAB_2", () => switchToTab(1), undefined, [switchToTab]); - useAppHotkey("JUMP_TO_TAB_3", () => switchToTab(2), undefined, [switchToTab]); - useAppHotkey("JUMP_TO_TAB_4", () => switchToTab(3), undefined, [switchToTab]); - useAppHotkey("JUMP_TO_TAB_5", () => switchToTab(4), undefined, [switchToTab]); - useAppHotkey("JUMP_TO_TAB_6", () => switchToTab(5), undefined, [switchToTab]); - useAppHotkey("JUMP_TO_TAB_7", () => switchToTab(6), undefined, [switchToTab]); - useAppHotkey("JUMP_TO_TAB_8", () => switchToTab(7), undefined, [switchToTab]); - useAppHotkey("JUMP_TO_TAB_9", () => switchToTab(8), undefined, [switchToTab]); + useAppHotkey("JUMP_TO_TAB_1", () => switchToTab(0), hotkeyOptions, [switchToTab]); + useAppHotkey("JUMP_TO_TAB_2", () => switchToTab(1), hotkeyOptions, [switchToTab]); + useAppHotkey("JUMP_TO_TAB_3", () => switchToTab(2), hotkeyOptions, [switchToTab]); + useAppHotkey("JUMP_TO_TAB_4", () => switchToTab(3), hotkeyOptions, [switchToTab]); + useAppHotkey("JUMP_TO_TAB_5", () => switchToTab(4), hotkeyOptions, [switchToTab]); + useAppHotkey("JUMP_TO_TAB_6", () => switchToTab(5), hotkeyOptions, [switchToTab]); + useAppHotkey("JUMP_TO_TAB_7", () => switchToTab(6), hotkeyOptions, [switchToTab]); + useAppHotkey("JUMP_TO_TAB_8", () => switchToTab(7), hotkeyOptions, [switchToTab]); + useAppHotkey("JUMP_TO_TAB_9", () => switchToTab(8), hotkeyOptions, [switchToTab]); useAppHotkey( "PREV_PANE", @@ -337,7 +357,7 @@ function WorkspacePage() { setFocusedPane(activeTabId, prevPaneId); } }, - undefined, + hotkeyOptions, [activeTabId, activeTab?.layout, focusedPaneId, setFocusedPane], ); @@ -350,7 +370,7 @@ function WorkspacePage() { setFocusedPane(activeTabId, nextPaneId); } }, - undefined, + hotkeyOptions, [activeTabId, activeTab?.layout, focusedPaneId, setFocusedPane], ); @@ -379,7 +399,7 @@ function WorkspacePage() { }); } }, [workspace?.worktreePath, resolvedDefaultApp, mutateOpenInApp, projectId]); - useAppHotkey("OPEN_IN_APP", handleOpenInApp, undefined, [handleOpenInApp]); + useAppHotkey("OPEN_IN_APP", handleOpenInApp, hotkeyOptions, [handleOpenInApp]); // Copy path shortcut const { copyToClipboard } = useCopyToClipboard(); @@ -390,7 +410,7 @@ function WorkspacePage() { copyToClipboard(workspace.worktreePath); } }, - undefined, + hotkeyOptions, [workspace?.worktreePath], ); @@ -408,7 +428,7 @@ function WorkspacePage() { createOrOpenPR(); } }, - undefined, + hotkeyOptions, [pr?.url, createOrOpenPR], ); @@ -418,11 +438,18 @@ function WorkspacePage() { }); const handleQuickOpen = useCallback(() => { commandPalette.toggle(); - }, [commandPalette.toggle]); + }, [commandPalette.toggle, keywordSearch.handleOpenChange]); + const handleKeywordSearch = useCallback(() => { + commandPalette.handleOpenChange(false); + keywordSearch.toggle(); + }, [commandPalette.handleOpenChange, keywordSearch.toggle]); useAppHotkey("QUICK_OPEN", handleQuickOpen, undefined, [handleQuickOpen]); + useAppHotkey("KEYWORD_SEARCH", handleKeywordSearch, hotkeyOptions, [ + handleKeywordSearch, + ]); // Toggle changes sidebar (⌘L) - useAppHotkey("TOGGLE_SIDEBAR", () => toggleSidebar(), undefined, [ + useAppHotkey("TOGGLE_SIDEBAR", () => toggleSidebar(), hotkeyOptions, [ toggleSidebar, ]); @@ -438,7 +465,7 @@ function WorkspacePage() { setSidebarMode(isExpanded ? SidebarMode.Tabs : SidebarMode.Changes); } }, - undefined, + hotkeyOptions, [isSidebarOpen, setSidebarOpen, setSidebarMode, currentSidebarMode], ); @@ -473,7 +500,7 @@ function WorkspacePage() { } } }, - undefined, + hotkeyOptions, [activeTabId, focusedPaneId, activeTab, splitPaneAuto, resolveSplitTarget], ); @@ -490,7 +517,7 @@ function WorkspacePage() { splitPaneVertical(activeTabId, target.paneId, target.path); } }, - undefined, + hotkeyOptions, [ activeTabId, focusedPaneId, @@ -513,7 +540,7 @@ function WorkspacePage() { splitPaneHorizontal(activeTabId, target.paneId, target.path); } }, - undefined, + hotkeyOptions, [ activeTabId, focusedPaneId, @@ -538,7 +565,7 @@ function WorkspacePage() { }); } }, - undefined, + hotkeyOptions, [ activeTabId, focusedPaneId, @@ -563,7 +590,7 @@ function WorkspacePage() { }); } }, - undefined, + hotkeyOptions, [ activeTabId, focusedPaneId, @@ -581,7 +608,7 @@ function WorkspacePage() { equalizePaneSplits(activeTabId); } }, - undefined, + hotkeyOptions, [activeTabId, equalizePaneSplits], ); @@ -599,7 +626,7 @@ function WorkspacePage() { navigateToWorkspace(prevWorkspaceId, navigate); } }, - undefined, + hotkeyOptions, [getPreviousWorkspace.data, navigate], ); @@ -616,11 +643,12 @@ function WorkspacePage() { navigateToWorkspace(nextWorkspaceId, navigate); } }, - undefined, + hotkeyOptions, [getNextWorkspace.data, navigate], ); return ( +
{showInitView ? ( @@ -631,6 +659,7 @@ function WorkspacePage() { /> ) : (
+ ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/GeneralSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/GeneralSettings.tsx index f464e5c705a..ec0513b8635 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/GeneralSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/GeneralSettings.tsx @@ -11,6 +11,7 @@ import { HiOutlinePuzzlePiece, HiOutlineShieldCheck, HiOutlineSparkles, + HiOutlineSquare3Stack3D, HiOutlineUser, } from "react-icons/hi2"; import { LuBrain, LuGitBranch, LuKeyboard } from "react-icons/lu"; @@ -33,6 +34,7 @@ type SettingsRoute = | "/settings/terminal" | "/settings/models" | "/settings/integrations" + | "/settings/extensions" | "/settings/billing" | "/settings/api-keys" | "/settings/permissions"; @@ -113,6 +115,12 @@ const SECTION_GROUPS: SectionGroup[] = [ label: "Models", icon: , }, + { + id: "/settings/extensions", + section: "extensions", + label: "Extensions", + icon: , + }, ], }, { diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/extensions/components/ExtensionsSettings/ExtensionsSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/extensions/components/ExtensionsSettings/ExtensionsSettings.tsx new file mode 100644 index 00000000000..741d45b9419 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/extensions/components/ExtensionsSettings/ExtensionsSettings.tsx @@ -0,0 +1,267 @@ +import { Badge } from "@superset/ui/badge"; +import { Button } from "@superset/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, +} from "@superset/ui/card"; +import { Input } from "@superset/ui/input"; +import { useCallback, useState } from "react"; +import { + HiOutlineGlobeAlt, + HiOutlinePuzzlePiece, + HiOutlineTrash, +} from "react-icons/hi2"; +import { LuLoaderCircle } from "react-icons/lu"; +import { electronTrpc } from "renderer/lib/electron-trpc"; + +export function ExtensionsSettings() { + const [installInput, setInstallInput] = useState(""); + const [isInstalling, setIsInstalling] = useState(false); + const [error, setError] = useState(null); + + const utils = electronTrpc.useUtils(); + const { data: extensions, isLoading } = + electronTrpc.extensions.list.useQuery(); + + const invalidateExtensionQueries = useCallback(() => { + utils.extensions.list.invalidate(); + utils.extensions.listToolbarExtensions.invalidate(); + }, [utils]); + + const installMutation = electronTrpc.extensions.install.useMutation({ + onSuccess: () => { + setInstallInput(""); + setError(null); + invalidateExtensionQueries(); + }, + onError: (err) => { + setError(err.message); + }, + onSettled: () => { + setIsInstalling(false); + }, + }); + + const uninstallMutation = electronTrpc.extensions.uninstall.useMutation({ + onSuccess: () => { + invalidateExtensionQueries(); + }, + }); + + const toggleMutation = electronTrpc.extensions.toggle.useMutation({ + onSuccess: () => { + invalidateExtensionQueries(); + }, + }); + + const handleInstall = useCallback(() => { + if (!installInput.trim()) return; + setIsInstalling(true); + setError(null); + installMutation.mutate({ input: installInput.trim() }); + }, [installInput, installMutation]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + handleInstall(); + } + }, + [handleInstall], + ); + + return ( +
+
+

Browser Extensions

+

+ Install Chrome extensions from the Chrome Web Store +

+
+ + {/* Install form */} + + +
+
+ +
+
+ Install from Chrome Web Store + + Paste a Chrome Web Store URL or extension ID + +
+
+
+ +
+ { + setInstallInput(e.target.value); + setError(null); + }} + onKeyDown={handleKeyDown} + placeholder="https://chromewebstore.google.com/detail/... or extension ID" + className="flex-1" + disabled={isInstalling} + /> + +
+ {error && ( +

{error}

+ )} +
+
+ + {/* Installed extensions list */} + {isLoading ? ( +
+ + Loading extensions... +
+ ) : extensions && extensions.length > 0 ? ( +
+ {extensions.map((ext) => ( + + +
+
+
+ +
+
+
+ + {ext.name} + + + v{ext.version} + + +
+ {ext.description && ( + + {ext.description} + + )} +
+
+
+ + +
+
+
+ {ext.compatibility.issues.length > 0 && ( + +
+ + {ext.compatibility.issues.length} compatibility{" "} + {ext.compatibility.issues.length === 1 + ? "issue" + : "issues"} + +
    + {ext.compatibility.issues.map((issue, i) => ( +
  • + + {issue.severity === "error" ? "x" : "!"} + + {issue.message} +
  • + ))} +
+
+
+ )} +
+ ))} +
+ ) : ( +
+ +

No extensions installed

+

+ Install extensions from the Chrome Web Store using the form + above. Not all extensions are compatible with Electron. +

+
+ )} +
+ ); +} + +function CompatibilityBadge({ + level, +}: { level: "full" | "partial" | "low" }) { + switch (level) { + case "full": + return ( + + Compatible + + ); + case "partial": + return ( + + Partial + + ); + case "low": + return ( + + Low Compat + + ); + } +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/extensions/components/ExtensionsSettings/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/extensions/components/ExtensionsSettings/index.ts new file mode 100644 index 00000000000..ad94fa55215 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/extensions/components/ExtensionsSettings/index.ts @@ -0,0 +1 @@ +export { ExtensionsSettings } from "./ExtensionsSettings"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/extensions/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/extensions/page.tsx new file mode 100644 index 00000000000..a89f64c8080 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/extensions/page.tsx @@ -0,0 +1,12 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { ExtensionsSettings } from "./components/ExtensionsSettings"; + +export const Route = createFileRoute( + "/_authenticated/settings/extensions/", +)({ + component: ExtensionsSettingsPage, +}); + +function ExtensionsSettingsPage() { + return ; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx index 02a30dcdf74..e80313f9a5c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx @@ -31,6 +31,7 @@ const SECTION_ORDER: SettingsSection[] = [ "git", "terminal", "models", + "extensions", "organization", "integrations", "billing", @@ -49,6 +50,7 @@ function getSectionFromPath(pathname: string): SettingsSection | null { if (pathname.includes("/settings/terminal")) return "terminal"; if (pathname.includes("/settings/models")) return "models"; if (pathname.includes("/settings/integrations")) return "integrations"; + if (pathname.includes("/settings/extensions")) return "extensions"; if (pathname.includes("/settings/permissions")) return "permissions"; if (pathname.includes("/settings/project")) return "project"; return null; @@ -76,6 +78,8 @@ function getPathFromSection(section: SettingsSection): string { return "/settings/models"; case "integrations": return "/settings/integrations"; + case "extensions": + return "/settings/extensions"; case "permissions": return "/settings/permissions"; default: diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/TerminalSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/TerminalSettings.tsx index 09581f20570..b508c951292 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/TerminalSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/TerminalSettings.tsx @@ -7,6 +7,7 @@ import { import { LinkBehaviorSetting } from "./components/LinkBehaviorSetting"; import { PresetsSection } from "./components/PresetsSection"; import { SessionsSection } from "./components/SessionsSection"; +import { SuggestionsSetting } from "./components/SuggestionsSetting"; interface TerminalSettingsProps { visibleItems?: SettingItemId[] | null; @@ -52,6 +53,10 @@ export function TerminalSettings({ SETTING_ITEM_ID.TERMINAL_QUICK_ADD, visibleItems, ); + const showSuggestions = isItemVisible( + SETTING_ITEM_ID.TERMINAL_SUGGESTIONS, + visibleItems, + ); const showLinkBehavior = isItemVisible( SETTING_ITEM_ID.TERMINAL_LINK_BEHAVIOR, visibleItems, @@ -82,6 +87,7 @@ export function TerminalSettings({ onPendingCreateProjectIdChange={onPendingCreateProjectIdChange} /> )} + {showSuggestions && } {showLinkBehavior && } {showSessions && } diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/SuggestionsSetting/SuggestionsSetting.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/SuggestionsSetting/SuggestionsSetting.tsx new file mode 100644 index 00000000000..9b7adb1c832 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/SuggestionsSetting/SuggestionsSetting.tsx @@ -0,0 +1,29 @@ +import { Label } from "@superset/ui/label"; +import { Switch } from "@superset/ui/switch"; +import { useTerminalSuggestionsStore } from "renderer/stores/terminal-suggestions"; + +export function SuggestionsSetting() { + const enabled = useTerminalSuggestionsStore((s) => s.enabled); + const setEnabled = useTerminalSuggestionsStore((s) => s.setEnabled); + + return ( +
+
+ +

+ Show command suggestions from shell history while typing +

+
+ +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/SuggestionsSetting/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/SuggestionsSetting/index.ts new file mode 100644 index 00000000000..aa7b88efba8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/SuggestionsSetting/index.ts @@ -0,0 +1 @@ +export { SuggestionsSetting } from "./SuggestionsSetting"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts index d7e56ed6276..6caf2a2b962 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts @@ -38,6 +38,7 @@ export const SETTING_ITEM_ID = { TERMINAL_PRESETS: "terminal-presets", TERMINAL_QUICK_ADD: "terminal-quick-add", TERMINAL_SESSIONS: "terminal-sessions", + TERMINAL_SUGGESTIONS: "terminal-suggestions", TERMINAL_LINK_BEHAVIOR: "terminal-link-behavior", MODELS_ANTHROPIC: "models-anthropic", @@ -62,6 +63,8 @@ export const SETTING_ITEM_ID = { API_KEYS_LIST: "api-keys-list", API_KEYS_GENERATE: "api-keys-generate", + EXTENSIONS_BROWSER: "extensions-browser", + PERMISSIONS_FULL_DISK_ACCESS: "permissions-full-disk-access", PERMISSIONS_ACCESSIBILITY: "permissions-accessibility", PERMISSIONS_MICROPHONE: "permissions-microphone", @@ -597,6 +600,22 @@ export const SETTINGS_ITEMS: SettingsItem[] = [ "pty", ], }, + { + id: SETTING_ITEM_ID.TERMINAL_SUGGESTIONS, + section: "terminal", + title: "Shell History Suggestions", + description: "Show command suggestions from shell history", + keywords: [ + "terminal", + "suggest", + "suggestion", + "autocomplete", + "history", + "shell", + "command", + "dropdown", + ], + }, { id: SETTING_ITEM_ID.TERMINAL_LINK_BEHAVIOR, section: "terminal", @@ -896,6 +915,22 @@ export const SETTINGS_ITEMS: SettingsItem[] = [ "claude code", ], }, + { + id: SETTING_ITEM_ID.EXTENSIONS_BROWSER, + section: "extensions", + title: "Browser Extensions", + description: "Install and manage Chrome extensions from the Chrome Web Store", + keywords: [ + "extensions", + "chrome", + "browser", + "web store", + "addon", + "plugin", + "install", + "crx", + ], + }, { id: SETTING_ITEM_ID.PERMISSIONS_FULL_DISK_ACCESS, section: "permissions", diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/PortsList.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/PortsList.tsx index 12813863547..956490ba274 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/PortsList.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/PortsList.tsx @@ -1,7 +1,14 @@ import { COMPANY } from "@superset/shared/constants"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import { LuChevronRight, LuCircleHelp, LuRadioTower } from "react-icons/lu"; +import { useCallback, useRef } from "react"; +import { + LuChevronRight, + LuCircleHelp, + LuFilter, + LuRadioTower, +} from "react-icons/lu"; import { usePortsStore } from "renderer/stores"; +import { MIN_LIST_HEIGHT, MAX_LIST_HEIGHT } from "renderer/stores/ports/store"; import { STROKE_WIDTH } from "../constants"; import { WorkspacePortGroup } from "./components/WorkspacePortGroup"; import { usePortsData } from "./hooks/usePortsData"; @@ -11,10 +18,43 @@ const PORTS_DOCS_URL = `${COMPANY.DOCS_URL}/ports`; export function PortsList() { const isCollapsed = usePortsStore((s) => s.isListCollapsed); const toggleCollapsed = usePortsStore((s) => s.toggleListCollapsed); + const listHeight = usePortsStore((s) => s.listHeight); + const setListHeight = usePortsStore((s) => s.setListHeight); + const showConfiguredOnly = usePortsStore((s) => s.showConfiguredOnly); + const setShowConfiguredOnly = usePortsStore((s) => s.setShowConfiguredOnly); const { workspacePortGroups, totalPortCount } = usePortsData(); - if (totalPortCount === 0) { + // --- Drag-to-resize handle --- + const isDragging = useRef(false); + const startY = useRef(0); + const startHeight = useRef(0); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + isDragging.current = true; + startY.current = e.clientY; + startHeight.current = listHeight; + + const handleMouseMove = (ev: MouseEvent) => { + if (!isDragging.current) return; + // Dragging UP increases height (handle is at top of list) + const delta = startY.current - ev.clientY; + setListHeight(startHeight.current + delta); + }; + const handleMouseUp = () => { + isDragging.current = false; + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + }, + [listHeight, setListHeight], + ); + + if (totalPortCount === 0 && !showConfiguredOnly) { return null; } @@ -24,7 +64,16 @@ export function PortsList() { }; return ( -
+
+ {/* Resize handle */} + {!isCollapsed && ( +
+
+
+ )}
+ + +

+ {showConfiguredOnly + ? "Show all ports" + : "Show only ports.json ports"} +

+
+ + -

Learn about static port configuration

+

+ Learn about static port configuration +

- {totalPortCount} + + {totalPortCount} +
{!isCollapsed && ( -
- {workspacePortGroups.map((group) => ( - - ))} +
+ {workspacePortGroups.length > 0 ? ( + workspacePortGroups.map((group) => ( + + )) + ) : showConfiguredOnly ? ( +

+ No ports defined in ports.json +

+ ) : null}
)}
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx index 600023673bd..0c787b53ab8 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx @@ -22,10 +22,14 @@ export function MergedPortBadge({ port }: MergedPortBadgeProps) { const openUrl = electronTrpc.external.openUrl.useMutation(); const { killPort } = useKillPort(); + const isDetected = port.detected; + const displayContent = port.label ? ( <> {port.label}{" "} - + {port.port} @@ -65,7 +69,13 @@ export function MergedPortBadge({ port }: MergedPortBadgeProps) { return ( -
+
- - + {isDetected && ( + <> + + + + )}
@@ -100,12 +117,17 @@ export function MergedPortBadge({ port }: MergedPortBadgeProps) { > localhost:{port.port}
- {(port.processName || port.pid != null) && ( + {isDetected && (port.processName || port.pid != null) && (
{port.processName} {port.pid != null && ` (pid ${port.pid})`}
)} + {!isDetected && ( +
+ Not detected +
+ )} {canJumpToTerminal && (
Click to open workspace diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/WorkspacePortGroup/WorkspacePortGroup.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/WorkspacePortGroup/WorkspacePortGroup.tsx index 4c9e5e3fdb9..198cc5720ea 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/WorkspacePortGroup/WorkspacePortGroup.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/WorkspacePortGroup/WorkspacePortGroup.tsx @@ -19,8 +19,10 @@ export function WorkspacePortGroup({ group }: WorkspacePortGroupProps) { navigateToWorkspace(group.workspaceId, navigate); }; + const detectedPorts = group.ports.filter((p) => p.detected); + const handleCloseAll = () => { - killPorts(group.ports); + killPorts(detectedPorts); }; return ( diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/useKillPort.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/useKillPort.ts index 8d3b75cd259..ac3bc3fda91 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/useKillPort.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/useKillPort.ts @@ -6,6 +6,7 @@ export function useKillPort() { const killMutation = electronTrpc.ports.kill.useMutation(); const killPort = async (port: EnrichedPort) => { + if (!port.paneId) return; const result = await killMutation.mutateAsync({ paneId: port.paneId, port: port.port, @@ -18,12 +19,13 @@ export function useKillPort() { }; const killPorts = async (ports: EnrichedPort[]) => { - if (ports.length === 0) return; + const killable = ports.filter((p) => p.paneId != null); + if (killable.length === 0) return; const results = await Promise.all( - ports.map((port) => + killable.map((port) => killMutation.mutateAsync({ - paneId: port.paneId, + paneId: port.paneId as string, port: port.port, }), ), diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/usePortsData.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/usePortsData.ts index 4e3e926b9fb..bfd75f24542 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/usePortsData.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/usePortsData.ts @@ -1,5 +1,6 @@ import { useMemo } from "react"; import { electronTrpc } from "renderer/lib/electron-trpc"; +import { usePortsStore } from "renderer/stores"; import type { EnrichedPort } from "shared/types"; const PORTS_FALLBACK_REFETCH_INTERVAL_MS = 10_000; @@ -10,8 +11,44 @@ export interface WorkspacePortGroup { ports: EnrichedPort[]; } +/** + * Build a display-friendly name for each workspace. + * Uses the worktree directory basename to distinguish workspaces + * that share the same user-facing name (e.g. multiple "default" worktrees). + */ +function buildWorkspaceDisplayNames( + groups: { + workspaces: { id: string; name: string; worktreePath: string }[]; + sections: { + workspaces: { id: string; name: string; worktreePath: string }[]; + }[]; + }[], +): Record { + const names: Record = {}; + + for (const group of groups) { + const allWs = [ + ...group.workspaces, + ...group.sections.flatMap((s) => s.workspaces), + ]; + for (const ws of allWs) { + if (ws.worktreePath) { + const basename = ws.worktreePath.split("/").pop() || ws.name; + names[ws.id] = + basename !== ws.name ? `${basename} (${ws.name})` : ws.name; + } else { + names[ws.id] = ws.name; + } + } + } + + return names; +} + export function usePortsData() { - const { data: allWorkspaces } = electronTrpc.workspaces.getAll.useQuery(); + // getAllGrouped is already cached by the sidebar, so this is zero-cost. + const { data: allWorkspaceGroups } = + electronTrpc.workspaces.getAllGrouped.useQuery(); const utils = electronTrpc.useUtils(); @@ -29,18 +66,18 @@ export function usePortsData() { }, }); - const ports = detectedPorts ?? []; + const showConfiguredOnly = usePortsStore((s) => s.showConfiguredOnly); + + const ports = useMemo(() => { + const all = detectedPorts ?? []; + if (!showConfiguredOnly) return all; + return all.filter((p) => p.label != null); + }, [detectedPorts, showConfiguredOnly]); const workspaceNames = useMemo(() => { - if (!allWorkspaces) return {}; - return allWorkspaces.reduce( - (acc, ws) => { - acc[ws.id] = ws.name; - return acc; - }, - {} as Record, - ); - }, [allWorkspaces]); + if (!allWorkspaceGroups) return {}; + return buildWorkspaceDisplayNames(allWorkspaceGroups); + }, [allWorkspaceGroups]); const workspacePortGroups = useMemo(() => { const groupMap = new Map(); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/ChangesContent.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/ChangesContent.tsx index 4bdae7e68c7..94257f6ac6f 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/ChangesContent.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/ChangesContent.tsx @@ -1,5 +1,5 @@ -import { useParams } from "@tanstack/react-router"; import { electronTrpc } from "renderer/lib/electron-trpc"; +import { useWorkspaceId } from "renderer/screens/main/components/WorkspaceView/WorkspaceIdContext"; import { useGitChangesStatus } from "renderer/screens/main/hooks/useGitChangesStatus"; import { RightSidebarTab, @@ -8,7 +8,7 @@ import { import { InfiniteScrollView } from "./components/InfiniteScrollView"; export function ChangesContent() { - const { workspaceId } = useParams({ strict: false }); + const workspaceId = useWorkspaceId(); const isChangesSidebarVisible = useSidebarStore( (s) => s.isSidebarOpen && s.rightSidebarTab === RightSidebarTab.Changes, ); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/FileDiffSection.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/FileDiffSection.tsx index e6b3e785714..10e0e076684 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/FileDiffSection.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/FileDiffSection.tsx @@ -1,10 +1,10 @@ import { Alert, AlertDescription, AlertTitle } from "@superset/ui/alert"; import { Button } from "@superset/ui/button"; import { Collapsible, CollapsibleContent } from "@superset/ui/collapsible"; -import { useParams } from "@tanstack/react-router"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { LuFileCode, LuLoader } from "react-icons/lu"; import { electronTrpc } from "renderer/lib/electron-trpc"; +import { useWorkspaceId } from "renderer/screens/main/components/WorkspaceView/WorkspaceIdContext"; import { CodeEditor } from "renderer/screens/main/components/WorkspaceView/components/CodeEditor"; import { FileSaveConflictDialog } from "renderer/screens/main/components/WorkspaceView/components/FileSaveConflictDialog"; import { useChangesStore } from "renderer/stores/changes"; @@ -79,7 +79,7 @@ export function FileDiffSection({ onDiscard, isActioning = false, }: FileDiffSectionProps) { - const { workspaceId } = useParams({ strict: false }); + const workspaceId = useWorkspaceId(); const sectionRef = useRef(null); const copyTimeoutRef = useRef | null>(null); const { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ContentHeader/ContentHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ContentHeader/ContentHeader.tsx index cbc1deac5cb..aca45938c78 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ContentHeader/ContentHeader.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ContentHeader/ContentHeader.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import { isTearoffWindow } from "renderer/hooks/useTearoffInit"; interface ContentHeaderProps { /** Optional leading action */ @@ -14,8 +15,13 @@ export function ContentHeader({ children, trailingAction, }: ContentHeaderProps) { + const isTearoff = isTearoffWindow(); + return ( -
+
{leadingAction && (
{leadingAction}
)} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/EmptyTabView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/EmptyTabView.tsx index ffd4a619a7d..619910a45e1 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/EmptyTabView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/EmptyTabView.tsx @@ -1,5 +1,4 @@ import type { ExternalApp } from "@superset/local-db"; -import { useParams } from "@tanstack/react-router"; import { useCallback, useMemo } from "react"; import type { IconType } from "react-icons"; import { BsTerminalPlus } from "react-icons/bs"; @@ -8,6 +7,7 @@ import { TbMessageCirclePlus, TbWorld } from "react-icons/tb"; import { getAppOption } from "renderer/components/OpenInExternalDropdown"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { useWorkspaceDeleteHandler } from "renderer/react-query/workspaces"; +import { useWorkspaceId } from "renderer/screens/main/components/WorkspaceView/WorkspaceIdContext"; import { DeleteWorkspaceDialog } from "renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/DeleteWorkspaceDialog/DeleteWorkspaceDialog"; import { useHotkeyDisplay } from "renderer/stores/hotkeys"; import { useTabsStore } from "renderer/stores/tabs/store"; @@ -35,9 +35,7 @@ export function EmptyTabView({ onOpenInApp, onOpenQuickOpen, }: EmptyTabViewProps) { - const { workspaceId } = useParams({ - from: "/_authenticated/_dashboard/workspace/$workspaceId/", - }); + const workspaceId = useWorkspaceId(); const addChatTab = useTabsStore((s) => s.addChatTab); const addBrowserTab = useTabsStore((s) => s.addBrowserTab); const activeTheme = useTheme(); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupItem.tsx index 3296797fc8f..cccc1fe5d62 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupItem.tsx @@ -4,6 +4,9 @@ import { ContextMenuContent, ContextMenuItem, ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, ContextMenuTrigger, } from "@superset/ui/context-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; @@ -12,10 +15,12 @@ import { useEffect, useState } from "react"; import { useDrag, useDrop } from "react-dnd"; import { getEmptyImage } from "react-dnd-html5-backend"; import { HiMiniXMark } from "react-icons/hi2"; -import { LuEyeOff, LuPencil } from "react-icons/lu"; +import { LuEyeOff, LuPalette, LuPencil } from "react-icons/lu"; import type { MosaicBranch } from "react-mosaic-component"; import { MosaicDragType } from "react-mosaic-component"; +import { ColorSelector } from "renderer/components/ColorSelector/ColorSelector"; import { StatusIndicator } from "renderer/screens/main/components/StatusIndicator"; +import { PROJECT_COLOR_DEFAULT } from "shared/constants/project-colors"; import { RenameInput } from "renderer/screens/main/components/WorkspaceSidebar/RenameInput"; import { useDragPaneStore } from "renderer/stores/drag-pane-store"; import { useTabsStore } from "renderer/stores/tabs/store"; @@ -48,6 +53,7 @@ interface GroupItemProps { onSelect: () => void; onClose: () => void; onRename: (newName: string) => void; + onSetColor: (color: string | null) => void; onMarkAsUnread: () => void; onPaneDrop?: (paneId: string) => void; onReorder?: (fromIndex: number, toIndex: number) => void; @@ -61,6 +67,7 @@ export function GroupItem({ onSelect, onClose, onRename, + onSetColor, onMarkAsUnread, onPaneDrop, onReorder, @@ -88,7 +95,9 @@ export function GroupItem({ return { type: MosaicDragType.WINDOW, item: { - mosaicId: canDropOntoActiveTab ? MOSAIC_ID : TAB_DRAG_NO_MATCH_ID, + mosaicId: canDropOntoActiveTab + ? `${MOSAIC_ID}-${activeTabId}` + : TAB_DRAG_NO_MATCH_ID, hideTimer: 0, tabId: tab.id, index, @@ -185,11 +194,15 @@ export function GroupItem({ [onPaneDrop, onReorder, tab.id, index], ); + const hasTabColor = tab.color && tab.color !== PROJECT_COLOR_DEFAULT; + const tabStyles = cn( "flex items-center gap-2 transition-all w-full shrink-0 pl-3 pr-8 h-full", - isActive - ? "text-foreground bg-border/30" - : "text-muted-foreground/70 hover:text-muted-foreground hover:bg-tertiary/20", + hasTabColor + ? "text-foreground" + : isActive + ? "text-foreground bg-border/30" + : "text-muted-foreground/70 hover:text-muted-foreground hover:bg-tertiary/20", ); const startEditing = () => { @@ -217,7 +230,14 @@ export function GroupItem({ isOver && canDrop && "bg-primary/5", isDragging && "opacity-50 text-muted-foreground/50", )} - style={{ cursor: isDragging ? "grabbing" : undefined }} + style={{ + cursor: isDragging ? "grabbing" : undefined, + ...(hasTabColor + ? { + backgroundColor: `${tab.color}${isActive ? "30" : "18"}`, + } + : {}), + }} > {isEditing ? (
@@ -282,6 +302,21 @@ export function GroupItem({ Rename + + + + Set Color + + + + onSetColor(color === PROJECT_COLOR_DEFAULT ? null : color) + } + /> + + diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx index 9f6a84e3195..283b9eb8f70 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx @@ -1,7 +1,7 @@ import type { TerminalPreset } from "@superset/local-db"; import { eq, or } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; -import { useNavigate, useParams } from "@tanstack/react-router"; +import { useNavigate } from "@tanstack/react-router"; import { useCallback, useEffect, @@ -10,8 +10,10 @@ import { useRef, useState, } from "react"; +import { isTearoffWindow } from "renderer/hooks/useTearoffInit"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { usePresets } from "renderer/react-query/presets"; +import { useWorkspaceId } from "renderer/screens/main/components/WorkspaceView/WorkspaceIdContext"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { requestTabClose } from "renderer/stores/editor-state/editorCoordinator"; import { useTabsStore } from "renderer/stores/tabs/store"; @@ -31,7 +33,7 @@ import { GroupItem } from "./GroupItem"; const NO_WORKSPACE_MATCH = "__no_workspace__"; export function GroupStrip() { - const { workspaceId: activeWorkspaceId } = useParams({ strict: false }); + const activeWorkspaceId = useWorkspaceId(); const allTabs = useTabsStore((s) => s.tabs); const panes = useTabsStore((s) => s.panes); @@ -40,6 +42,7 @@ export function GroupStrip() { const addChatTab = useTabsStore((s) => s.addChatTab); const addBrowserTab = useTabsStore((s) => s.addBrowserTab); const renameTab = useTabsStore((s) => s.renameTab); + const setTabColor = useTabsStore((s) => s.setTabColor); const setActiveTab = useTabsStore((s) => s.setActiveTab); const movePaneToTab = useTabsStore((s) => s.movePaneToTab); const movePaneToNewTab = useTabsStore((s) => s.movePaneToNewTab); @@ -360,6 +363,7 @@ export function GroupStrip() { onSelect={() => handleSelectGroup(tab.id)} onClose={() => handleCloseGroup(tab.id)} onRename={(newName) => handleRenameGroup(tab.id, newName)} + onSetColor={(color) => setTabColor(tab.id, color)} onMarkAsUnread={() => handleMarkTabAsUnread(tab.id)} onPaneDrop={(paneId) => movePaneToTab(paneId, tab.id)} onReorder={handleReorderTabs} @@ -379,6 +383,12 @@ export function GroupStrip() {
{plusControl}
)}
+ {isTearoffWindow() && ( +
+ )}
{hasHorizontalOverflow && (
{plusControl}
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/PersistentTabRenderer.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/PersistentTabRenderer.tsx new file mode 100644 index 00000000000..5fbe6c2b4d3 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/PersistentTabRenderer.tsx @@ -0,0 +1,71 @@ +import { useMemo } from "react"; +import { useTabsStore } from "renderer/stores/tabs/store"; +import type { Tab } from "renderer/stores/tabs/types"; +import { extractPaneIdsFromLayout } from "renderer/stores/tabs/utils"; +import { TabView } from "./TabView"; + +interface PersistentTabRendererProps { + tabs: Tab[]; + activeTabId: string | null; +} + +/** + * Renders workspace tabs, keeping only those that contain a browser (webview) + * pane mounted when inactive. Tabs without webviews are unmounted normally. + * + * Electron's tag reloads its content whenever it is reparented in the + * DOM. By keeping webview-containing tabs mounted (but off-screen), webview + * elements stay in their original DOM parent and never reparent, eliminating + * the reload. Non-webview tabs (terminals, chat, files) can safely unmount and + * remount without data loss. + */ +export function PersistentTabRenderer({ + tabs, + activeTabId, +}: PersistentTabRendererProps) { + const panes = useTabsStore((s) => s.panes); + + const tabsWithWebview = useMemo(() => { + const ids = new Set(); + for (const tab of tabs) { + const paneIds = extractPaneIdsFromLayout(tab.layout); + if (paneIds.some((id) => panes[id]?.type === "webview")) { + ids.add(tab.id); + } + } + return ids; + }, [tabs, panes]); + + return ( + <> + {tabs.map((tab) => { + const isActive = tab.id === activeTabId; + const hasWebview = tabsWithWebview.has(tab.id); + + // Tabs without webviews: only render when active (original behavior) + if (!hasWebview && !isActive) return null; + + return ( +
+ +
+ ); + })} + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/BrowserPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/BrowserPane.tsx index 0225544e8bb..aaa6311976f 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/BrowserPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/BrowserPane.tsx @@ -1,6 +1,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { GlobeIcon } from "lucide-react"; -import { useCallback } from "react"; +import { useCallback, useState } from "react"; +import { LuMinus, LuPlus } from "react-icons/lu"; import { TbDeviceDesktop } from "react-icons/tb"; import type { MosaicBranch } from "react-mosaic-component"; import { electronTrpc } from "renderer/lib/electron-trpc"; @@ -9,6 +10,7 @@ import { BasePaneWindow, PaneToolbarActions } from "../components"; import { BrowserErrorOverlay } from "./components/BrowserErrorOverlay"; import { BrowserToolbar } from "./components/BrowserToolbar"; import { BrowserOverflowMenu } from "./components/BrowserToolbar/components/BrowserOverflowMenu"; +import { ExtensionToolbar } from "./components/ExtensionToolbar"; import { DEFAULT_BROWSER_URL } from "./constants"; import { usePersistentWebview } from "./hooks/usePersistentWebview"; @@ -24,6 +26,7 @@ interface BrowserPaneProps { ) => void; removePane: (paneId: string) => void; setFocusedPane: (tabId: string, paneId: string) => void; + onPopOut?: () => void; } export function BrowserPane({ @@ -33,6 +36,7 @@ export function BrowserPane({ splitPaneAuto, removePane, setFocusedPane, + onPopOut, }: BrowserPaneProps) { const pane = useTabsStore((s) => s.panes[paneId]); const browserState = pane?.browser; @@ -44,6 +48,8 @@ export function BrowserPane({ const isBlankPage = currentUrl === "about:blank"; const { mutate: openDevTools } = electronTrpc.browser.openDevTools.useMutation(); + const { mutate: setZoomLevel } = + electronTrpc.browser.setZoomLevel.useMutation(); const { containerRef, @@ -58,6 +64,34 @@ export function BrowserPane({ initialUrl: currentUrl, }); + // -- Zoom (synced with Electron's built-in Cmd+/- zoom) ----------------- + + const ZOOM_STEP = 1; + const ZOOM_MIN = -3; + const ZOOM_MAX = 5; + + const [zoomLevel, setZoomLevelLocal] = useState(0); + const zoomPercent = Math.round(1.2 ** zoomLevel * 100); + + // Sync when Cmd+/- changes zoom from keyboard + electronTrpc.browser.onZoomChanged.useSubscription( + { paneId }, + { + onData: ({ zoomLevel: level }) => { + setZoomLevelLocal(level); + }, + }, + ); + + const applyZoom = useCallback( + (level: number) => { + const clamped = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, level)); + setZoomLevelLocal(clamped); + setZoomLevel({ paneId, level: clamped }); + }, + [paneId, setZoomLevel], + ); + const handleOpenDevTools = useCallback(() => { openDevTools({ paneId }); }, [openDevTools, paneId]); @@ -70,6 +104,7 @@ export function BrowserPane({ splitPaneAuto={splitPaneAuto} removePane={removePane} setFocusedPane={setFocusedPane} + onPopOut={onPopOut} renderToolbar={(handlers) => (
+
+ + + + + + Zoom Out + + + + + + + + Reset Zoom + + + + + + + + Zoom In + + +
+ + {!isBlank && ( + + + + + + Copy URL + + )} - +
)} {isEditing && autocomplete.isOpen && ( + {extensions.map((ext) => ( + + ))} +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/ExtensionToolbar/components/ExtensionIcon/ExtensionIcon.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/ExtensionToolbar/components/ExtensionIcon/ExtensionIcon.tsx new file mode 100644 index 00000000000..0638edae991 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/ExtensionToolbar/components/ExtensionIcon/ExtensionIcon.tsx @@ -0,0 +1,67 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { useCallback, useRef, useState } from "react"; +import { HiOutlinePuzzlePiece } from "react-icons/hi2"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import type { ExtensionToolbarInfo } from "./types"; + +interface ExtensionIconProps { + extension: ExtensionToolbarInfo; +} + +export function ExtensionIcon({ extension }: ExtensionIconProps) { + const buttonRef = useRef(null); + const [imgError, setImgError] = useState(false); + + const openPopupMutation = electronTrpc.extensions.openPopup.useMutation(); + + const handleClick = useCallback(() => { + const el = buttonRef.current; + if (!el || !extension.popupPath) return; + + // Get the bounding rect relative to the BrowserWindow content area + const rect = el.getBoundingClientRect(); + + openPopupMutation.mutate({ + // Use the Electron-assigned ID for the chrome-extension:// URL + extensionId: extension.electronId, + popupPath: extension.popupPath, + anchorRect: { + x: Math.round(rect.left), + y: Math.round(rect.top), + width: Math.round(rect.width), + height: Math.round(rect.height), + }, + }); + }, [extension, openPopupMutation]); + + // Use the Chrome Web Store ID for the icon protocol (directory name) + const iconUrl = `superset-ext-icon://${extension.id}/32`; + const title = extension.actionTitle ?? extension.name; + + return ( + + + + + + {title} + + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/ExtensionToolbar/components/ExtensionIcon/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/ExtensionToolbar/components/ExtensionIcon/index.ts new file mode 100644 index 00000000000..563a67cfa28 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/ExtensionToolbar/components/ExtensionIcon/index.ts @@ -0,0 +1,2 @@ +export { ExtensionIcon } from "./ExtensionIcon"; +export type { ExtensionToolbarInfo } from "./types"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/ExtensionToolbar/components/ExtensionIcon/types.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/ExtensionToolbar/components/ExtensionIcon/types.ts new file mode 100644 index 00000000000..b29d74440dc --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/ExtensionToolbar/components/ExtensionIcon/types.ts @@ -0,0 +1,10 @@ +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; +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/ExtensionToolbar/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/ExtensionToolbar/index.ts new file mode 100644 index 00000000000..fb54de523d8 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/ExtensionToolbar/index.ts @@ -0,0 +1 @@ +export { ExtensionToolbar } from "./ExtensionToolbar"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/hooks/usePersistentWebview/usePersistentWebview.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/hooks/usePersistentWebview/usePersistentWebview.ts index cd9363802dc..6167f244620 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/hooks/usePersistentWebview/usePersistentWebview.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/hooks/usePersistentWebview/usePersistentWebview.ts @@ -1,12 +1,27 @@ import { useCallback, useEffect, useRef } from "react"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { useTabsStore } from "renderer/stores/tabs/store"; +import { PLATFORM } from "shared/constants"; // --------------------------------------------------------------------------- // Module-level singletons // --------------------------------------------------------------------------- const webviewRegistry = new Map(); +/** + * A persistent wrapper div per pane that ALWAYS contains its webview. + * + * Electron's tag reloads its content whenever the element is + * reparented (moved from one parent to another). The previous approach moved + * the webview itself between a visible container and a hidden one — each move + * was a reparent that triggered a reload. + * + * By wrapping the webview in a persistent div and only ever moving that + * wrapper, the webview's parentNode never changes, so Electron never sees a + * reparent. The wrapper moves between React's container div (visible) and a + * hidden parking container, but the webview inside is untouched. + */ +const wrapperRegistry = new Map(); /** Tracks paneId → last-registered webContentsId so we can re-register if it changes. */ const registeredWebContentsIds = new Map(); let hiddenContainer: HTMLDivElement | null = null; @@ -58,6 +73,11 @@ window.addEventListener("drop", () => setWebviewsDragPassthrough(false), true); /** Call from useBrowserLifecycle when a pane is removed. */ export function destroyPersistentWebview(paneId: string): void { + const wrapper = wrapperRegistry.get(paneId); + if (wrapper) { + wrapper.remove(); + wrapperRegistry.delete(paneId); + } const webview = webviewRegistry.get(paneId); if (webview) { webview.remove(); @@ -113,21 +133,9 @@ export function usePersistentWebview({ const { mutate: upsertHistory } = electronTrpc.browserHistory.upsert.useMutation(); - // Subscribe to new-window events (target="_blank" links, window.open) - // handled via setWindowOpenHandler in the main process - electronTrpc.browser.onNewWindow.useSubscription( - { paneId }, - { - onData: ({ url }: { url: string }) => { - 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.openInBrowserPane(tab.workspaceId, url); - }, - }, - ); + // New-window events (target="_blank", window.open) are handled globally + // by useBrowserNewWindowHandler in the dashboard layout, so webviews that + // are parked in the hidden container still get their events handled. // Subscribe to context menu actions (e.g. "Open Link as New Split") electronTrpc.browser.onContextMenuAction.useSubscription( @@ -171,19 +179,27 @@ export function usePersistentWebview({ [paneId], ); - // Main lifecycle effect: create or reclaim webview, attach events, park on unmount + // Main lifecycle effect: create or reclaim wrapper+webview, attach events, park on unmount useEffect(() => { const container = containerRef.current; if (!container) return; + let wrapper = wrapperRegistry.get(paneId); let webview = webviewRegistry.get(paneId); - if (webview) { - // Reclaim from hidden container - container.appendChild(webview); + if (wrapper && webview) { + // Reclaim: move the wrapper (with webview inside) into React's container. + // The webview's parentNode stays as `wrapper` — no reparent, no reload. + container.appendChild(wrapper); syncStoreFromWebview(webview); } else { - // Create new webview + // First time: create a persistent wrapper div and a webview inside it. + wrapper = document.createElement("div"); + wrapper.style.display = "flex"; + wrapper.style.flex = "1"; + wrapper.style.width = "100%"; + wrapper.style.height = "100%"; + webview = document.createElement("webview") as Electron.WebviewTag; webview.setAttribute("partition", "persist:superset"); webview.setAttribute("allowpopups", ""); @@ -193,8 +209,11 @@ export function usePersistentWebview({ webview.style.height = "100%"; webview.style.border = "none"; + // webview goes into wrapper, wrapper goes into container + wrapper.appendChild(webview); + wrapperRegistry.set(paneId, wrapper); webviewRegistry.set(paneId, webview); - container.appendChild(webview); + container.appendChild(wrapper); const finalUrl = sanitizeUrl(initialUrlRef.current); webview.src = finalUrl; @@ -207,11 +226,48 @@ export function usePersistentWebview({ const handleDomReady = () => { const webContentsId = wv.getWebContentsId(); const previousId = registeredWebContentsIds.get(paneId); - // Register on first load, or re-register if webContentsId changed (e.g. after DOM reparenting) + // Register on first load, or re-register if webContentsId changed if (previousId !== webContentsId) { registeredWebContentsIds.set(paneId, webContentsId); registerBrowser({ paneId, webContentsId }); } + + // Inject mouse back/forward button support into the guest page. + // Electron's consumes mouse events in the guest process, + // so the host renderer never sees button 3/4 (back/forward). + // Only needed on macOS — Windows/Linux use the `app-command` event + // handler in the main process instead. + if (PLATFORM.IS_MAC) { + wv.executeJavaScript(` + if (!window.__supersetMouseNavInstalled) { + window.__supersetMouseNavInstalled = true; + window.addEventListener('mouseup', function(e) { + if (e.button === 3) { e.preventDefault(); history.back(); } + if (e.button === 4) { e.preventDefault(); history.forward(); } + }, true); + } + `).catch(() => {}); + } + + // Cmd/Ctrl+click on links opens in a new browser tab. + // Chromium may not always trigger setWindowOpenHandler for modifier + // clicks, so we intercept them in the guest page and call window.open + // which is reliably caught by the handler. + wv.executeJavaScript(` + if (!window.__supersetCmdClickInstalled) { + window.__supersetCmdClickInstalled = true; + document.addEventListener('click', function(e) { + if (!(e.metaKey || e.ctrlKey) || e.button !== 0) return; + var el = e.target; + while (el && el.tagName !== 'A') el = el.parentElement; + if (el && el.href && !el.href.startsWith('javascript:')) { + e.preventDefault(); + e.stopPropagation(); + window.open(el.href, '_blank'); + } + }, true); + } + `).catch(() => {}); }; const handleDidStartLoading = () => { @@ -340,7 +396,7 @@ export function usePersistentWebview({ ); wv.addEventListener("did-fail-load", handleDidFailLoad as EventListener); - // -- Cleanup: park in hidden container ----------------------------- + // -- Cleanup: park the wrapper (not the webview) in hidden container - return () => { wv.removeEventListener("dom-ready", handleDomReady); @@ -367,7 +423,13 @@ export function usePersistentWebview({ handleDidFailLoad as EventListener, ); - getHiddenContainer().appendChild(wv); + // Park the WRAPPER (which contains the webview) in the hidden + // container. The webview's parentNode remains `wrapper` throughout + // — no reparent, no reload. + const w = wrapperRegistry.get(paneId); + if (w) { + getHiddenContainer().appendChild(w); + } }; // paneId is stable for the lifetime of a pane; initialUrlRef only used on first create. }, [paneId, registerBrowser, syncStoreFromWebview, upsertHistory]); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPane.tsx index 80f0f07bb50..701f3338fe3 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPane.tsx @@ -50,6 +50,7 @@ interface ChatPaneProps { availableTabs: Tab[]; onMoveToTab: (targetTabId: string) => void; onMoveToNewTab: () => void; + onPopOut?: () => void; } export function ChatPane({ @@ -65,6 +66,7 @@ export function ChatPane({ availableTabs, onMoveToTab, onMoveToNewTab, + onPopOut, }: ChatPaneProps) { const showDevToolbarActions = env.NODE_ENV === "development"; const isFocused = useTabsStore((s) => s.focusedPaneIds[tabId] === paneId); @@ -148,6 +150,7 @@ export function ChatPane({ splitPaneAuto={splitPaneAuto} removePane={removePane} setFocusedPane={setFocusedPane} + onPopOut={onPopOut} renderToolbar={(handlers) => (
@@ -165,6 +168,7 @@ export function ChatPane({ splitOrientation={handlers.splitOrientation} onSplitPane={handlers.onSplitPane} onClosePane={handlers.onClosePane} + onPopOut={handlers.onPopOut} leadingActions={ showDevToolbarActions ? ( diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/DevToolsPane/DevToolsPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/DevToolsPane/DevToolsPane.tsx index 36d7d4eb015..8058807443e 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/DevToolsPane/DevToolsPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/DevToolsPane/DevToolsPane.tsx @@ -16,6 +16,7 @@ interface DevToolsPaneProps { ) => void; removePane: (paneId: string) => void; setFocusedPane: (tabId: string, paneId: string) => void; + onPopOut?: () => void; } export function DevToolsPane({ @@ -26,6 +27,7 @@ export function DevToolsPane({ splitPaneAuto, removePane, setFocusedPane, + onPopOut, }: DevToolsPaneProps) { const { mutate: openDevTools } = electronTrpc.browser.openDevTools.useMutation(); @@ -42,6 +44,7 @@ export function DevToolsPane({ splitPaneAuto={splitPaneAuto} removePane={removePane} setFocusedPane={setFocusedPane} + onPopOut={onPopOut} renderToolbar={(handlers) => (
@@ -52,6 +55,7 @@ export function DevToolsPane({ onSplitPane={handlers.onSplitPane} onClosePane={handlers.onClosePane} closeHotkeyId="CLOSE_TERMINAL" + onPopOut={handlers.onPopOut} />
)} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx index c22537bfec2..b62a63a9ad6 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx @@ -1,10 +1,10 @@ import { Alert, AlertDescription, AlertTitle } from "@superset/ui/alert"; import { Button } from "@superset/ui/button"; -import { useParams } from "@tanstack/react-router"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { MosaicBranch } from "react-mosaic-component"; import type { MarkdownEditorAdapter } from "renderer/components/MarkdownRenderer"; import { electronTrpc } from "renderer/lib/electron-trpc"; +import { useWorkspaceId } from "renderer/screens/main/components/WorkspaceView/WorkspaceIdContext"; import { FileSaveConflictDialog } from "renderer/screens/main/components/WorkspaceView/components/FileSaveConflictDialog"; import { useWorkspaceFileEvents } from "renderer/screens/main/components/WorkspaceView/hooks/useWorkspaceFileEvents"; import { useChangesStore } from "renderer/stores/changes"; @@ -78,6 +78,7 @@ interface FileViewerPaneProps { availableTabs: Tab[]; onMoveToTab: (targetTabId: string) => void; onMoveToNewTab: () => void; + onPopOut?: () => void; } function getUnsavedDialogCopy(intent: EditorPendingIntent | null) { @@ -126,8 +127,9 @@ export function FileViewerPane({ availableTabs, onMoveToTab, onMoveToNewTab, + onPopOut, }: FileViewerPaneProps) { - const { workspaceId } = useParams({ strict: false }); + const workspaceId = useWorkspaceId(); const normalizedWorkspaceId = workspaceId ?? worktreePath; const fileViewer = useTabsStore((s) => s.panes[paneId]?.fileViewer); const isFocused = useTabsStore((s) => s.focusedPaneIds[tabId] === paneId); @@ -617,6 +619,7 @@ export function FileViewerPane({ splitPaneAuto={splitPaneAuto} removePane={requestPaneClose} setFocusedPane={setFocusedPane} + onPopOut={onPopOut} contentClassName="w-full h-full overflow-hidden bg-background" renderToolbar={(handlers) => (
@@ -637,6 +640,7 @@ export function FileViewerPane({ onSplitPane={handlers.onSplitPane} onPin={handlePin} onClosePane={handlers.onClosePane} + onPopOut={handlers.onPopOut} />
)} @@ -676,6 +680,10 @@ export function FileViewerPane({ )}
(null); + const mergeViewRef = useRef(null); + const langCompartmentA = useRef(new Compartment()).current; + const langCompartmentB = useRef(new Compartment()).current; + const themeCompartmentA = useRef(new Compartment()).current; + const themeCompartmentB = useRef(new Compartment()).current; + const activeTheme = useResolvedTheme(); + const { data: fontSettings } = electronTrpc.settings.getFontSettings.useQuery( + undefined, + { staleTime: 30_000 }, + ); + const editorFontFamily = fontSettings?.editorFontFamily ?? undefined; + const editorFontSize = fontSettings?.editorFontSize ?? undefined; + + // biome-ignore lint/correctness/useExhaustiveDependencies: MergeView is created once and destroyed on unmount + useEffect(() => { + if (!containerRef.current) return; + + const baseExtensions = [ + lineNumbers(), + highlightSpecialChars(), + drawSelection(), + highlightSelectionMatches(), + EditorState.readOnly.of(true), + EditorView.editable.of(false), + EditorView.lineWrapping, + keymap.of([indentWithTab, ...defaultKeymap, ...searchKeymap]), + ]; + + const themeExts = [ + getCodeSyntaxHighlighting(activeTheme), + createCodeMirrorTheme( + activeTheme, + { fontFamily: editorFontFamily, fontSize: editorFontSize }, + true, + ), + ]; + + const mergeView = new MergeView({ + parent: containerRef.current, + collapseUnchanged: { margin: 3, minSize: 4 }, + diffConfig: { scanLimit: 50000, timeout: 5000 }, + a: { + doc: original, + extensions: [ + ...baseExtensions, + themeCompartmentA.of(themeExts), + langCompartmentA.of([]), + ], + }, + b: { + doc: modified, + extensions: [ + ...baseExtensions, + themeCompartmentB.of(themeExts), + langCompartmentB.of([]), + ], + }, + }); + + mergeViewRef.current = mergeView; + + void loadLanguageSupport(language).then((ext) => { + if (!ext || !mergeViewRef.current) return; + const mv = mergeViewRef.current; + mv.a.dispatch({ effects: langCompartmentA.reconfigure(ext) }); + mv.b.dispatch({ effects: langCompartmentB.reconfigure(ext) }); + }); + + return () => { + mergeView.destroy(); + mergeViewRef.current = null; + }; + }, [original, modified, language, viewMode]); + + useEffect(() => { + const mv = mergeViewRef.current; + if (!mv) return; + + const themeExts = [ + getCodeSyntaxHighlighting(activeTheme), + createCodeMirrorTheme( + activeTheme, + { fontFamily: editorFontFamily, fontSize: editorFontSize }, + true, + ), + ]; + + mv.a.dispatch({ effects: themeCompartmentA.reconfigure(themeExts) }); + mv.b.dispatch({ effects: themeCompartmentB.reconfigure(themeExts) }); + }, [ + activeTheme, + editorFontFamily, + editorFontSize, + themeCompartmentA, + themeCompartmentB, + ]); + + return
; +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/CodeMirrorDiffViewer/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/CodeMirrorDiffViewer/index.ts new file mode 100644 index 00000000000..13fcbde4860 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/CodeMirrorDiffViewer/index.ts @@ -0,0 +1 @@ +export { CodeMirrorDiffViewer } from "./CodeMirrorDiffViewer"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerContent/FileViewerContent.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerContent/FileViewerContent.tsx index 92aa62b1f44..61c7e13eedd 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerContent/FileViewerContent.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerContent/FileViewerContent.tsx @@ -15,13 +15,15 @@ import { CodeEditor } from "renderer/screens/main/components/WorkspaceView/compo import type { Tab } from "renderer/stores/tabs/types"; import type { DiffViewMode } from "shared/changes-types"; import { detectLanguage } from "shared/detect-language"; -import { isImageFile } from "shared/file-types"; +import { isImageFile, isSpreadsheetFile } from "shared/file-types"; import type { FileViewerMode } from "shared/tabs-types"; import { useScrollToFirstDiffChange } from "../../hooks/useScrollToFirstDiffChange"; +import { CodeMirrorDiffViewer } from "../CodeMirrorDiffViewer"; import { DiffScrollbarDecorations } from "../DiffScrollbarDecorations"; import { DiffViewerContextMenu } from "../DiffViewerContextMenu"; import { FileEditorContextMenu } from "../FileEditorContextMenu"; import { MarkdownSearch } from "../MarkdownSearch"; +import { SpreadsheetDiffViewer, SpreadsheetViewer } from "../SpreadsheetViewer"; import { type DiffDomLocation, getColumnFromDiffPoint, @@ -101,6 +103,10 @@ interface TextSearchState { } interface FileViewerContentProps { + workspaceId?: string; + worktreePath?: string; + diffCategory?: import("shared/changes-types").ChangeCategory; + commitHash?: string; viewMode: FileViewerMode; filePath: string; isLoadingRaw: boolean; @@ -136,6 +142,10 @@ interface FileViewerContentProps { } export function FileViewerContent({ + workspaceId, + worktreePath, + diffCategory, + commitHash, viewMode, filePath, isLoadingRaw, @@ -276,6 +286,23 @@ export function FileViewerContent({ rawFileData, ]); + if ( + viewMode === "diff" && + isSpreadsheetFile(filePath) && + workspaceId && + worktreePath + ) { + return ( + + ); + } + if (viewMode === "diff") { if (isLoadingDiff) { return ( @@ -293,6 +320,11 @@ export function FileViewerContent({ ); } + const totalLines = + diffData.original.split("\n").length + + diffData.modified.split("\n").length; + const useLargeDiffViewer = totalLines > 2000; + return (
- + {!useLargeDiffViewer && ( + + )}
{ + if (useLargeDiffViewer) return; const location = getDiffLocationFromEvent(event.nativeEvent); if (!location) { return; @@ -360,16 +395,27 @@ export function FileViewerContent({ }; }} > - + {useLargeDiffViewer ? ( + + ) : ( + + )}
- + {!useLargeDiffViewer && ( + + )}
); @@ -422,6 +468,15 @@ export function FileViewerContent({ ); } + if ( + rawFileData?.ok === false && + rawFileData.reason === "binary" && + isSpreadsheetFile(filePath) && + workspaceId + ) { + return ; + } + if (!rawFileData?.ok) { const errorMessage = rawFileData?.reason === "too-large" diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerToolbar/FileViewerToolbar.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerToolbar/FileViewerToolbar.tsx index 628a2e92334..ba67dc9fb63 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerToolbar/FileViewerToolbar.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerToolbar/FileViewerToolbar.tsx @@ -35,6 +35,7 @@ interface FileViewerToolbarProps { /** Pin this pane (convert from preview to permanent) */ onPin: () => void; onClosePane: (e: React.MouseEvent) => void; + onPopOut?: (e: React.MouseEvent) => void; } export function FileViewerToolbar({ @@ -54,6 +55,7 @@ export function FileViewerToolbar({ onSplitPane, onPin, onClosePane, + onPopOut, }: FileViewerToolbarProps) { const { copyToClipboard, copied } = useCopyToClipboard(1500); @@ -168,6 +170,7 @@ export function FileViewerToolbar({ splitOrientation={splitOrientation} onSplitPane={onSplitPane} onClosePane={onClosePane} + onPopOut={onPopOut} leadingActions={ !isPinned ? ( diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/SpreadsheetViewer/SpreadsheetDiffViewer.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/SpreadsheetViewer/SpreadsheetDiffViewer.tsx new file mode 100644 index 00000000000..c05e165b185 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/SpreadsheetViewer/SpreadsheetDiffViewer.tsx @@ -0,0 +1,541 @@ +import { + type RefObject, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { createPortal } from "react-dom"; +import type { ChangeCategory } from "shared/changes-types"; +import useResizeObserver from "use-resize-observer"; +import type { ParsedCell, RichTextPart } from "./parseWorkbook"; +import { + type DiffParsedCell, + type DiffParsedRow, + type DiffSegment, + useSpreadsheetDiff, +} from "./useSpreadsheetDiff"; + +interface SpreadsheetDiffViewerProps { + workspaceId: string; + worktreePath: string; + filePath: string; + diffCategory?: ChangeCategory; + commitHash?: string; +} + +const ROW_NUM_COL_WIDTH = 30; + +const DIFF_BG = { + added: "rgba(34, 197, 94, 0.25)", + removed: "rgba(239, 68, 68, 0.25)", + modified: "rgba(59, 130, 246, 0.2)", +} as const; + +const DIFF_BORDER = { + added: "2px solid #22c55e", + removed: "2px solid #ef4444", + modified: "2px solid #3b82f6", +} as const; + +function RichTextContent({ parts }: { parts: RichTextPart[] }) { + return ( + <> + {parts.map((part, i) => { + const key = `${i}-${part.text.slice(0, 8)}`; + return Object.keys(part.style).length === 0 ? ( + {part.text} + ) : ( + + {part.text} + + ); + })} + + ); +} + +function CellContent({ cell }: { cell: ParsedCell }) { + if (cell.richText) return ; + return <>{cell.value}; +} + +function InlineDiffContent({ segments }: { segments: DiffSegment[] }) { + return ( + <> + {segments.map((seg, i) => { + const key = `${i}-${seg.type}-${seg.text.slice(0, 8)}`; + switch (seg.type) { + case "added": + return ( + + {seg.text} + + ); + case "removed": + return ( + + {seg.text} + + ); + default: + return {seg.text}; + } + })} + + ); +} + +function DiffCellTooltip({ + segments, + anchorEl, +}: { + segments: DiffSegment[]; + anchorEl: HTMLElement; +}) { + const [pos, setPos] = useState({ top: 0, left: 0 }); + const tooltipRef = useRef(null); + + useEffect(() => { + const rect = anchorEl.getBoundingClientRect(); + const top = rect.top - 6; + const left = Math.min(rect.left, window.innerWidth - 500); + setPos({ top, left: Math.max(4, left) }); + }, [anchorEl]); + + return createPortal( +
+ +
, + document.body, + ); +} + +interface DiffCellProps { + cell: DiffParsedCell; + cellKey: string; +} + +function DiffCell({ cell, cellKey }: DiffCellProps) { + const [hovered, setHovered] = useState(false); + const tdRef = useRef(null); + + const cellStyle: React.CSSProperties = { + overflow: "hidden", + padding: "1px 2px", + whiteSpace: "nowrap", + lineHeight: "normal", + boxSizing: "border-box", + ...cell.style, + }; + if (cell.diffStatus) { + cellStyle.backgroundColor = DIFF_BG[cell.diffStatus]; + cellStyle.outline = DIFF_BORDER[cell.diffStatus]; + cellStyle.outlineOffset = "-2px"; + } + if (cell.wrapText) { + cellStyle.whiteSpace = "pre-wrap"; + cellStyle.wordBreak = "break-all"; + } + if (cell.diffSegments) { + cellStyle.cursor = "default"; + } + + return ( + setHovered(true) : undefined} + onMouseLeave={cell.diffSegments ? () => setHovered(false) : undefined} + > + {cell.diffSegments ? ( + + ) : ( + + )} + {hovered && cell.diffSegments && tdRef.current && ( + + )} + + ); +} + +function DiffTable({ + rows, + columnWidths, + label, + scrollRef, + peerScrollRef, +}: { + rows: DiffParsedRow[]; + columnWidths: number[]; + label: string; + scrollRef: RefObject; + peerScrollRef: RefObject; +}) { + const [containerWidth, setContainerWidth] = useState(null); + const isSyncingRef = useRef(false); + + const onResize = useCallback(({ width }: { width?: number }) => { + if (width) setContainerWidth(width); + }, []); + const { ref: sizeRef } = useResizeObserver({ onResize }); + + const scaledWidths = useMemo(() => { + if (!containerWidth) return columnWidths; + const total = ROW_NUM_COL_WIDTH + columnWidths.reduce((s, w) => s + w, 0); + if (total <= containerWidth) return columnWidths; + const available = containerWidth - ROW_NUM_COL_WIDTH; + const colTotal = columnWidths.reduce((s, w) => s + w, 0); + if (colTotal <= 0) return columnWidths; + return columnWidths.map((w) => Math.floor((w / colTotal) * available)); + }, [columnWidths, containerWidth]); + + const handleScroll = useCallback(() => { + if (isSyncingRef.current) { + isSyncingRef.current = false; + return; + } + const el = scrollRef.current; + const peer = peerScrollRef.current; + if (!el || !peer) return; + isSyncingRef.current = true; + peer.scrollTop = el.scrollTop; + peer.scrollLeft = el.scrollLeft; + }, [scrollRef, peerScrollRef]); + + const setRefs = useCallback( + (node: HTMLDivElement | null) => { + (scrollRef as React.MutableRefObject).current = + node; + if (typeof sizeRef === "function") sizeRef(node); + }, + [scrollRef, sizeRef], + ); + + return ( +
+
+ {label} +
+ + + + {scaledWidths.map((w, i) => ( + + ))} + + + {rows.map((row, rowIdx) => ( + + + {row.cells.map((cell, colIdx) => { + if (cell.hidden) return null; + return ( + + ); + })} + + ))} + +
+ {rowIdx + 1} +
+
+ ); +} + +export function SpreadsheetDiffViewer({ + workspaceId, + worktreePath, + filePath, + diffCategory, + commitHash, +}: SpreadsheetDiffViewerProps) { + const { diffSheets, isLoading, error } = useSpreadsheetDiff({ + workspaceId, + worktreePath, + filePath, + diffCategory, + commitHash, + }); + const leftScrollRef = useRef(null); + const rightScrollRef = useRef(null); + const [activeSheetIndex, setActiveSheetIndex] = useState(0); + + const activeSheet = + diffSheets.length > 0 + ? diffSheets[Math.min(activeSheetIndex, diffSheets.length - 1)] + : null; + + const diffRowIndices = useMemo(() => { + if (!activeSheet) return []; + const indices: number[] = []; + for (let r = 0; r < activeSheet.modifiedRows.length; r++) { + if (activeSheet.modifiedRows[r].cells.some((c) => c.diffStatus)) { + indices.push(r); + } + } + return indices; + }, [activeSheet]); + + const [currentDiffIdx, setCurrentDiffIdx] = useState(0); + + const jumpToDiff = useCallback( + (idx: number) => { + const rowIdx = diffRowIndices[idx]; + if (rowIdx === undefined) return; + setCurrentDiffIdx(idx); + const left = leftScrollRef.current; + const right = rightScrollRef.current; + if (!left) return; + const rows = left.querySelectorAll("tbody tr"); + const target = rows[rowIdx] as HTMLElement | undefined; + if (!target) return; + const containerRect = left.getBoundingClientRect(); + const targetRect = target.getBoundingClientRect(); + const scrollTop = + left.scrollTop + + targetRect.top - + containerRect.top - + containerRect.height / 2 + + targetRect.height / 2; + left.scrollTop = scrollTop; + if (right) right.scrollTop = scrollTop; + }, + [diffRowIndices], + ); + + const goNext = useCallback(() => { + if (diffRowIndices.length === 0) return; + const next = + currentDiffIdx + 1 < diffRowIndices.length ? currentDiffIdx + 1 : 0; + jumpToDiff(next); + }, [currentDiffIdx, diffRowIndices, jumpToDiff]); + + const goPrev = useCallback(() => { + if (diffRowIndices.length === 0) return; + const prev = + currentDiffIdx - 1 >= 0 ? currentDiffIdx - 1 : diffRowIndices.length - 1; + jumpToDiff(prev); + }, [currentDiffIdx, diffRowIndices, jumpToDiff]); + + if (isLoading) { + return ( +
+ Loading diff... +
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (!activeSheet) { + return ( +
+ No changes found +
+ ); + } + + return ( +
+
+ + {diffRowIndices.length > 0 + ? `${diffRowIndices.length} changes` + : "No changes"} + + {diffRowIndices.length > 0 && ( + <> + + + {currentDiffIdx + 1} / {diffRowIndices.length} + + + + )} +
+
+ +
+ +
+ + {diffSheets.length > 1 && ( +
+ {diffSheets.map((sheet, idx) => ( + + ))} +
+ )} +
+ ); +} + +function getColumnLabel(index: number): string { + let label = ""; + let n = index; + do { + label = String.fromCharCode(65 + (n % 26)) + label; + n = Math.floor(n / 26) - 1; + } while (n >= 0); + return label; +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/SpreadsheetViewer/SpreadsheetViewer.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/SpreadsheetViewer/SpreadsheetViewer.tsx new file mode 100644 index 00000000000..6f8ef42891e --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/SpreadsheetViewer/SpreadsheetViewer.tsx @@ -0,0 +1,536 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import useResizeObserver from "use-resize-observer"; +import { + type ParsedCell, + type RenderAnchor, + type RichTextPart, + useSpreadsheetData, +} from "./useSpreadsheetData"; + +/** EMU → px (96 DPI: 1px = 9525 EMU) */ +const emuToPx = (emu: number): number => emu / 9525; + +/** SVG stroke-dasharray from Excel dash style */ +const SVG_DASH_PATTERNS: Record = { + solid: "", + sysDot: "2,2", + sysDash: "6,2", + dash: "8,4", + dashDot: "8,4,2,4", + lgDash: "12,4", + lgDashDot: "12,4,2,4", + lgDashDotDot: "12,4,2,4,2,4", +}; + +function getDashPattern(dash: string): string { + return SVG_DASH_PATTERNS[dash] || ""; +} + +interface SpreadsheetViewerProps { + workspaceId: string; + filePath: string; +} + +function RichTextContent({ parts }: { parts: RichTextPart[] }) { + return ( + <> + {parts.map((part, i) => { + const key = `${i}-${part.text.slice(0, 8)}`; + if (Object.keys(part.style).length === 0) { + return {part.text}; + } + return ( + + {part.text} + + ); + })} + + ); +} + +function CellContent({ cell }: { cell: ParsedCell }) { + if (cell.richText) { + return ; + } + return <>{cell.value}; +} + +/** Render SVG diagonal lines inside a cell */ +function DiagonalOverlay({ + diagonal, +}: { + diagonal: NonNullable; +}) { + const widthMatch = diagonal.style.match(/^(\d+)px/); + const strokeWidth = widthMatch ? Number(widthMatch[1]) : 1; + + return ( + + {diagonal.down && ( + + )} + {diagonal.up && ( + + )} + + ); +} + +const ROW_NUM_COL_WIDTH = 36; +const DEFAULT_ROW_HEIGHT = 20; + +export function SpreadsheetViewer({ + workspaceId, + filePath, +}: SpreadsheetViewerProps) { + const { sheets, isLoading, error } = useSpreadsheetData( + workspaceId, + filePath, + ); + const [activeSheetIndex, setActiveSheetIndex] = useState(0); + const [containerWidth, setContainerWidth] = useState(null); + const tableRef = useRef(null); + const [domRowYPositions, setDomRowYPositions] = useState | null>(null); + + const onResize = useCallback(({ width }: { width?: number }) => { + if (width) setContainerWidth(width); + }, []); + + const { ref: containerRef } = useResizeObserver({ onResize }); + + const activeSheet = sheets[Math.min(activeSheetIndex, sheets.length - 1)]; + + // Natural (unscaled) column widths from the parser + const columnWidths = activeSheet?.columnWidths ?? []; + + // Cumulative column widths (unscaled) for shape x-coordinate calculation + const cumulativeColWidths = useMemo(() => { + const cumulative: number[] = [0]; + for (let i = 0; i < columnWidths.length; i++) { + cumulative.push(cumulative[i] + columnWidths[i]); + } + return cumulative; + }, [columnWidths]); + + // Natural table width (unscaled) = row-num col + all data columns + const naturalTableWidth = useMemo(() => { + return ROW_NUM_COL_WIDTH + columnWidths.reduce((sum, w) => sum + w, 0); + }, [columnWidths]); + + // Scale factor: shrink table to fit container if needed. + // Instead of recalculating individual column widths, use CSS transform + // so the SVG overlay shares the exact same coordinate space as the table. + const scaleFactor = useMemo(() => { + if (!containerWidth || naturalTableWidth <= containerWidth) return 1; + return containerWidth / naturalTableWidth; + }, [containerWidth, naturalTableWidth]); + + // Measure actual row Y positions from the DOM after render. + // Uses Excel row numbers (from data-row-num) as keys. + useEffect(() => { + if (!activeSheet || activeSheet.shapes.length === 0) { + setDomRowYPositions(null); + return; + } + + const table = tableRef.current; + if (!table) return; + + const rafId = requestAnimationFrame(() => { + const rowMap = new Map(); + const trs = + table.querySelectorAll("tr[data-row-num]"); + for (const tr of trs) { + const excelRow = Number.parseInt(tr.dataset.rowNum ?? "0", 10); + rowMap.set(excelRow, tr.offsetTop); + } + if (trs.length > 0) { + const lastTr = trs[trs.length - 1]; + const lastExcelRow = Number.parseInt( + lastTr.dataset.rowNum ?? "0", + 10, + ); + rowMap.set( + lastExcelRow + 1, + lastTr.offsetTop + lastTr.offsetHeight, + ); + } + // Extrapolate for shapes extending beyond rendered rows + let maxNeeded = 0; + for (const s of activeSheet.shapes) + maxNeeded = Math.max(maxNeeded, s.br.r + 2); + const measuredKeys = Array.from(rowMap.keys()); + const measuredMax = + measuredKeys.length > 0 ? Math.max(...measuredKeys) : 1; + if (maxNeeded > measuredMax) { + let cumulative = rowMap.get(measuredMax) ?? 0; + for (let r = measuredMax; r <= maxNeeded; r++) { + if (!rowMap.has(r)) rowMap.set(r, cumulative); + cumulative += DEFAULT_ROW_HEIGHT; + } + } + setDomRowYPositions(rowMap); + }); + return () => cancelAnimationFrame(rafId); + }, [activeSheet]); + + // Fallback cumulative row Y positions keyed by Excel row number + const calculatedRowYPositions = useMemo(() => { + if (!activeSheet) return new Map(); + const map = new Map(); + let cumY = 0; + for (const row of activeSheet.rows) { + map.set(row.excelRow, cumY); + cumY += row.height; + } + const lastRow = activeSheet.rows[activeSheet.rows.length - 1]; + if (lastRow) map.set(lastRow.excelRow + 1, cumY); + return map; + }, [activeSheet]); + + const effectiveRowYPositions = domRowYPositions ?? calculatedRowYPositions; + + // Simple anchor position calculation — same approach as ai-zyusetu. + // Because we use CSS transform: scale() instead of recalculating column + // widths, the SVG and table share the same (unscaled) coordinate space. + // EMU offsets are added directly without any scaling. + const getAnchorPosition = useCallback( + (anchor: RenderAnchor): { x: number; y: number } => { + const minCol = activeSheet?.minCol ?? 1; + const colIdx = Math.max( + 0, + Math.min(anchor.c - (minCol - 1), cumulativeColWidths.length - 1), + ); + const x = + ROW_NUM_COL_WIDTH + + cumulativeColWidths[colIdx] + + emuToPx(anchor.co); + const excelRow = anchor.r + 1; + const y = + (effectiveRowYPositions.get(excelRow) ?? 0) + emuToPx(anchor.ro); + return { x, y }; + }, + [activeSheet, cumulativeColWidths, effectiveRowYPositions], + ); + + const renderShapeOverlay = useCallback(() => { + if (!activeSheet || activeSheet.shapes.length === 0) return null; + + const shapes = activeSheet.shapes; + const lastRow = activeSheet.rows[activeSheet.rows.length - 1]; + const totalHeight = lastRow + ? (effectiveRowYPositions.get(lastRow.excelRow + 1) ?? + activeSheet.rows.reduce((sum, r) => sum + r.height, 0)) + : 0; + + return ( + + {shapes.map((shape, i) => { + const tlPos = getAnchorPosition(shape.tl); + const brPos = getAnchorPosition(shape.br); + const dashPattern = getDashPattern(shape.o.d); + const dashProps = dashPattern + ? { strokeDasharray: dashPattern } + : {}; + + if (shape.t === "line") { + const flipped = shape.vf !== shape.hf; + return ( + + ); + } + + return ( + + ); + })} + + ); + }, [activeSheet, naturalTableWidth, effectiveRowYPositions, getAnchorPosition]); + + if (isLoading) { + return ( +
+ Loading spreadsheet... +
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (sheets.length === 0 || !activeSheet) { + return ( +
+ No sheets found +
+ ); + } + + const needsScale = scaleFactor < 1; + + return ( +
+
+ {/* Outer wrapper: clips to container width */} +
+ {/* Inner wrapper: holds table + SVG at natural size, scaled via CSS transform. + This ensures SVG and table share the exact same coordinate space. */} +
+ + + + {columnWidths.map((w, i) => ( + + ))} + + + + + ); + })} + + + + {activeSheet.rows.map((row, rowIdx) => ( + + + {row.cells.map((cell, colIdx) => { + if (cell.hidden) return null; + + const cellStyle: React.CSSProperties = { + overflow: "hidden", + padding: "1px 3px", + whiteSpace: "nowrap", + lineHeight: "normal", + boxSizing: "border-box", + position: cell.diagonal ? "relative" : undefined, + ...cell.style, + }; + + if (cell.wrapText) { + cellStyle.whiteSpace = "pre-wrap"; + cellStyle.wordBreak = "break-all"; + cellStyle.overflow = "visible"; + } + + if (cell.verticalText) { + cellStyle.writingMode = "vertical-rl"; + cellStyle.textOrientation = "upright"; + cellStyle.letterSpacing = 0; + cellStyle.lineHeight = 1; + cellStyle.textAlign = "center"; + cellStyle.verticalAlign = "middle"; + cellStyle.whiteSpace = "normal"; + cellStyle.wordBreak = "keep-all"; + cellStyle.overflow = "hidden"; + cellStyle.padding = "2px 0"; + } + + return ( + + ); + })} + + ))} + +
+ {Array.from({ length: activeSheet.columnCount }, (_, i) => { + const label = getColumnLabel(i); + return ( + + {label} +
+ {rowIdx + 1} + + + {cell.diagonal && ( + + )} +
+ {(domRowYPositions || activeSheet.shapes.length === 0) && + renderShapeOverlay()} +
+
+ {activeSheet.truncated && ( +
+ Showing first 2,000 rows. Full file contains more rows. +
+ )} +
+ + {sheets.length > 1 && ( +
+ {sheets.map((sheet, idx) => ( + + ))} +
+ )} +
+ ); +} + +function getColumnLabel(index: number): string { + let label = ""; + let n = index; + do { + label = String.fromCharCode(65 + (n % 26)) + label; + n = Math.floor(n / 26) - 1; + } while (n >= 0); + return label; +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/SpreadsheetViewer/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/SpreadsheetViewer/index.ts new file mode 100644 index 00000000000..1485822b8bf --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/SpreadsheetViewer/index.ts @@ -0,0 +1,2 @@ +export { SpreadsheetDiffViewer } from "./SpreadsheetDiffViewer"; +export { SpreadsheetViewer } from "./SpreadsheetViewer"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/SpreadsheetViewer/parseWorkbook.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/SpreadsheetViewer/parseWorkbook.ts new file mode 100644 index 00000000000..6c16703326c --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/SpreadsheetViewer/parseWorkbook.ts @@ -0,0 +1,685 @@ +import type React from "react"; + +const MAX_ROWS = 2000; + +// ── Types ── + +type StyleObj = React.CSSProperties; + +export interface RichTextPart { + text: string; + style: StyleObj; +} + +export interface RenderAnchor { + c: number; // col (0-indexed) + co: number; // colOff (EMU) + r: number; // row (0-indexed) + ro: number; // rowOff (EMU) +} + +export interface RenderShape { + n: string; // name + t: string; // type ("line" | "rect" etc.) + vf: boolean; // verticalFlip + hf: boolean; // horizontalFlip + tl: RenderAnchor; // top-left anchor + br: RenderAnchor; // bottom-right anchor + o: { + w: number; // outline weight (px) + cl: string; // outline color + d: string; // dash style + }; +} + +export interface DiagonalBorder { + up: boolean; // bottom-left to top-right + down: boolean; // top-left to bottom-right + style: string; // CSS border style e.g. "1px solid" + color: string; // e.g. "#000" +} + +export interface ParsedCell { + value: string; + style: StyleObj; + colSpan?: number; + rowSpan?: number; + hidden?: boolean; + wrapText?: boolean; + verticalText?: boolean; + richText?: RichTextPart[]; + diagonal?: DiagonalBorder; +} + +export interface ParsedRow { + excelRow: number; // actual Excel row number (1-based) + cells: ParsedCell[]; + height: number; +} + +export interface ParsedSheet { + name: string; + rows: ParsedRow[]; + columnCount: number; + columnWidths: number[]; + truncated: boolean; + shapes: RenderShape[]; + /** First data column in Excel (1-based) */ + minCol: number; +} + +// ── Theme colors (standard Excel Office theme) ── + +const THEME_COLORS: Record = { + 0: "#FFFFFF", + 1: "#000000", + 2: "#E7E6E6", + 3: "#44546A", + 4: "#4472C4", + 5: "#ED7D31", + 6: "#A5A5A5", + 7: "#FFC000", + 8: "#5B9BD5", + 9: "#70AD47", +}; + +const BORDER_STYLES: Record = { + thin: "1px solid", + medium: "2px solid", + thick: "3px solid", + dotted: "1px dotted", + dashed: "1px dashed", + double: "3px double", + mediumDashed: "2px dashed", + dashDot: "1px dashed", + dashDotDot: "1px dashed", + mediumDashDot: "2px dashed", + mediumDashDotDot: "2px dashed", + slantDashDot: "1px dashed", + hair: "1px solid", +}; + +// ── Color resolution ── + +function argbToHex(argb: string | undefined): string | null { + if (!argb || argb.length < 6) return null; + const hex = argb.length === 8 ? argb.slice(2) : argb; + if (/^0+$/.test(hex)) return null; + return `#${hex}`; +} + +function applyTint(hex: string, tint: number): string { + const r = Number.parseInt(hex.slice(1, 3), 16); + const g = Number.parseInt(hex.slice(3, 5), 16); + const b = Number.parseInt(hex.slice(5, 7), 16); + const apply = (c: number) => + tint < 0 ? Math.round(c * (1 + tint)) : Math.round(c + (255 - c) * tint); + const clamp = (v: number) => Math.min(255, Math.max(0, v)); + return `#${clamp(apply(r)).toString(16).padStart(2, "0")}${clamp(apply(g)).toString(16).padStart(2, "0")}${clamp(apply(b)).toString(16).padStart(2, "0")}`; +} + +// biome-ignore lint/suspicious/noExplicitAny: ExcelJS internal types are incomplete +function resolveColor(color: any): string | null { + if (!color) return null; + if (color.argb) return argbToHex(color.argb); + if (color.theme !== undefined) { + const base = THEME_COLORS[color.theme] || "#000000"; + return color.tint ? applyTint(base, color.tint) : base; + } + if (color.indexed !== undefined) + return color.indexed === 64 ? "#000000" : null; + return null; +} + +const SHAPE_THEME_COLORS: Record = { + lt1: "#FFFFFF", + dk1: "#000000", + lt2: "#E7E6E6", + dk2: "#44546A", + accent1: "#4472C4", + accent2: "#ED7D31", + accent3: "#A5A5A5", + accent4: "#FFC000", + accent5: "#5B9BD5", + accent6: "#70AD47", +}; + +// ── Drawing XML parser (works with standard ExcelJS 4.4.0) ── + +function xmlAttr(el: Element, name: string): string { + return el.getAttribute(name) || ""; +} + +function xmlInt(el: Element, name: string): number { + return Number.parseInt(el.getAttribute(name) || "0", 10); +} + +function xmlChild(el: Element, localName: string): Element | null { + for (let i = 0; i < el.children.length; i++) { + const child = el.children[i]; + if (child.localName === localName) return child; + } + return null; +} + +function xmlText(el: Element, localName: string): string { + const child = xmlChild(el, localName); + return child?.textContent?.trim() || "0"; +} + +function parseAnchorPosition(el: Element): RenderAnchor { + return { + c: Number.parseInt(xmlText(el, "col"), 10), + co: Number.parseInt(xmlText(el, "colOff"), 10), + r: Number.parseInt(xmlText(el, "row"), 10), + ro: Number.parseInt(xmlText(el, "rowOff"), 10), + }; +} + +function resolveXmlColor(el: Element | null): string { + if (!el) return "#000000"; + // + const srgb = xmlChild(el, "srgbClr"); + if (srgb) return `#${xmlAttr(srgb, "val")}`; + // + const scheme = xmlChild(el, "schemeClr"); + if (scheme) { + const val = xmlAttr(scheme, "val"); + return SHAPE_THEME_COLORS[val] || "#000000"; + } + return "#000000"; +} + +function parseShapeFromAnchor(anchor: Element): RenderShape | null { + const from = xmlChild(anchor, "from"); + const to = xmlChild(anchor, "to"); + if (!from || !to) return null; + + // Look for sp (shape) or cxnSp (connector) + const sp = xmlChild(anchor, "sp") || xmlChild(anchor, "cxnSp"); + if (!sp) return null; + + // Get name from nvSpPr/cNvPr or nvCxnSpPr/cNvPr + const nvPr = + xmlChild(sp, "nvSpPr") || xmlChild(sp, "nvCxnSpPr"); + const cNvPr = nvPr ? xmlChild(nvPr, "cNvPr") : null; + const name = cNvPr ? xmlAttr(cNvPr, "name") : ""; + + // Get shape properties + const spPr = xmlChild(sp, "spPr"); + if (!spPr) return null; + + // Determine shape type from prstGeom + const prstGeom = xmlChild(spPr, "prstGeom"); + const prst = prstGeom ? xmlAttr(prstGeom, "prst") : ""; + const isLine = prst === "line" || sp.localName === "cxnSp"; + + // Get transform (flip, rotation) + const xfrm = xmlChild(spPr, "xfrm"); + const flipH = xfrm ? xmlAttr(xfrm, "flipH") === "1" : false; + const flipV = xfrm ? xmlAttr(xfrm, "flipV") === "1" : false; + + // Get line properties + const ln = xmlChild(spPr, "ln"); + let lineWidth = 1; + let lineColor = "#000000"; + let lineDash = "solid"; + + if (ln) { + const w = xmlAttr(ln, "w"); + if (w) lineWidth = (Number.parseInt(w, 10) / 12700) * (96 / 72); + const fill = xmlChild(ln, "solidFill"); + if (fill) lineColor = resolveXmlColor(fill); + const dash = xmlChild(ln, "prstDash"); + if (dash) lineDash = xmlAttr(dash, "val") || "solid"; + } + + return { + n: name, + t: isLine ? "line" : prst || "rect", + vf: flipV, + hf: flipH, + tl: parseAnchorPosition(from), + br: parseAnchorPosition(to), + o: { w: lineWidth, cl: lineColor, d: lineDash }, + }; +} + +async function parseDrawingsFromZip( + zipBuffer: ArrayBuffer, +): Promise> { + // biome-ignore lint/suspicious/noExplicitAny: jszip has no type declarations in this context + const JSZip = (await import("jszip" as any)).default; + const zip = await JSZip.loadAsync(zipBuffer); + const parser = new DOMParser(); + const result = new Map(); + + const files = zip.files as Record< + string, + { dir: boolean; async: (type: string) => Promise } + >; + + // Find which sheet links to which drawing via rels files + const sheetDrawingMap = new Map(); + for (const [name, file] of Object.entries(files)) { + const relsMatch = name.match( + /xl\/worksheets\/_rels\/sheet(\d+)\.xml\.rels$/, + ); + if (!relsMatch || file.dir) continue; + const sheetIndex = Number.parseInt(relsMatch[1], 10); + const relsXml = await file.async("text"); + const doc = parser.parseFromString(relsXml, "application/xml"); + const rels = doc.getElementsByTagName("Relationship"); + for (let i = 0; i < rels.length; i++) { + const target = rels[i].getAttribute("Target") || ""; + const drawingMatch = target.match(/drawing(\d+)\.xml$/); + if (drawingMatch) { + sheetDrawingMap.set(`drawing${drawingMatch[1]}`, sheetIndex); + } + } + } + + // Parse each drawing XML + for (const [name, file] of Object.entries(files)) { + const drawingMatch = name.match(/xl\/drawings\/(drawing\d+)\.xml$/); + if (!drawingMatch || file.dir) continue; + const drawingId = drawingMatch[1]; + const sheetIndex = sheetDrawingMap.get(drawingId); + if (sheetIndex === undefined) continue; + + const xml = await file.async("text"); + const doc = parser.parseFromString(xml, "application/xml"); + const shapes: RenderShape[] = []; + + // Parse twoCellAnchor elements + const anchors = doc.getElementsByTagNameNS("*", "twoCellAnchor"); + for (let i = 0; i < anchors.length; i++) { + const shape = parseShapeFromAnchor(anchors[i]); + if (shape) shapes.push(shape); + } + + if (shapes.length > 0) { + result.set(sheetIndex, shapes); + } + } + + return result; +} + +// biome-ignore lint/suspicious/noExplicitAny: ExcelJS internal types are incomplete +function borderToCSS(b: any): string | null { + if (!b?.style) return null; + const base = BORDER_STYLES[b.style] || "1px solid"; + const col = resolveColor(b.color) || "#000"; + return `${base} ${col}`; +} + +function rowHeightToPx(h: number | undefined): number { + if (!h || h <= 0) return 20; + return Math.round((h * 96) / 72); +} + +function charWidthToPx(w: number | undefined): number { + if (!w || w <= 0) return 64; + return Math.max(4, Math.round(w * 10)); +} + +// biome-ignore lint/suspicious/noExplicitAny: ExcelJS internal types are incomplete +function richTextFontStyle(font: any): StyleObj { + const s: StyleObj = {}; + if (!font) return s; + if (font.size) s.fontSize = `${font.size}pt`; + if (font.name) s.fontFamily = `'${font.name}', sans-serif`; + if (font.bold) s.fontWeight = "bold"; + if (font.italic) s.fontStyle = "italic"; + const decor: string[] = []; + if (font.underline) decor.push("underline"); + if (font.strike) decor.push("line-through"); + if (decor.length) s.textDecoration = decor.join(" "); + const fc = resolveColor(font.color); + if (fc && fc !== "#FFFFFF") s.color = fc; + if (font.vertAlign === "superscript") { + s.verticalAlign = "super"; + s.fontSize = s.fontSize || "0.7em"; + } + if (font.vertAlign === "subscript") { + s.verticalAlign = "sub"; + s.fontSize = s.fontSize || "0.7em"; + } + return s; +} + +// biome-ignore lint/suspicious/noExplicitAny: ExcelJS internal types are incomplete +function getCellStyle(cell: any): StyleObj { + const style: StyleObj = { verticalAlign: "bottom" }; + const al = cell.alignment; + if (al) { + const hmap: Record = { + left: "left", + center: "center", + right: "right", + fill: "left", + justify: "justify", + centerContinuous: "center", + distributed: "center", + }; + const vmap: Record = { + top: "top", + middle: "middle", + center: "middle", + bottom: "bottom", + distributed: "middle", + justify: "middle", + }; + if (al.horizontal) + style.textAlign = (hmap[al.horizontal] || + "left") as StyleObj["textAlign"]; + style.verticalAlign = ((al.vertical && vmap[al.vertical]) || + "bottom") as StyleObj["verticalAlign"]; + if (al.indent) style.paddingLeft = `${al.indent * 8 + 3}px`; + } + const f = cell.font; + if (f) { + if (f.size) style.fontSize = `${f.size}pt`; + if (f.name) style.fontFamily = `'${f.name}', sans-serif`; + if (f.bold) style.fontWeight = "bold"; + if (f.italic) style.fontStyle = "italic"; + const decor: string[] = []; + if (f.underline) decor.push("underline"); + if (f.strike) decor.push("line-through"); + if (decor.length) style.textDecoration = decor.join(" "); + const fc = resolveColor(f.color); + if (fc && fc !== "#FFFFFF") style.color = fc; + } + const fill = cell.fill; + if (fill?.type === "pattern" && fill.pattern === "solid") { + const bg = resolveColor(fill.fgColor); + if (bg) style.backgroundColor = bg; + } + const bd = cell.border; + if (bd) { + const bt = borderToCSS(bd.top); + if (bt) style.borderTop = bt; + const bb = borderToCSS(bd.bottom); + if (bb) style.borderBottom = bb; + const bl = borderToCSS(bd.left); + if (bl) style.borderLeft = bl; + const br = borderToCSS(bd.right); + if (br) style.borderRight = br; + } + return style; +} + +// biome-ignore lint/suspicious/noExplicitAny: ExcelJS internal types are incomplete +function getCellDiagonal(cell: any): DiagonalBorder | undefined { + const bd = cell.border; + if (!bd?.diagonal?.style) return undefined; + const up = bd.diagonal.up === true; + const down = bd.diagonal.down === true; + if (!up && !down) return undefined; + const base = BORDER_STYLES[bd.diagonal.style] || "1px solid"; + const color = resolveColor(bd.diagonal.color) || "#000"; + return { up, down, style: base, color }; +} + +function getMergedCellBorders( + // biome-ignore lint/suspicious/noExplicitAny: ExcelJS internal types are incomplete + ws: any, + r: number, + c: number, + rowspan: number, + colspan: number, +): StyleObj { + const borders: StyleObj = {}; + const getBorder = (row: number, col: number) => + ws.getRow(row).getCell(col).border; + const topBd = getBorder(r, c); + if (topBd?.top) { + const v = borderToCSS(topBd.top); + if (v) borders.borderTop = v; + } + if (topBd?.left) { + const v = borderToCSS(topBd.left); + if (v) borders.borderLeft = v; + } + const bottomRow = r + rowspan - 1; + for (let cc = c; cc < c + colspan; cc++) { + const bd = getBorder(bottomRow, cc); + if (bd?.bottom) { + const v = borderToCSS(bd.bottom); + if (v) { + borders.borderBottom = v; + break; + } + } + } + const rightCol = c + colspan - 1; + for (let rr = r; rr < r + rowspan; rr++) { + const bd = getBorder(rr, rightCol); + if (bd?.right) { + const v = borderToCSS(bd.right); + if (v) { + borders.borderRight = v; + break; + } + } + } + return borders; +} + +// biome-ignore lint/suspicious/noExplicitAny: ExcelJS internal types are incomplete +function getCellDisplayValue(cell: any): string { + if (cell.type === 2) return ""; + if (cell.value?.richText) { + // biome-ignore lint/suspicious/noExplicitAny: ExcelJS internal types are incomplete + return cell.value.richText.map((rt: any) => rt.text || "").join(""); + } + if (cell.value?.formula) { + const r = cell.value.result; + return r != null ? String(r) : ""; + } + if (cell.value instanceof Date) return cell.value.toLocaleDateString(); + if (cell.text != null) return String(cell.text); + if (cell.value != null) return String(cell.value); + return ""; +} + +interface MergeOrigin { + rowspan: number; + colspan: number; +} +type MergeEntry = MergeOrigin | { skip: true }; + +// biome-ignore lint/suspicious/noExplicitAny: ExcelJS internal types are incomplete +function buildMergeMap(ws: any): Record { + const mm: Record = {}; + const model = ws.model; + if (!model?.merges) return mm; + for (const range of model.merges) { + const parts = range.split(":"); + if (parts.length !== 2) continue; + const s = decodeAddr(parts[0]); + const e = decodeAddr(parts[1]); + for (let r = s.r; r <= e.r; r++) { + for (let c = s.c; c <= e.c; c++) { + const key = `${r},${c}`; + if (r === s.r && c === s.c) + mm[key] = { rowspan: e.r - s.r + 1, colspan: e.c - s.c + 1 }; + else mm[key] = { skip: true }; + } + } + } + return mm; +} + +function decodeAddr(addr: string): { r: number; c: number } { + const m = addr.match(/^([A-Z]+)(\d+)$/); + if (!m) return { r: 1, c: 1 }; + const col = m[1] + .split("") + .reduce((a, ch) => a * 26 + ch.charCodeAt(0) - 64, 0); + return { r: Number.parseInt(m[2], 10), c: col }; +} + +interface SheetDims { + minR: number; + maxR: number; + minC: number; + maxC: number; +} + +function parsePrintArea(area: string): SheetDims | null { + const clean = area.replace(/\$/g, ""); + const m = clean.match(/^([A-Z]+)(\d+):([A-Z]+)(\d+)$/); + if (!m) return null; + const colToNum = (s: string) => + s.split("").reduce((a, ch) => a * 26 + ch.charCodeAt(0) - 64, 0); + return { + minC: colToNum(m[1]), + minR: Number.parseInt(m[2], 10), + maxC: colToNum(m[3]), + maxR: Number.parseInt(m[4], 10), + }; +} + +// biome-ignore lint/suspicious/noExplicitAny: ExcelJS internal types are incomplete +function getSheetDimensions(ws: any): SheetDims { + const printArea = ws.pageSetup?.printArea; + if (printArea) { + const parsed = parsePrintArea(printArea.split(",")[0].trim()); + if (parsed) return parsed; + } + const dims = ws.dimensions; + if (dims) + return { + minR: dims.top || 1, + maxR: dims.bottom || 1, + minC: dims.left || 1, + maxC: dims.right || 1, + }; + return { + minR: 1, + maxR: ws.rowCount || 1, + minC: 1, + maxC: ws.columnCount || 1, + }; +} + +// ── Main parser ── + +export async function parseWorkbook( + base64Content: string, +): Promise { + const ExcelJS = await import("exceljs"); + const workbook = new ExcelJS.Workbook(); + const binaryStr = atob(base64Content); + const bytes = new Uint8Array(binaryStr.length); + for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i); + const buffer = bytes.buffer as ArrayBuffer; + await workbook.xlsx.load(buffer); + + // Parse drawing objects (shapes/lines) directly from the xlsx ZIP + // since standard ExcelJS 4.4.0 only supports images, not shapes. + const drawingsMap = await parseDrawingsFromZip(buffer); + + const sheets: ParsedSheet[] = []; + let sheetIndex = 0; + + workbook.eachSheet((worksheet) => { + sheetIndex++; + const dims = getSheetDimensions(worksheet); + const mergeMap = buildMergeMap(worksheet); + const shapes = drawingsMap.get(sheetIndex) || []; + const colCount = dims.maxC - dims.minC + 1; + const columnWidths: number[] = []; + for (let c = dims.minC; c <= dims.maxC; c++) { + const col = worksheet.getColumn(c); + columnWidths.push(col.hidden ? 0 : charWidthToPx(col.width)); + } + + const rows: ParsedRow[] = []; + const maxRow = Math.min(dims.maxR, dims.minR + MAX_ROWS - 1); + const truncated = dims.maxR > maxRow; + + for (let r = dims.minR; r <= maxRow; r++) { + const row = worksheet.getRow(r); + if (row.hidden) continue; + const cells: ParsedCell[] = []; + + for (let c = dims.minC; c <= dims.maxC; c++) { + const key = `${r},${c}`; + const mergeEntry = mergeMap[key]; + if (mergeEntry && "skip" in mergeEntry) { + cells.push({ value: "", style: {}, hidden: true }); + continue; + } + + // biome-ignore lint/suspicious/noExplicitAny: ExcelJS internal types are incomplete + const cell = row.getCell(c) as any; + const val = getCellDisplayValue(cell); + let style = getCellStyle(cell); + const mergeInfo = + mergeEntry && "rowspan" in mergeEntry ? mergeEntry : null; + const colspan = mergeInfo?.colspan ?? 1; + const rowspan = mergeInfo?.rowspan ?? 1; + + if (mergeInfo) { + const { + borderTop: _bt, + borderBottom: _bb, + borderLeft: _bl, + borderRight: _br, + ...rest + } = style as Record; + style = { + ...rest, + ...getMergedCellBorders(worksheet, r, c, rowspan, colspan), + } as StyleObj; + } + + const isRichText = !!cell.value?.richText; + const richText: RichTextPart[] | undefined = isRichText + ? // biome-ignore lint/suspicious/noExplicitAny: ExcelJS internal types are incomplete + cell.value.richText.map((rt: any) => ({ + text: rt.text || "", + style: richTextFontStyle(rt.font), + })) + : undefined; + + const al = cell.alignment; + const wrapText = + al?.wrapText === true || + (typeof val === "string" && val.includes("\n")); + const verticalText = + al?.textRotation === "vertical" || al?.textRotation === 255; + + const diagonal = getCellDiagonal(cell); + + const parsed: ParsedCell = { value: val, style }; + if (mergeInfo) { + parsed.colSpan = colspan; + parsed.rowSpan = rowspan; + } + if (wrapText) parsed.wrapText = true; + if (verticalText) parsed.verticalText = true; + if (richText) parsed.richText = richText; + if (diagonal) parsed.diagonal = diagonal; + cells.push(parsed); + } + + rows.push({ excelRow: r, cells, height: rowHeightToPx(row.height) }); + } + + sheets.push({ + name: worksheet.name, + rows, + columnCount: colCount, + columnWidths, + truncated, + shapes, + minCol: dims.minC, + }); + }); + + return sheets; +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/SpreadsheetViewer/useSpreadsheetData.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/SpreadsheetViewer/useSpreadsheetData.ts new file mode 100644 index 00000000000..c72f2df9981 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/SpreadsheetViewer/useSpreadsheetData.ts @@ -0,0 +1,81 @@ +import { useEffect, useState } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import type { ParsedSheet } from "./parseWorkbook"; + +export type { + DiagonalBorder, + ParsedCell, + ParsedRow, + ParsedSheet, + RenderAnchor, + RenderShape, + RichTextPart, +} from "./parseWorkbook"; + +const MAX_SPREADSHEET_SIZE = 10 * 1024 * 1024; + +interface UseSpreadsheetDataResult { + sheets: ParsedSheet[]; + isLoading: boolean; + error: string | null; +} + +export function useSpreadsheetData( + workspaceId: string, + filePath: string, +): UseSpreadsheetDataResult { + const [sheets, setSheets] = useState([]); + const [isParsing, setIsParsing] = useState(false); + const [parseError, setParseError] = useState(null); + + const query = electronTrpc.filesystem.readFile.useQuery( + { + workspaceId, + absolutePath: filePath, + maxBytes: MAX_SPREADSHEET_SIZE, + }, + { retry: false, refetchOnWindowFocus: false }, + ); + + useEffect(() => { + if (!query.data) return; + + if (query.data.exceededLimit) { + setParseError("File is too large to preview (>10MB)"); + return; + } + + let cancelled = false; + setIsParsing(true); + setParseError(null); + + import("./parseWorkbook") + .then(({ parseWorkbook }) => parseWorkbook(query.data?.content as string)) + .then((parsed) => { + if (!cancelled) { + setSheets(parsed); + setIsParsing(false); + } + }) + .catch((err) => { + if (!cancelled) { + setParseError( + err instanceof Error ? err.message : "Failed to parse spreadsheet", + ); + setIsParsing(false); + } + }); + + return () => { + cancelled = true; + }; + }, [query.data]); + + const error = query.error ? "Failed to load file" : parseError; + + return { + sheets, + isLoading: query.isLoading || isParsing, + error, + }; +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/SpreadsheetViewer/useSpreadsheetDiff.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/SpreadsheetViewer/useSpreadsheetDiff.ts new file mode 100644 index 00000000000..c6a0f94f6ea --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/SpreadsheetViewer/useSpreadsheetDiff.ts @@ -0,0 +1,354 @@ +import { diffChars } from "diff"; +import { useEffect, useMemo, useState } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import type { ChangeCategory } from "shared/changes-types"; +import type { ParsedCell, ParsedSheet } from "./useSpreadsheetData"; + +const MAX_SPREADSHEET_SIZE = 10 * 1024 * 1024; + +export interface DiffSegment { + text: string; + type: "added" | "removed" | "unchanged"; +} + +export interface DiffParsedCell extends ParsedCell { + diffStatus?: "added" | "removed" | "modified"; + diffSegments?: DiffSegment[]; +} + +export interface DiffParsedRow { + cells: DiffParsedCell[]; + height: number; +} + +export interface DiffParsedSheet { + name: string; + originalRows: DiffParsedRow[]; + modifiedRows: DiffParsedRow[]; + columnCount: number; + columnWidths: number[]; + sheetStatus?: "added" | "removed"; +} + +function computeDiffSegments( + oldValue: string, + newValue: string, + side: "original" | "modified", +): DiffSegment[] { + const changes = diffChars(oldValue, newValue); + const segments: DiffSegment[] = []; + for (const change of changes) { + if (change.added) { + if (side === "modified") { + segments.push({ text: change.value, type: "added" }); + } + // skip added parts on original side + } else if (change.removed) { + if (side === "original") { + segments.push({ text: change.value, type: "removed" }); + } + // skip removed parts on modified side + } else { + segments.push({ text: change.value, type: "unchanged" }); + } + } + return segments; +} + +async function parseBase64Workbook( + base64Content: string, +): Promise { + const { parseWorkbook } = await import("./parseWorkbook"); + return parseWorkbook(base64Content); +} + +function compareCellValue(a: ParsedCell, b: ParsedCell): boolean { + return a.value === b.value; +} + +function buildDiffSheets( + originalSheets: ParsedSheet[], + modifiedSheets: ParsedSheet[], +): DiffParsedSheet[] { + const result: DiffParsedSheet[] = []; + + const origMap = new Map(originalSheets.map((s) => [s.name, s])); + const modMap = new Map(modifiedSheets.map((s) => [s.name, s])); + + const allNames = new Set([...origMap.keys(), ...modMap.keys()]); + + for (const name of allNames) { + const orig = origMap.get(name); + const mod = modMap.get(name); + + if (!orig && mod) { + result.push({ + name, + originalRows: [], + modifiedRows: mod.rows.map((r) => ({ + ...r, + cells: r.cells.map((c) => ({ ...c, diffStatus: "added" as const })), + })), + columnCount: mod.columnCount, + columnWidths: mod.columnWidths, + sheetStatus: "added", + }); + continue; + } + + if (orig && !mod) { + result.push({ + name, + originalRows: orig.rows.map((r) => ({ + ...r, + cells: r.cells.map((c) => ({ + ...c, + diffStatus: "removed" as const, + })), + })), + modifiedRows: [], + columnCount: orig.columnCount, + columnWidths: orig.columnWidths, + sheetStatus: "removed", + }); + continue; + } + + if (orig && mod) { + const maxRows = Math.max(orig.rows.length, mod.rows.length); + const maxCols = Math.max(orig.columnCount, mod.columnCount); + const colWidths = + mod.columnWidths.length >= orig.columnWidths.length + ? mod.columnWidths + : orig.columnWidths; + + const origRows: DiffParsedRow[] = []; + const modRows: DiffParsedRow[] = []; + + for (let r = 0; r < maxRows; r++) { + const origRow = orig.rows[r]; + const modRow = mod.rows[r]; + + const origCells: DiffParsedCell[] = []; + const modCells: DiffParsedCell[] = []; + + for (let c = 0; c < maxCols; c++) { + const origCell = origRow?.cells[c]; + const modCell = modRow?.cells[c]; + + const emptyCell: DiffParsedCell = { + value: "", + style: {}, + }; + + if (!origCell && modCell) { + origCells.push(emptyCell); + modCells.push({ + ...modCell, + diffStatus: modCell.value ? "added" : undefined, + }); + } else if (origCell && !modCell) { + origCells.push({ + ...origCell, + diffStatus: origCell.value ? "removed" : undefined, + }); + modCells.push(emptyCell); + } else if (origCell && modCell) { + const changed = !compareCellValue(origCell, modCell); + origCells.push({ + ...origCell, + diffStatus: changed ? "modified" : undefined, + diffSegments: changed + ? computeDiffSegments(origCell.value, modCell.value, "original") + : undefined, + }); + modCells.push({ + ...modCell, + diffStatus: changed ? "modified" : undefined, + diffSegments: changed + ? computeDiffSegments(origCell.value, modCell.value, "modified") + : undefined, + }); + } else { + origCells.push(emptyCell); + modCells.push(emptyCell); + } + } + + origRows.push({ + cells: origCells, + height: origRow?.height ?? modRow?.height ?? 20, + }); + modRows.push({ + cells: modCells, + height: modRow?.height ?? origRow?.height ?? 20, + }); + } + + result.push({ + name, + originalRows: origRows, + modifiedRows: modRows, + columnCount: maxCols, + columnWidths: colWidths, + }); + } + } + + return result; +} + +interface UseSpreadsheetDiffParams { + workspaceId: string; + worktreePath: string; + filePath: string; + diffCategory?: ChangeCategory; + commitHash?: string; +} + +interface UseSpreadsheetDiffResult { + diffSheets: DiffParsedSheet[]; + isLoading: boolean; + error: string | null; + debug: Record; +} + +export function useSpreadsheetDiff({ + workspaceId, + worktreePath, + filePath, + diffCategory, + commitHash, +}: UseSpreadsheetDiffParams): UseSpreadsheetDiffResult { + const [diffSheets, setDiffSheets] = useState([]); + const [isParsing, setIsParsing] = useState(false); + const [parseError, setParseError] = useState(null); + + // Determine git refs for original and modified + const refs = useMemo(() => { + switch (diffCategory) { + case "staged": + return { originalRef: "HEAD", modifiedRef: undefined }; // modified = staged (:0:) + case "committed": + return { + originalRef: commitHash ? `${commitHash}^` : "HEAD", + modifiedRef: commitHash ?? "HEAD", + }; + case "against-base": + return { originalRef: "origin/main", modifiedRef: "HEAD" }; + default: + // unstaged: original from git, modified from disk + return { originalRef: "HEAD", modifiedRef: undefined }; + } + }, [diffCategory, commitHash]); + + const isUnstaged = !diffCategory || diffCategory === "unstaged"; + + // Fetch original from git + const originalQuery = electronTrpc.changes.readGitFileBinary.useQuery( + { + worktreePath, + absolutePath: filePath, + ref: refs.originalRef ?? "HEAD", + }, + { retry: false, refetchOnWindowFocus: false, enabled: !!worktreePath }, + ); + + // Fetch modified: from git ref or from disk + const modifiedGitQuery = electronTrpc.changes.readGitFileBinary.useQuery( + { + worktreePath, + absolutePath: filePath, + ref: refs.modifiedRef ?? "HEAD", + }, + { + retry: false, + refetchOnWindowFocus: false, + enabled: !!worktreePath && !isUnstaged && !!refs.modifiedRef, + }, + ); + + const modifiedDiskQuery = electronTrpc.filesystem.readFile.useQuery( + { + workspaceId, + absolutePath: filePath, + maxBytes: MAX_SPREADSHEET_SIZE, + }, + { + retry: false, + refetchOnWindowFocus: false, + enabled: isUnstaged, + }, + ); + + const originalBase64 = originalQuery.data?.content ?? null; + const modifiedBase64 = isUnstaged + ? ((modifiedDiskQuery.data?.content as string) ?? null) + : (modifiedGitQuery.data?.content ?? null); + + const isLoading = + originalQuery.isLoading || + (isUnstaged ? modifiedDiskQuery.isLoading : modifiedGitQuery.isLoading) || + isParsing; + + useEffect(() => { + if (!originalBase64 && !modifiedBase64) return; + + let cancelled = false; + setIsParsing(true); + setParseError(null); + + Promise.all([ + originalBase64 + ? parseBase64Workbook(originalBase64) + : Promise.resolve([]), + modifiedBase64 + ? parseBase64Workbook(modifiedBase64) + : Promise.resolve([]), + ]) + .then(([origSheets, modSheets]) => { + if (!cancelled) { + setDiffSheets(buildDiffSheets(origSheets, modSheets)); + setIsParsing(false); + } + }) + .catch((err) => { + if (!cancelled) { + setParseError( + err instanceof Error ? err.message : "Failed to parse spreadsheet", + ); + setIsParsing(false); + } + }); + + return () => { + cancelled = true; + }; + }, [originalBase64, modifiedBase64]); + + const error = + originalQuery.error || modifiedGitQuery.error || modifiedDiskQuery.error + ? "Failed to load file" + : parseError; + + const debug = { + diffCategory: diffCategory ?? "undefined", + isUnstaged, + originalRef: refs.originalRef, + modifiedRef: refs.modifiedRef ?? "disk", + originalLoading: originalQuery.isLoading, + originalHasData: !!originalBase64, + originalError: originalQuery.error?.message ?? null, + modifiedLoading: isUnstaged + ? modifiedDiskQuery.isLoading + : modifiedGitQuery.isLoading, + modifiedHasData: !!modifiedBase64, + modifiedError: isUnstaged + ? (modifiedDiskQuery.error?.message ?? null) + : (modifiedGitQuery.error?.message ?? null), + sheetsCount: diffSheets.length, + isParsing, + }; + + return { diffSheets, isLoading, error, debug }; +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/TabPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/TabPane.tsx index 81658904d46..aaf37f425f7 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/TabPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/TabPane.tsx @@ -41,6 +41,7 @@ interface TabPaneProps { availableTabs: Tab[]; onMoveToTab: (targetTabId: string) => void; onMoveToNewTab: () => void; + onPopOut?: () => void; } export function TabPane({ @@ -56,6 +57,7 @@ export function TabPane({ availableTabs, onMoveToTab, onMoveToNewTab, + onPopOut, }: TabPaneProps) { const paneName = useTabsStore((s) => s.panes[paneId]?.name); const paneStatus = useTabsStore((s) => s.panes[paneId]?.status); @@ -100,6 +102,7 @@ export function TabPane({ splitPaneAuto={splitPaneAuto} removePane={removePane} setFocusedPane={setFocusedPane} + onPopOut={onPopOut} renderToolbar={(handlers) => (
@@ -123,6 +126,7 @@ export function TabPane({ onSplitPane={handlers.onSplitPane} onClosePane={handlers.onClosePane} closeHotkeyId="CLOSE_TERMINAL" + onPopOut={handlers.onPopOut} />
)} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/components/BasePaneWindow/BasePaneWindow.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/components/BasePaneWindow/BasePaneWindow.tsx index 53468f4e9f5..311bb2d713a 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/components/BasePaneWindow/BasePaneWindow.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/components/BasePaneWindow/BasePaneWindow.tsx @@ -11,6 +11,7 @@ export interface PaneHandlers { onFocus: () => void; onClosePane: (e: React.MouseEvent) => void; onSplitPane: (e: React.MouseEvent) => void; + onPopOut?: (e: React.MouseEvent) => void; splitOrientation: SplitOrientation; } @@ -38,6 +39,7 @@ interface BasePaneWindowProps { ) => void; removePane: (paneId: string) => void; setFocusedPane: (tabId: string, paneId: string) => void; + onPopOut?: () => void; renderToolbar: (handlers: PaneHandlers) => React.ReactElement; children: React.ReactNode; contentClassName?: string; @@ -50,6 +52,7 @@ export function BasePaneWindow({ splitPaneAuto, removePane, setFocusedPane, + onPopOut, renderToolbar, children, contentClassName = "w-full h-full overflow-hidden", @@ -83,10 +86,18 @@ export function BasePaneWindow({ splitPaneAuto(tabId, paneId, { width, height }, path); }; + const handlePopOut = onPopOut + ? (e: React.MouseEvent) => { + e.stopPropagation(); + onPopOut(); + } + : undefined; + const handlers: PaneHandlers = { onFocus: handleFocus, onClosePane: handleClosePane, onSplitPane: handleSplitPane, + onPopOut: handlePopOut, splitOrientation, }; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/components/PaneToolbarActions/PaneToolbarActions.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/components/PaneToolbarActions/PaneToolbarActions.tsx index 5d15fbd1ff5..46aec4ac252 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/components/PaneToolbarActions/PaneToolbarActions.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/components/PaneToolbarActions/PaneToolbarActions.tsx @@ -1,5 +1,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { HiMiniXMark } from "react-icons/hi2"; +import { LuArrowUpRight } from "react-icons/lu"; import { TbLayoutColumns, TbLayoutRows } from "react-icons/tb"; import { HotkeyTooltipContent } from "renderer/components/HotkeyTooltipContent"; import type { HotkeyId } from "shared/hotkeys"; @@ -9,6 +10,7 @@ interface PaneToolbarActionsProps { splitOrientation: SplitOrientation; onSplitPane: (e: React.MouseEvent) => void; onClosePane: (e: React.MouseEvent) => void; + onPopOut?: (e: React.MouseEvent) => void; leadingActions?: React.ReactNode; /** Hotkey ID to display for the close action. Defaults to CLOSE_PANE. */ closeHotkeyId?: HotkeyId; @@ -18,6 +20,7 @@ export function PaneToolbarActions({ splitOrientation, onSplitPane, onClosePane, + onPopOut, leadingActions, closeHotkeyId = "CLOSE_PANE", }: PaneToolbarActionsProps) { @@ -31,6 +34,23 @@ export function PaneToolbarActions({ return (
{leadingActions} + {onPopOut && ( + + + + + + Pop out to new window + + + )}