From a7816ee34eca00afdb85953522410a63c56c72bc Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Sat, 28 Mar 2026 02:09:32 +0900 Subject: [PATCH 001/816] feat(desktop): add Excel/spreadsheet file viewer with diff support Add the ability to view .xlsx, .xls, .xlsm, .xlsb, and .ods files natively instead of showing "Binary file preview not supported". Features: - Full spreadsheet rendering with ExcelJS (formatting, borders, merged cells, fonts, colors, theme colors with tint, rich text, row heights, text wrapping, vertical text) - Print area aware column/row range detection - Auto fit-to-width column scaling - Multiple sheet tab navigation - Side-by-side diff viewer with cell-level change highlighting - Diff navigation (Prev/Next) with synchronized scrolling - Git binary file read via new readGitFileBinary tRPC procedure --- apps/desktop/package.json | 1 + .../lib/trpc/routers/changes/file-contents.ts | 49 ++ .../WorkspaceFilePreviewContent.tsx | 10 + .../TabView/FileViewerPane/FileViewerPane.tsx | 4 + .../FileViewerContent/FileViewerContent.tsx | 37 +- .../SpreadsheetDiffViewer.tsx | 408 ++++++++++++++++ .../SpreadsheetViewer/SpreadsheetViewer.tsx | 266 +++++++++++ .../components/SpreadsheetViewer/index.ts | 2 + .../SpreadsheetViewer/parseWorkbook.ts | 452 ++++++++++++++++++ .../SpreadsheetViewer/useSpreadsheetData.ts | 78 +++ .../SpreadsheetViewer/useSpreadsheetDiff.ts | 316 ++++++++++++ apps/desktop/src/shared/file-types.ts | 10 + bun.lock | 131 +++++ 13 files changed, 1763 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/SpreadsheetViewer/SpreadsheetDiffViewer.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/SpreadsheetViewer/SpreadsheetViewer.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/SpreadsheetViewer/index.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/SpreadsheetViewer/parseWorkbook.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/SpreadsheetViewer/useSpreadsheetData.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/SpreadsheetViewer/useSpreadsheetDiff.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 79100913a92..acd5c2f79db 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -160,6 +160,7 @@ "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", 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/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/components/WorkspaceFilePreview/components/WorkspaceFilePreviewContent/WorkspaceFilePreviewContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/components/WorkspaceFilePreview/components/WorkspaceFilePreviewContent/WorkspaceFilePreviewContent.tsx index e293635b91a..9114892b53e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/components/WorkspaceFilePreview/components/WorkspaceFilePreviewContent/WorkspaceFilePreviewContent.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/components/WorkspaceFilePreview/components/WorkspaceFilePreviewContent/WorkspaceFilePreviewContent.tsx @@ -1,4 +1,6 @@ import { useFileDocument } from "@superset/workspace-client"; +import { SpreadsheetViewer } from "renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/SpreadsheetViewer"; +import { isSpreadsheetFile } from "shared/file-types"; interface WorkspaceFilePreviewContentProps { selectedFilePath: string; @@ -32,6 +34,14 @@ export function WorkspaceFilePreviewContent({ } if (document.state.kind === "binary") { + if (isSpreadsheetFile(selectedFilePath)) { + return ( + + ); + } return (
Binary files are not previewed yet 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..c3ed1de0ea7 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 @@ -676,6 +676,10 @@ export function FileViewerPane({ )}
+ ); + } + if (viewMode === "diff") { if (isLoadingDiff) { return ( @@ -422,6 +448,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/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..f1af2950115 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/SpreadsheetViewer/SpreadsheetDiffViewer.tsx @@ -0,0 +1,408 @@ +import { type RefObject, useCallback, useMemo, useRef, useState } from "react"; +import type { ChangeCategory } from "shared/changes-types"; +import useResizeObserver from "use-resize-observer"; +import type { ParsedCell, RichTextPart } from "./parseWorkbook"; +import { type DiffParsedRow, 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 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; + 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"; + } + + 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..359aed2f04a --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/SpreadsheetViewer/SpreadsheetViewer.tsx @@ -0,0 +1,266 @@ +import { useCallback, useMemo, useState } from "react"; +import useResizeObserver from "use-resize-observer"; +import { + type ParsedCell, + type RichTextPart, + useSpreadsheetData, +} from "./useSpreadsheetData"; + +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}; +} + +const ROW_NUM_COL_WIDTH = 36; + +export function SpreadsheetViewer({ + workspaceId, + filePath, +}: SpreadsheetViewerProps) { + const { sheets, isLoading, error } = useSpreadsheetData( + workspaceId, + filePath, + ); + const [activeSheetIndex, setActiveSheetIndex] = useState(0); + const [containerWidth, setContainerWidth] = useState(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)]; + + const scaledColumnWidths = useMemo(() => { + if (!activeSheet) return []; + const widths = activeSheet.columnWidths; + if (!containerWidth) return widths; + + const totalNatural = + ROW_NUM_COL_WIDTH + widths.reduce((sum, w) => sum + w, 0); + if (totalNatural <= containerWidth) return widths; + + const availableForCols = containerWidth - ROW_NUM_COL_WIDTH; + const colTotal = widths.reduce((sum, w) => sum + w, 0); + if (colTotal <= 0) return widths; + + return widths.map((w) => Math.floor((w / colTotal) * availableForCols)); + }, [activeSheet, containerWidth]); + + if (isLoading) { + return ( +
+ Loading spreadsheet... +
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (sheets.length === 0 || !activeSheet) { + return ( +
+ No sheets found +
+ ); + } + + return ( +
+
+ + + + {scaledColumnWidths.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", + ...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} + + +
+ {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..97d8fd8d59d --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/SpreadsheetViewer/parseWorkbook.ts @@ -0,0 +1,452 @@ +import type React from "react"; + +const MAX_ROWS = 2000; + +// ── Types ── + +type StyleObj = React.CSSProperties; + +export interface RichTextPart { + text: string; + style: StyleObj; +} + +export interface ParsedCell { + value: string; + style: StyleObj; + colSpan?: number; + rowSpan?: number; + hidden?: boolean; + wrapText?: boolean; + verticalText?: boolean; + richText?: RichTextPart[]; +} + +export interface ParsedRow { + cells: ParsedCell[]; + height: number; +} + +export interface ParsedSheet { + name: string; + rows: ParsedRow[]; + columnCount: number; + columnWidths: number[]; + truncated: boolean; +} + +// ── 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; +} + +// 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; +} + +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); + await workbook.xlsx.load(bytes.buffer as ArrayBuffer); + + const sheets: ParsedSheet[] = []; + + workbook.eachSheet((worksheet) => { + const dims = getSheetDimensions(worksheet); + const mergeMap = buildMergeMap(worksheet); + 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 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; + cells.push(parsed); + } + + rows.push({ cells, height: rowHeightToPx(row.height) }); + } + + sheets.push({ + name: worksheet.name, + rows, + columnCount: colCount, + columnWidths, + truncated, + }); + }); + + 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..c459df062b6 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/SpreadsheetViewer/useSpreadsheetData.ts @@ -0,0 +1,78 @@ +import { useEffect, useState } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import type { ParsedSheet } from "./parseWorkbook"; + +export type { + ParsedCell, + ParsedRow, + ParsedSheet, + 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..69eea1ab308 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/SpreadsheetViewer/useSpreadsheetDiff.ts @@ -0,0 +1,316 @@ +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 DiffParsedCell extends ParsedCell { + diffStatus?: "added" | "removed" | "modified"; +} + +export interface DiffParsedRow { + cells: DiffParsedCell[]; + height: number; +} + +export interface DiffParsedSheet { + name: string; + originalRows: DiffParsedRow[]; + modifiedRows: DiffParsedRow[]; + columnCount: number; + columnWidths: number[]; + sheetStatus?: "added" | "removed"; +} + +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, + }); + modCells.push({ + ...modCell, + diffStatus: changed ? "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/shared/file-types.ts b/apps/desktop/src/shared/file-types.ts index cbac5fcc766..49ec6f3c926 100644 --- a/apps/desktop/src/shared/file-types.ts +++ b/apps/desktop/src/shared/file-types.ts @@ -27,6 +27,9 @@ const IMAGE_MIME_TYPES: Record = { ico: "image/x-icon", }; +/** Spreadsheet extensions */ +const SPREADSHEET_EXTENSIONS = new Set(["xlsx", "xls", "xlsm", "xlsb", "ods"]); + /** Markdown extensions */ const MARKDOWN_EXTENSIONS = new Set(["md", "markdown", "mdx"]); @@ -60,6 +63,13 @@ export function isMarkdownFile(filePath: string): boolean { return MARKDOWN_EXTENSIONS.has(getExtension(filePath)); } +/** + * Checks if a file is a spreadsheet based on extension + */ +export function isSpreadsheetFile(filePath: string): boolean { + return SPREADSHEET_EXTENSIONS.has(getExtension(filePath)); +} + /** * Checks if a file supports rendered preview (markdown or image) */ diff --git a/bun.lock b/bun.lock index 21669df53f5..4ef17774d08 100644 --- a/bun.lock +++ b/bun.lock @@ -237,6 +237,7 @@ "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", @@ -1468,6 +1469,10 @@ "@expo/xcpretty": ["@expo/xcpretty@4.4.0", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "chalk": "^4.1.0", "js-yaml": "^4.1.0" }, "bin": { "excpretty": "build/cli.js" } }, "sha512-o2qDlTqJ606h4xR36H2zWTywmZ3v3842K6TU8Ik2n1mfW0S580VHlt3eItVYdLYz+klaPp7CXqanja8eASZjRw=="], + "@fast-csv/format": ["@fast-csv/format@4.3.5", "", { "dependencies": { "@types/node": "^14.0.1", "lodash.escaperegexp": "^4.1.2", "lodash.isboolean": "^3.0.3", "lodash.isequal": "^4.5.0", "lodash.isfunction": "^3.0.9", "lodash.isnil": "^4.0.0" } }, "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A=="], + + "@fast-csv/parse": ["@fast-csv/parse@4.3.6", "", { "dependencies": { "@types/node": "^14.0.1", "lodash.escaperegexp": "^4.1.2", "lodash.groupby": "^4.6.0", "lodash.isfunction": "^3.0.9", "lodash.isnil": "^4.0.0", "lodash.isundefined": "^3.0.1", "lodash.uniq": "^4.5.0" } }, "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA=="], + "@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="], "@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="], @@ -2928,6 +2933,10 @@ "app-builder-lib": ["app-builder-lib@26.4.0", "", { "dependencies": { "@develar/schema-utils": "~2.6.5", "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "4.0.1", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@types/fs-extra": "9.0.13", "async-exit-hook": "^2.0.1", "builder-util": "26.3.4", "builder-util-runtime": "9.5.1", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "26.3.4", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.0.3", "plist": "3.1.0", "resedit": "^1.7.0", "semver": "~7.7.3", "tar": "^6.1.12", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.4.0", "electron-builder-squirrel-windows": "26.4.0" } }, "sha512-Uas6hNe99KzP3xPWxh5LGlH8kWIVjZixzmMJHNB9+6hPyDpjc7NQMkVgi16rQDdpCFy22ZU5sp8ow7tvjeMgYQ=="], + "archiver": ["archiver@5.3.2", "", { "dependencies": { "archiver-utils": "^2.1.0", "async": "^3.2.4", "buffer-crc32": "^0.2.1", "readable-stream": "^3.6.0", "readdir-glob": "^1.1.2", "tar-stream": "^2.2.0", "zip-stream": "^4.1.0" } }, "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw=="], + + "archiver-utils": ["archiver-utils@2.1.0", "", { "dependencies": { "glob": "^7.1.4", "graceful-fs": "^4.2.0", "lazystream": "^1.0.0", "lodash.defaults": "^4.2.0", "lodash.difference": "^4.5.0", "lodash.flatten": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.union": "^4.6.0", "normalize-path": "^3.0.0", "readable-stream": "^2.0.0" } }, "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw=="], + "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], @@ -3044,6 +3053,8 @@ "big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="], + "binary": ["binary@0.3.0", "", { "dependencies": { "buffers": "~0.1.1", "chainsaw": "~0.1.0" } }, "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg=="], + "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], @@ -3052,6 +3063,8 @@ "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], + "bluebird": ["bluebird@3.4.7", "", {}, "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA=="], + "blueimp-md5": ["blueimp-md5@2.19.0", "", {}, "sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w=="], "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], @@ -3078,6 +3091,10 @@ "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + "buffer-indexof-polyfill": ["buffer-indexof-polyfill@1.0.2", "", {}, "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A=="], + + "buffers": ["buffers@0.1.1", "", {}, "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ=="], + "bufferutil": ["bufferutil@4.1.0", "", { "dependencies": { "node-gyp-build": "^4.3.0" } }, "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw=="], "builder-util": ["builder-util@26.3.4", "", { "dependencies": { "7zip-bin": "~5.2.0", "@types/debug": "^4.1.6", "app-builder-bin": "5.0.0-alpha.12", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "js-yaml": "^4.1.0", "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" } }, "sha512-aRn88mYMktHxzdqDMF6Ayj0rKoX+ZogJ75Ck7RrIqbY/ad0HBvnS2xA4uHfzrGr5D2aLL3vU6OBEH4p0KMV2XQ=="], @@ -3114,6 +3131,8 @@ "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "chainsaw": ["chainsaw@0.1.0", "", { "dependencies": { "traverse": ">=0.3.0 <0.4" } }, "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ=="], + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], @@ -3210,6 +3229,8 @@ "compare-version": ["compare-version@0.1.2", "", {}, "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A=="], + "compress-commons": ["compress-commons@4.1.2", "", { "dependencies": { "buffer-crc32": "^0.2.13", "crc32-stream": "^4.0.2", "normalize-path": "^3.0.0", "readable-stream": "^3.6.0" } }, "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg=="], + "compressible": ["compressible@2.0.18", "", { "dependencies": { "mime-db": ">= 1.43.0 < 2" } }, "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg=="], "compression": ["compression@1.8.1", "", { "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "negotiator": "~0.6.4", "on-headers": "~1.1.0", "safe-buffer": "5.2.1", "vary": "~1.1.2" } }, "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w=="], @@ -3256,6 +3277,10 @@ "crc": ["crc@3.8.0", "", { "dependencies": { "buffer": "^5.1.0" } }, "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ=="], + "crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="], + + "crc32-stream": ["crc32-stream@4.0.3", "", { "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^3.4.0" } }, "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw=="], + "crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="], "cross-dirname": ["cross-dirname@0.1.0", "", {}, "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q=="], @@ -3472,6 +3497,8 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "duplexer2": ["duplexer2@0.1.4", "", { "dependencies": { "readable-stream": "^2.0.2" } }, "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA=="], + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], @@ -3598,6 +3625,8 @@ "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], + "exceljs": ["exceljs@4.4.0", "", { "dependencies": { "archiver": "^5.0.0", "dayjs": "^1.8.34", "fast-csv": "^4.3.1", "jszip": "^3.10.1", "readable-stream": "^3.6.0", "saxes": "^5.0.1", "tmp": "^0.2.0", "unzipper": "^0.10.11", "uuid": "^8.3.0" } }, "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg=="], + "exec-async": ["exec-async@2.2.0", "", {}, "sha512-87OpwcEiMia/DeiKFzaQNBNFeN3XkkpYIh9FyOqq5mS2oKv3CBE67PXoEKcr6nodWdXNogTiQ0jE2NGuoffXPw=="], "execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], @@ -3690,6 +3719,8 @@ "fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="], + "fast-csv": ["fast-csv@4.3.6", "", { "dependencies": { "@fast-csv/format": "4.3.5", "@fast-csv/parse": "4.3.6" } }, "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-diff": ["fast-diff@1.3.0", "", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="], @@ -3776,6 +3807,8 @@ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "fstream": ["fstream@1.0.12", "", { "dependencies": { "graceful-fs": "^4.1.2", "inherits": "~2.0.0", "mkdirp": ">=0.5 0", "rimraf": "2" } }, "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg=="], + "fumadocs-core": ["fumadocs-core@16.4.7", "", { "dependencies": { "@formatjs/intl-localematcher": "^0.7.5", "@orama/orama": "^3.1.18", "@shikijs/rehype": "^3.21.0", "@shikijs/transformers": "^3.21.0", "estree-util-value-to-estree": "^3.5.0", "github-slugger": "^2.0.0", "hast-util-to-estree": "^3.1.3", "hast-util-to-jsx-runtime": "^2.3.6", "image-size": "^2.0.2", "negotiator": "^1.0.0", "npm-to-yarn": "^3.0.1", "path-to-regexp": "^8.3.0", "remark": "^15.0.1", "remark-gfm": "^4.0.1", "remark-rehype": "^11.1.2", "scroll-into-view-if-needed": "^3.1.0", "shiki": "^3.21.0", "tinyglobby": "^0.2.15", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "@mixedbread/sdk": "^0.46.0", "@orama/core": "1.x.x", "@oramacloud/client": "2.x.x", "@tanstack/react-router": "1.x.x", "@types/react": "*", "algoliasearch": "5.x.x", "lucide-react": "*", "next": "16.x.x", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router": "7.x.x", "waku": "^0.26.0 || ^0.27.0", "zod": "4.x.x" }, "optionalPeers": ["@mixedbread/sdk", "@orama/core", "@oramacloud/client", "@tanstack/react-router", "@types/react", "algoliasearch", "lucide-react", "next", "react", "react-dom", "react-router", "waku", "zod"] }, "sha512-oEsoha5EjyQnhRb6s5tNYEM+AiDA4BN80RyevRohsKPXGRQ2K3ddMaFAQq5kBaqA/Xxb+vqrElyRtzmdif7w2A=="], "fumadocs-mdx": ["fumadocs-mdx@14.2.5", "", { "dependencies": { "@mdx-js/mdx": "^3.1.1", "@standard-schema/spec": "^1.1.0", "chokidar": "^5.0.0", "esbuild": "^0.27.2", "estree-util-value-to-estree": "^3.5.0", "js-yaml": "^4.1.1", "mdast-util-to-markdown": "^2.1.2", "picocolors": "^1.1.1", "picomatch": "^4.0.3", "remark-mdx": "^3.1.1", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.3", "zod": "^4.3.5" }, "peerDependencies": { "@fumadocs/mdx-remote": "^1.4.0", "@types/react": "*", "fumadocs-core": "^15.0.0 || ^16.0.0", "next": "^15.3.0 || ^16.0.0", "react": "*", "vite": "6.x.x || 7.x.x" }, "optionalPeers": ["@fumadocs/mdx-remote", "@types/react", "next", "react", "vite"], "bin": { "fumadocs-mdx": "dist/bin.js" } }, "sha512-1WJeJ1Xago2lRq6GhTvTb+hxDtWUBr7lHi4YgHNBYSpWKsTfOor3UxgZV1UYBrd32cq4xHdtMK33LM67gA0eBA=="], @@ -4032,6 +4065,8 @@ "is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + "isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="], "isbot": ["isbot@5.1.33", "", {}, "sha512-P4Hgb5NqswjkI0J1CM6XKXon/sxKY1SuowE7Qx2hrBhIwICFyXy54mfgB5eMHXsbe/eStzzpbIGNOvGmz+dlKg=="], @@ -4106,6 +4141,8 @@ "jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], + "jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="], + "katex": ["katex@0.16.28", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-YHzO7721WbmAL6Ov1uzN/l5mY5WWWhJBSW+jq4tkfZfsxmo1hu6frS0EOswvjBUnWE6NtjEs48SFn5CQESRLZg=="], "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], @@ -4130,6 +4167,8 @@ "lazy-val": ["lazy-val@1.0.5", "", {}, "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q=="], + "lazystream": ["lazystream@1.0.1", "", { "dependencies": { "readable-stream": "^2.0.5" } }, "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw=="], + "lcid": ["lcid@3.1.1", "", { "dependencies": { "invert-kv": "^3.0.0" } }, "sha512-M6T051+5QCGLBQb8id3hdvIW8+zeFV2FyBGFS9IEK5H9Wt4MueD4bW1eWikpHgZp+5xR3l5c8pZUkQsIA0BFZg=="], "leac": ["leac@0.6.0", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="], @@ -4174,6 +4213,8 @@ "linkifyjs": ["linkifyjs@4.3.2", "", {}, "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA=="], + "listenercount": ["listenercount@1.0.1", "", {}, "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ=="], + "load-json-file": ["load-json-file@7.0.1", "", {}, "sha512-Gnxj3ev3mB5TkVBGad0JM6dmLiQL+o0t23JPBZ9sd+yvSLk05mFoqKBw5N8gbbkU4TNXyqCgIrl/VM17OgUIgQ=="], "loader-runner": ["loader-runner@4.3.1", "", {}, "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q=="], @@ -4188,14 +4229,36 @@ "lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="], + "lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="], + + "lodash.difference": ["lodash.difference@4.5.0", "", {}, "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA=="], + "lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="], + "lodash.flatten": ["lodash.flatten@4.4.0", "", {}, "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g=="], + + "lodash.groupby": ["lodash.groupby@4.6.0", "", {}, "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw=="], + + "lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="], + "lodash.isequal": ["lodash.isequal@4.5.0", "", {}, "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="], + "lodash.isfunction": ["lodash.isfunction@3.0.9", "", {}, "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw=="], + + "lodash.isnil": ["lodash.isnil@4.0.0", "", {}, "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng=="], + + "lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="], + + "lodash.isundefined": ["lodash.isundefined@3.0.1", "", {}, "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA=="], + "lodash.samplesize": ["lodash.samplesize@4.2.0", "", {}, "sha512-1ZhKV7/nuISuaQdxfCqrs4HHxXIYN+0Z4f7NMQn2PHkxFZJGavJQ1j/paxyJnLJmN2ZamNN6SMepneV+dCgQTA=="], "lodash.throttle": ["lodash.throttle@4.1.1", "", {}, "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ=="], + "lodash.union": ["lodash.union@4.6.0", "", {}, "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw=="], + + "lodash.uniq": ["lodash.uniq@4.5.0", "", {}, "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ=="], + "log-symbols": ["log-symbols@7.0.1", "", { "dependencies": { "is-unicode-supported": "^2.0.0", "yoctocolors": "^2.1.1" } }, "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg=="], "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], @@ -4588,6 +4651,8 @@ "package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], + "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], @@ -4712,6 +4777,8 @@ "proc-log": ["proc-log@4.2.0", "", {}, "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA=="], + "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], "promise": ["promise@8.3.0", "", { "dependencies": { "asap": "~2.0.6" } }, "sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg=="], @@ -4884,6 +4951,8 @@ "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "readdir-glob": ["readdir-glob@1.1.3", "", { "dependencies": { "minimatch": "^5.1.0" } }, "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA=="], + "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], @@ -5036,6 +5105,8 @@ "sax": ["sax@1.4.4", "", {}, "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw=="], + "saxes": ["saxes@5.0.1", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="], @@ -5068,6 +5139,8 @@ "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], + "setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="], + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], "sf-symbols-typescript": ["sf-symbols-typescript@2.2.0", "", {}, "sha512-TPbeg0b7ylrswdGCji8FRGFAKuqbpQlLbL8SOle3j1iHSs5Ob5mhvMAxWN2UItOjgALAB5Zp3fmMfj8mbWvXKw=="], @@ -5350,6 +5423,8 @@ "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + "traverse": ["traverse@0.3.9", "", {}, "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ=="], + "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], @@ -5476,6 +5551,8 @@ "unplugin": ["unplugin@1.0.1", "", { "dependencies": { "acorn": "^8.8.1", "chokidar": "^3.5.3", "webpack-sources": "^3.2.3", "webpack-virtual-modules": "^0.5.0" } }, "sha512-aqrHaVBWW1JVKBHmGo33T5TxeL0qWzfvjWokObHA9bYmN7eNDkwOxmLjhioHl9878qDFMAaT51XNroRyuz7WxA=="], + "unzipper": ["unzipper@0.10.14", "", { "dependencies": { "big-integer": "^1.6.17", "binary": "~0.3.0", "bluebird": "~3.4.1", "buffer-indexof-polyfill": "~1.0.0", "duplexer2": "~0.1.4", "fstream": "^1.0.12", "graceful-fs": "^4.2.2", "listenercount": "~1.0.1", "readable-stream": "~2.3.6", "setimmediate": "~1.0.4" } }, "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g=="], + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], @@ -5614,6 +5691,8 @@ "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], "xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="], @@ -5638,6 +5717,8 @@ "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], + "zip-stream": ["zip-stream@4.1.1", "", { "dependencies": { "archiver-utils": "^3.0.4", "compress-commons": "^4.1.2", "readable-stream": "^3.6.0" } }, "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ=="], + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "zod-from-json-schema": ["zod-from-json-schema@0.5.2", "", { "dependencies": { "zod": "^4.0.17" } }, "sha512-/dNaicfdhJTOuUd4RImbLUE2g5yrSzzDjI/S6C2vO2ecAGZzn9UcRVgtyLSnENSmAOBRiSpUdzDS6fDWX3Z35g=="], @@ -5814,6 +5895,10 @@ "@expo/xcpretty/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "@fast-csv/format/@types/node": ["@types/node@14.18.63", "", {}, "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ=="], + + "@fast-csv/parse/@types/node": ["@types/node@14.18.63", "", {}, "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ=="], + "@fumadocs/ui/postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="], "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], @@ -6010,6 +6095,10 @@ "app-builder-lib/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], + "archiver-utils/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + + "archiver-utils/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "ava/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "ava/ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], @@ -6108,6 +6197,8 @@ "dotenv-expand/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + "duplexer2/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "electron-builder/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "electron-publish/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -6130,6 +6221,8 @@ "estree-util-build-jsx/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "exceljs/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], + "execa/is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], "expo/expo-asset": ["expo-asset@55.0.4", "", { "dependencies": { "@expo/image-utils": "^0.8.12", "expo-constants": "~55.0.4" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-eaPPe9Sw4V0nL/KkEvuWpyeeSoGhP2fu1ZA7wkldqywhMVhhY+7kerMkQ7nPgJVtevIfkQRw7wD8ghZEzrKzmg=="], @@ -6212,6 +6305,8 @@ "fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + "fstream/rimraf": ["rimraf@2.6.3", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA=="], + "fumadocs-mdx/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], "fumadocs-mdx/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], @@ -6260,12 +6355,16 @@ "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + "jszip/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], "launch-ide/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "launch-ide/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + "lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "libsql/detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="], "lighthouse-logger/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], @@ -6430,6 +6529,8 @@ "react-syntax-highlighter/lowlight": ["lowlight@1.20.0", "", { "dependencies": { "fault": "^1.0.0", "highlight.js": "~10.7.0" } }, "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw=="], + "readdir-glob/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="], + "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], @@ -6520,6 +6621,8 @@ "uniwind/@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.17", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.17", "@tailwindcss/oxide-darwin-arm64": "4.1.17", "@tailwindcss/oxide-darwin-x64": "4.1.17", "@tailwindcss/oxide-freebsd-x64": "4.1.17", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17", "@tailwindcss/oxide-linux-arm64-musl": "4.1.17", "@tailwindcss/oxide-linux-x64-gnu": "4.1.17", "@tailwindcss/oxide-linux-x64-musl": "4.1.17", "@tailwindcss/oxide-wasm32-wasi": "4.1.17", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17", "@tailwindcss/oxide-win32-x64-msvc": "4.1.17" } }, "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA=="], + "unzipper/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "use-resize-observer/react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="], "use-resize-observer/react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="], @@ -6552,6 +6655,8 @@ "youch/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + "zip-stream/archiver-utils": ["archiver-utils@3.0.4", "", { "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", "lazystream": "^1.0.0", "lodash.defaults": "^4.2.0", "lodash.difference": "^4.5.0", "lodash.flatten": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.union": "^4.6.0", "normalize-path": "^3.0.0", "readable-stream": "^3.6.0" } }, "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw=="], + "zod-from-json-schema-v3/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@a2a-js/sdk/@types/express/@types/express-serve-static-core": ["@types/express-serve-static-core@4.19.8", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA=="], @@ -6956,6 +7061,10 @@ "app-builder-lib/which/isexe": ["isexe@3.1.1", "", {}, "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ=="], + "archiver-utils/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + + "archiver-utils/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + "ava/figures/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "ava/figures/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], @@ -7004,6 +7113,8 @@ "dmg-license/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + "duplexer2/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + "electron-winstaller/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], "electron-winstaller/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], @@ -7646,6 +7757,8 @@ "friendly-words/express/type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="], + "fstream/rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "fumadocs-mdx/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], "fumadocs-mdx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="], @@ -7710,6 +7823,10 @@ "jest-haste-map/jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + "jszip/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + + "lazystream/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + "lighthouse-logger/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "metro-babel-transformer/hermes-parser/hermes-estree": ["hermes-estree@0.32.0", "", {}, "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ=="], @@ -7868,6 +7985,8 @@ "uniwind/@tailwindcss/oxide/@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.17", "", { "os": "win32", "cpu": "x64" }, "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw=="], + "unzipper/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + "use-resize-observer/react-dom/scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="], @@ -7980,6 +8099,8 @@ "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "zip-stream/archiver-utils/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "@a2a-js/sdk/@types/express/@types/serve-static/@types/send": ["@types/send@0.17.6", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og=="], "@a2a-js/sdk/express/accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], @@ -8686,6 +8807,8 @@ "@uiw/react-markdown-preview/rehype-prism-plus/refractor/hastscript": ["hastscript@7.2.0", "", { "dependencies": { "@types/hast": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^3.0.0", "property-information": "^6.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-TtYPq24IldU8iKoJQqvZOuhi5CyCQRAbvDOX0x1eW6rsHSxa/1i2CCiptNTotGHJ3VoHRGmqiv6/D3q113ikkw=="], + "archiver-utils/glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + "chromium-edge-launcher/rimraf/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "cli-highlight/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -10714,6 +10837,8 @@ "friendly-words/express/type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "fstream/rimraf/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "pkg-conf/find-up/locate-path/p-locate": ["p-locate@6.0.0", "", { "dependencies": { "p-limit": "^4.0.0" } }, "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw=="], "react-native/babel-plugin-syntax-hermes-parser/hermes-parser/hermes-estree": ["hermes-estree@0.32.0", "", {}, "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ=="], @@ -10742,6 +10867,8 @@ "uniwind/@tailwindcss/oxide/@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "zip-stream/archiver-utils/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "@a2a-js/sdk/express/accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "@a2a-js/sdk/express/type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], @@ -13538,10 +13665,14 @@ "friendly-words/express/type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "fstream/rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + "pkg-conf/find-up/locate-path/p-locate/p-limit": ["p-limit@4.0.0", "", { "dependencies": { "yocto-queue": "^1.0.0" } }, "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ=="], "temp/rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + "zip-stream/archiver-utils/glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + "@expo/cli/expo/@expo/cli/@expo/env/dotenv-expand/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], "@expo/cli/expo/babel-preset-expo/@react-native/babel-preset/@react-native/babel-plugin-codegen/@react-native/codegen": ["@react-native/codegen@0.81.5", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/parser": "^7.25.3", "glob": "^7.1.1", "hermes-parser": "0.29.1", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "yargs": "^17.6.2" } }, "sha512-a2TDA03Up8lpSa9sh5VRGCQDXgCTOyDOFH+aqyinxp1HChG8uk89/G+nkJ9FPd0rqgi25eCTR16TWdS3b+fA6g=="], From 1e2cfeab3bfe885357aea97908f0ceca6e37b4b5 Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Sat, 28 Mar 2026 03:37:08 +0900 Subject: [PATCH 002/816] fix(desktop): prevent browser webview reload on tab switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Electron's tag reloads its content whenever the element is reparented in the DOM. The previous approach rendered only the active tab, causing BrowserPane to unmount on every tab switch and park the webview in a hidden container (DOM reparent) — triggering a hard reload. - Add PersistentTabRenderer that keeps all workspace tabs mounted and hides inactive ones via off-screen positioning (not display:none, which stops Electron's compositor) - Wrap each webview in a persistent wrapper div so the webview's parentNode never changes during park/reclaim cycles - Use unique Mosaic IDs per tab to prevent drag-drop conflicts between simultaneously mounted Mosaic instances --- .../TabsContent/PersistentTabRenderer.tsx | 53 ++++++++++++++++++ .../usePersistentWebview.ts | 54 +++++++++++++++---- .../ContentView/TabsContent/TabView/index.tsx | 2 +- .../ContentView/TabsContent/index.tsx | 24 +++++---- 4 files changed, 114 insertions(+), 19 deletions(-) create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/PersistentTabRenderer.tsx 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..2a106ae757e --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/PersistentTabRenderer.tsx @@ -0,0 +1,53 @@ +import type { Tab } from "renderer/stores/tabs/types"; +import { TabView } from "./TabView"; + +interface PersistentTabRendererProps { + tabs: Tab[]; + activeTabId: string | null; +} + +/** + * Renders all workspace tabs simultaneously, hiding inactive ones with CSS. + * + * Electron's tag reloads its content whenever it is reparented in the + * DOM (moved from one parent element to another). The previous approach rendered + * only the active tab, which caused BrowserPane to unmount on every tab switch + * and park the webview in a hidden container (DOM reparent) — triggering a hard + * reload each time the user switched back. + * + * By keeping every tab mounted and toggling visibility via `display`, webview + * elements stay in their original DOM parent and never reparent, eliminating the + * reload. + */ +export function PersistentTabRenderer({ + tabs, + activeTabId, +}: PersistentTabRendererProps) { + return ( + <> + {tabs.map((tab) => { + const isActive = tab.id === activeTabId; + return ( +
+ +
+ ); + })} + + ); +} 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..b25e889763c 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 @@ -7,6 +7,20 @@ import { useTabsStore } from "renderer/stores/tabs/store"; // --------------------------------------------------------------------------- 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 +72,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(); @@ -171,19 +190,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 +220,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,7 +237,7 @@ 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 }); @@ -340,7 +370,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 +397,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/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx index 7dd16399845..bf03841d4ea 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx @@ -295,7 +295,7 @@ export function TabView({ tab }: TabViewProps) { return (
- mosaicId={MOSAIC_ID} + mosaicId={`${MOSAIC_ID}-${tab.id}`} renderTile={renderPane} value={cleanedLayout} onChange={handleLayoutChange} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx index 77ecd4c0183..8b0bab01c77 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx @@ -1,23 +1,23 @@ import type { ExternalApp } from "@superset/local-db"; -import { useParams } from "@tanstack/react-router"; import { useEffect, useMemo, useRef } from "react"; import { useTabsStore } from "renderer/stores/tabs/store"; import { resolveActiveTabIdForWorkspace } from "renderer/stores/tabs/utils"; import { EmptyTabView } from "./EmptyTabView"; -import { TabView } from "./TabView"; +import { PersistentTabRenderer } from "./PersistentTabRenderer"; interface TabsContentProps { + workspaceId: string; defaultExternalApp?: ExternalApp | null; onOpenInApp: () => void; onOpenQuickOpen: () => void; } export function TabsContent({ + workspaceId: activeWorkspaceId, defaultExternalApp, onOpenInApp, onOpenQuickOpen, }: TabsContentProps) { - const { workspaceId: activeWorkspaceId } = useParams({ strict: false }); const allTabs = useTabsStore((s) => s.tabs); const activeTabIds = useTabsStore((s) => s.activeTabIds); const tabHistoryStacks = useTabsStore((s) => s.tabHistoryStacks); @@ -47,10 +47,13 @@ export function TabsContent({ return resolvedActiveTabId; }, [activeWorkspaceId, activeTabIds, allTabs, tabHistoryStacks]); - const tabToRender = useMemo(() => { - if (!activeTabId) return null; - return allTabs.find((tab) => tab.id === activeTabId) || null; - }, [activeTabId, allTabs]); + const workspaceTabs = useMemo( + () => + activeWorkspaceId + ? allTabs.filter((t) => t.workspaceId === activeWorkspaceId) + : [], + [activeWorkspaceId, allTabs], + ); useEffect(() => { const nextWorkspaceId = activeWorkspaceId ?? null; @@ -89,8 +92,11 @@ export function TabsContent({ return (
- {tabToRender ? ( - + {workspaceTabs.length > 0 ? ( + ) : ( Date: Sat, 28 Mar 2026 03:37:22 +0900 Subject: [PATCH 003/816] fix(desktop): prevent browser webview reload on workspace switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep workspace pages mounted across workspace switches so that webview elements are never removed from the DOM. - Add KeepAliveWorkspaces component that replaces for workspace routes, rendering all visited workspaces simultaneously and hiding inactive ones off-screen - Make WorkspacePage accept workspaceId as a prop override so it works outside the router's matched-route context - Thread workspaceId through WorkspaceLayout → ContentView → TabsContent via props instead of useParams(), preventing hidden workspaces from reading the wrong workspace ID from the active route --- .../components/KeepAliveWorkspaces.tsx | 65 +++++++++++++++++++ .../_authenticated/_dashboard/layout.tsx | 4 +- .../workspace/$workspaceId/page.tsx | 33 ++++++++-- .../WorkspaceView/ContentView/index.tsx | 3 + .../WorkspaceLayout/WorkspaceLayout.tsx | 3 + 5 files changed, 99 insertions(+), 9 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/KeepAliveWorkspaces.tsx 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..591365ca870 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/KeepAliveWorkspaces.tsx @@ -0,0 +1,65 @@ +import { Outlet, useMatchRoute } from "@tanstack/react-router"; +import { useEffect, useRef, useState } from "react"; +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 + * . + */ +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]); + + // 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 4be47ca9183..fe00ceed508 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx @@ -1,7 +1,6 @@ import { FEATURE_FLAGS } from "@superset/shared/constants"; import { createFileRoute, - Outlet, useMatchRoute, useNavigate, } from "@tanstack/react-router"; @@ -18,6 +17,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")({ @@ -123,7 +123,7 @@ function DashboardLayout() { )}
- +
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 3fc0082907b..dc1044a4b97 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"; @@ -86,8 +92,13 @@ export const Route = createFileRoute( }, }); -function WorkspacePage() { - const { workspaceId } = Route.useParams(); +export function WorkspacePage({ + workspaceIdOverride, +}: { workspaceIdOverride?: string } = {}) { + const routeParams = useParams({ strict: false }) as { + workspaceId?: string; + }; + const workspaceId = workspaceIdOverride ?? routeParams.workspaceId ?? ""; const { data: workspace } = electronTrpc.workspaces.get.useQuery({ id: workspaceId, }); @@ -102,8 +113,10 @@ function WorkspacePage() { enabled: Boolean(workspace?.worktreePath), }); const navigate = useNavigate(); - const routeNavigate = Route.useNavigate(); - const { tabId: searchTabId, paneId: searchPaneId } = Route.useSearch(); + const genericNavigate = useNavigate(); + 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(); @@ -124,8 +137,13 @@ function WorkspacePage() { state.setFocusedPane(searchTabId, searchPaneId); } - routeNavigate({ search: {}, replace: true }); - }, [searchTabId, searchPaneId, workspaceId, routeNavigate]); + genericNavigate({ + to: "/workspace/$workspaceId", + params: { workspaceId }, + search: {}, + replace: true, + }); + }, [searchTabId, searchPaneId, workspaceId, genericNavigate]); // Check if workspace is initializing or failed const isInitializing = useIsWorkspaceInitializing(workspaceId); @@ -646,6 +664,7 @@ function WorkspacePage() { /> ) : ( void; onOpenQuickOpen: () => void; } export function ContentView({ + workspaceId, defaultExternalApp, onOpenInApp, onOpenQuickOpen, @@ -31,6 +33,7 @@ export function ContentView({ {showPresetsBar && } void; onOpenQuickOpen: () => void; } export function WorkspaceLayout({ + workspaceId, defaultExternalApp, onOpenInApp, onOpenQuickOpen, @@ -40,6 +42,7 @@ export function WorkspaceLayout({ ) : ( Date: Sat, 28 Mar 2026 04:10:02 +0900 Subject: [PATCH 004/816] fix(desktop): fix workspace context leaks and hotkey conflicts in keep-alive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When multiple WorkspacePage instances are mounted simultaneously (KeepAliveWorkspaces), several issues occurred: 1. Components using useParams() read the ACTIVE workspace's ID instead of their own — causing wrong files, wrong sidebar data, and wrong tab lists. Fixed by introducing WorkspaceIdContext and replacing useParams() with useWorkspaceId() in 9 affected components. 2. Mosaic drag-drop was broken because GroupItem used a static mosaicId that no longer matched the per-tab dynamic ID. Fixed by computing the active tab's mosaicId dynamically. 3. All workspace hotkeys fired for every mounted workspace. Fixed by passing `enabled: isActive` to all useAppHotkey calls so only the active workspace responds to keyboard shortcuts. --- .../components/KeepAliveWorkspaces.tsx | 2 +- .../workspace/$workspaceId/page.tsx | 83 ++++++++++--------- .../ChangesContent/ChangesContent.tsx | 4 +- .../FileDiffSection/FileDiffSection.tsx | 4 +- .../ContentView/TabsContent/EmptyTabView.tsx | 6 +- .../TabsContent/GroupStrip/GroupItem.tsx | 4 +- .../TabsContent/GroupStrip/GroupStrip.tsx | 5 +- .../TabView/FileViewerPane/FileViewerPane.tsx | 4 +- .../components/PresetsBar/PresetsBar.tsx | 5 +- .../RightSidebar/ChangesView/ChangesView.tsx | 4 +- .../RightSidebar/FilesView/FilesView.tsx | 4 +- .../WorkspaceView/RightSidebar/index.tsx | 4 +- .../WorkspaceView/WorkspaceIdContext.tsx | 21 +++++ 13 files changed, 89 insertions(+), 61 deletions(-) create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceIdContext.tsx diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/KeepAliveWorkspaces.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/KeepAliveWorkspaces.tsx index 591365ca870..62aac61bec1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/KeepAliveWorkspaces.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/KeepAliveWorkspaces.tsx @@ -56,7 +56,7 @@ export function KeepAliveWorkspaces() { } } > - +
); })} 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 dc1044a4b97..ba38490af1b 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 @@ -30,6 +30,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 { @@ -94,7 +95,8 @@ export const Route = createFileRoute( export function WorkspacePage({ workspaceIdOverride, -}: { workspaceIdOverride?: string } = {}) { + isActive = true, +}: { workspaceIdOverride?: string; isActive?: boolean } = {}) { const routeParams = useParams({ strict: false }) as { workspaceId?: string; }; @@ -231,11 +233,12 @@ export 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, ]); @@ -246,16 +249,16 @@ export 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, ]); @@ -266,7 +269,7 @@ export function WorkspacePage({ requestPaneClose(focusedPaneId); } }, - undefined, + hotkeyOptions, [focusedPaneId], ); useAppHotkey( @@ -276,7 +279,7 @@ export function WorkspacePage({ requestTabClose(activeTabId); } }, - undefined, + hotkeyOptions, [activeTabId], ); @@ -288,7 +291,7 @@ export function WorkspacePage({ const prevIndex = index <= 0 ? tabs.length - 1 : index - 1; setActiveTab(workspaceId, tabs[prevIndex].id); }, - undefined, + hotkeyOptions, [workspaceId, activeTabId, tabs, setActiveTab], ); @@ -301,7 +304,7 @@ export function WorkspacePage({ index >= tabs.length - 1 || index === -1 ? 0 : index + 1; setActiveTab(workspaceId, tabs[nextIndex].id); }, - undefined, + hotkeyOptions, [workspaceId, activeTabId, tabs, setActiveTab], ); @@ -313,7 +316,7 @@ export function WorkspacePage({ const prevIndex = index <= 0 ? tabs.length - 1 : index - 1; setActiveTab(workspaceId, tabs[prevIndex].id); }, - undefined, + hotkeyOptions, [workspaceId, activeTabId, tabs, setActiveTab], ); @@ -326,7 +329,7 @@ export function WorkspacePage({ index >= tabs.length - 1 || index === -1 ? 0 : index + 1; setActiveTab(workspaceId, tabs[nextIndex].id); }, - undefined, + hotkeyOptions, [workspaceId, activeTabId, tabs, setActiveTab], ); @@ -340,15 +343,15 @@ export 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", @@ -359,7 +362,7 @@ export function WorkspacePage({ setFocusedPane(activeTabId, prevPaneId); } }, - undefined, + hotkeyOptions, [activeTabId, activeTab?.layout, focusedPaneId, setFocusedPane], ); @@ -372,7 +375,7 @@ export function WorkspacePage({ setFocusedPane(activeTabId, nextPaneId); } }, - undefined, + hotkeyOptions, [activeTabId, activeTab?.layout, focusedPaneId, setFocusedPane], ); @@ -401,7 +404,7 @@ export 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(); @@ -412,7 +415,7 @@ export function WorkspacePage({ copyToClipboard(workspace.worktreePath); } }, - undefined, + hotkeyOptions, [workspace?.worktreePath], ); @@ -430,7 +433,7 @@ export function WorkspacePage({ createOrOpenPR(); } }, - undefined, + hotkeyOptions, [pr?.url, createOrOpenPR], ); @@ -449,13 +452,13 @@ export function WorkspacePage({ commandPalette.handleOpenChange(false); keywordSearch.toggle(); }, [commandPalette.handleOpenChange, keywordSearch.toggle]); - useAppHotkey("QUICK_OPEN", handleQuickOpen, undefined, [handleQuickOpen]); - useAppHotkey("KEYWORD_SEARCH", handleKeywordSearch, undefined, [ + useAppHotkey("QUICK_OPEN", handleQuickOpen, hotkeyOptions, [handleQuickOpen]); + useAppHotkey("KEYWORD_SEARCH", handleKeywordSearch, hotkeyOptions, [ handleKeywordSearch, ]); // Toggle changes sidebar (⌘L) - useAppHotkey("TOGGLE_SIDEBAR", () => toggleSidebar(), undefined, [ + useAppHotkey("TOGGLE_SIDEBAR", () => toggleSidebar(), hotkeyOptions, [ toggleSidebar, ]); @@ -471,7 +474,7 @@ export function WorkspacePage({ setSidebarMode(isExpanded ? SidebarMode.Tabs : SidebarMode.Changes); } }, - undefined, + hotkeyOptions, [isSidebarOpen, setSidebarOpen, setSidebarMode, currentSidebarMode], ); @@ -506,7 +509,7 @@ export function WorkspacePage({ } } }, - undefined, + hotkeyOptions, [activeTabId, focusedPaneId, activeTab, splitPaneAuto, resolveSplitTarget], ); @@ -523,7 +526,7 @@ export function WorkspacePage({ splitPaneVertical(activeTabId, target.paneId, target.path); } }, - undefined, + hotkeyOptions, [ activeTabId, focusedPaneId, @@ -546,7 +549,7 @@ export function WorkspacePage({ splitPaneHorizontal(activeTabId, target.paneId, target.path); } }, - undefined, + hotkeyOptions, [ activeTabId, focusedPaneId, @@ -571,7 +574,7 @@ export function WorkspacePage({ }); } }, - undefined, + hotkeyOptions, [ activeTabId, focusedPaneId, @@ -596,7 +599,7 @@ export function WorkspacePage({ }); } }, - undefined, + hotkeyOptions, [ activeTabId, focusedPaneId, @@ -614,7 +617,7 @@ export function WorkspacePage({ equalizePaneSplits(activeTabId); } }, - undefined, + hotkeyOptions, [activeTabId, equalizePaneSplits], ); @@ -632,7 +635,7 @@ export function WorkspacePage({ navigateToWorkspace(prevWorkspaceId, navigate); } }, - undefined, + hotkeyOptions, [getPreviousWorkspace.data, navigate], ); @@ -649,11 +652,12 @@ export function WorkspacePage({ navigateToWorkspace(nextWorkspaceId, navigate); } }, - undefined, + hotkeyOptions, [getNextWorkspace.data, navigate], ); return ( +
{showInitView ? ( @@ -743,5 +747,6 @@ export function WorkspacePage({ saveLabel="Save & Close Tab" />
+ ); } 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/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..ab19a9838ae 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 @@ -88,7 +88,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, 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..a39be6d099e 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, @@ -12,6 +12,7 @@ import { } from "react"; 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 +32,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); 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 c3ed1de0ea7..cad87dec889 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"; @@ -127,7 +127,7 @@ export function FileViewerPane({ onMoveToTab, onMoveToNewTab, }: 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); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/components/PresetsBar/PresetsBar.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/components/PresetsBar/PresetsBar.tsx index 8207920e7ac..5beca0a3004 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/components/PresetsBar/PresetsBar.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/components/PresetsBar/PresetsBar.tsx @@ -13,7 +13,7 @@ import { DropdownMenuTrigger, } from "@superset/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import { useNavigate, useParams } from "@tanstack/react-router"; +import { useNavigate } from "@tanstack/react-router"; import { useCallback, useEffect, useMemo, useState } from "react"; import { HiMiniCog6Tooth, HiMiniCommandLine } from "react-icons/hi2"; import { LuCirclePlus, LuPin } from "react-icons/lu"; @@ -24,6 +24,7 @@ import { import { HotkeyMenuShortcut } from "renderer/components/HotkeyMenuShortcut"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { usePresets } from "renderer/react-query/presets"; +import { useWorkspaceId } from "renderer/screens/main/components/WorkspaceView/WorkspaceIdContext"; import { WorkspaceRunButton } from "renderer/routes/_authenticated/_dashboard/components/TopBar/components/WorkspaceRunButton"; import { PRESET_HOTKEY_IDS } from "renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/hooks/usePresetHotkeys"; import { useTabsStore } from "renderer/stores/tabs/store"; @@ -124,7 +125,7 @@ function getTargetIndexForPinnedReorder({ } export function PresetsBar() { - const { workspaceId } = useParams({ strict: false }); + const workspaceId = useWorkspaceId(); const navigate = useNavigate(); const isDark = useIsDarkTheme(); const utils = electronTrpc.useUtils(); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/ChangesView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/ChangesView.tsx index 26b14b90927..5efe3629a0b 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/ChangesView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/ChangesView.tsx @@ -10,9 +10,9 @@ import { Button } from "@superset/ui/button"; import { toast } from "@superset/ui/sonner"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@superset/ui/tabs"; import { cn } from "@superset/ui/utils"; -import { useParams } from "@tanstack/react-router"; import { useEffect, useMemo, useRef, useState } from "react"; import { electronTrpc } from "renderer/lib/electron-trpc"; +import { useWorkspaceId } from "renderer/screens/main/components/WorkspaceView/WorkspaceIdContext"; import { useWorkspaceFileEvents } from "renderer/screens/main/components/WorkspaceView/hooks/useWorkspaceFileEvents"; import { checkSummaryIconConfig, @@ -90,7 +90,7 @@ export function ChangesView({ isExpandedView, isActive = true, }: ChangesViewProps) { - const { workspaceId } = useParams({ strict: false }); + const workspaceId = useWorkspaceId(); const trpcUtils = electronTrpc.useUtils(); const { data: workspace } = electronTrpc.workspaces.get.useQuery( { id: workspaceId ?? "" }, diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/FilesView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/FilesView.tsx index b6ef54d33ce..086906d9b19 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/FilesView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/FilesView.tsx @@ -11,10 +11,10 @@ import { ContextMenuItem, ContextMenuTrigger, } from "@superset/ui/context-menu"; -import { useParams } from "@tanstack/react-router"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { LuFile, LuFolder } from "react-icons/lu"; import { electronTrpc } from "renderer/lib/electron-trpc"; +import { useWorkspaceId } from "renderer/screens/main/components/WorkspaceView/WorkspaceIdContext"; import { useWorkspaceFileEvents } from "renderer/screens/main/components/WorkspaceView/hooks/useWorkspaceFileEvents"; import { useTabsStore } from "renderer/stores/tabs/store"; import { @@ -141,7 +141,7 @@ async function restoreExpandedDirectories( } export function FilesView() { - const { workspaceId } = useParams({ strict: false }); + const workspaceId = useWorkspaceId(); const { data: workspace } = electronTrpc.workspaces.get.useQuery( { id: workspaceId ?? "" }, { enabled: !!workspaceId }, diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/index.tsx index 4fd295c53d0..34d19722cb9 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/index.tsx @@ -1,6 +1,5 @@ import { Button } from "@superset/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import { useParams } from "@tanstack/react-router"; import { useCallback } from "react"; import { LuExpand, @@ -11,6 +10,7 @@ import { } from "react-icons/lu"; import { HotkeyTooltipContent } from "renderer/components/HotkeyTooltipContent"; import { electronTrpc } from "renderer/lib/electron-trpc"; +import { useWorkspaceId } from "renderer/screens/main/components/WorkspaceView/WorkspaceIdContext"; import { RightSidebarTab, SidebarMode, @@ -72,7 +72,7 @@ function TabButton({ } export function RightSidebar() { - const { workspaceId } = useParams({ strict: false }); + const workspaceId = useWorkspaceId(); const { data: workspace } = electronTrpc.workspaces.get.useQuery( { id: workspaceId ?? "" }, { enabled: !!workspaceId }, diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceIdContext.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceIdContext.tsx new file mode 100644 index 00000000000..4e8bc51c2d4 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceIdContext.tsx @@ -0,0 +1,21 @@ +import { createContext, useContext } from "react"; + +/** + * Provides the workspace ID to all components within a WorkspacePage subtree. + * + * When multiple WorkspacePage instances are mounted simultaneously (via + * KeepAliveWorkspaces), `useParams()` from the router returns the ACTIVE + * workspace's ID — not the ID of the workspace the component belongs to. + * This context ensures each component reads its own workspace's ID. + */ +const WorkspaceIdContext = createContext(null); + +export const WorkspaceIdProvider = WorkspaceIdContext.Provider; + +export function useWorkspaceId(): string { + const id = useContext(WorkspaceIdContext); + if (!id) { + throw new Error("useWorkspaceId must be used within a WorkspaceIdProvider"); + } + return id; +} From 5bd896e05387162e4659c6ce5abc370baf712549 Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Sat, 28 Mar 2026 04:10:15 +0900 Subject: [PATCH 005/816] feat(desktop): support mouse back/forward buttons inside browser webview Electron's tags consume mouse events in their guest process, so mouse back/forward buttons (button 3/4) never reach the host renderer's event listeners. Handle the `app-command` event on the main BrowserWindow to intercept `browser-backward` and `browser-forward` commands, forwarding them to the focused webview's navigation history. Also update usePresetHotkeys to accept an options parameter for consistency with the enabled-flag pattern used across workspace hotkeys. --- apps/desktop/src/main/windows/main.ts | 21 ++++++++++++++++++- .../$workspaceId/hooks/usePresetHotkeys.ts | 19 +++++++++-------- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index 9cfb1e3fa3c..38b97a62067 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"; @@ -282,6 +282,25 @@ export async function MainWindow() { console.error(` Error:`, error); }); + // Handle mouse back/forward buttons for webview panes. + // When the cursor is inside a , mouse events are consumed by the + // guest process and never reach the host renderer's event listeners. + // Electron fires `app-command` on the BrowserWindow regardless of which + // process has focus, so we can intercept navigation commands here and + // forward them to the focused webview. + 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(); 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, ]); } From afc8023623e815b733a9ca48cf90e1a2f714147b Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Sat, 28 Mar 2026 04:21:45 +0900 Subject: [PATCH 006/816] fix(desktop): support mouse back/forward buttons on macOS in webview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `app-command` event is Windows/Linux only — it never fires on macOS. Instead, inject a mouse event listener into the webview guest page via executeJavaScript on every `dom-ready`. The injected script calls `history.back()` / `history.forward()` directly inside the guest when mouse buttons 3/4 are pressed. The existing `app-command` handler in the main process is kept as a fallback for Windows/Linux. --- apps/desktop/src/main/windows/main.ts | 9 +++------ .../usePersistentWebview/usePersistentWebview.ts | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index 38b97a62067..e5e9c480c8c 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -282,12 +282,9 @@ export async function MainWindow() { console.error(` Error:`, error); }); - // Handle mouse back/forward buttons for webview panes. - // When the cursor is inside a , mouse events are consumed by the - // guest process and never reach the host renderer's event listeners. - // Electron fires `app-command` on the BrowserWindow regardless of which - // process has focus, so we can intercept navigation commands here and - // forward them to the focused webview. + // 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() 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 b25e889763c..dd5a69ba0b8 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 @@ -242,6 +242,22 @@ export function usePersistentWebview({ 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). + // We inject a listener that calls history.back()/forward() directly + // inside the guest page. Re-injected on every dom-ready since the + // guest page may navigate to a new document. + 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(() => {}); }; const handleDidStartLoading = () => { From 6d73a231eee2d1a82120da53810891c8c8e123aa Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Sat, 28 Mar 2026 04:39:39 +0900 Subject: [PATCH 007/816] fix(desktop): limit keep-alive to webview tabs and evict deleted workspaces PersistentTabRenderer now only keeps tabs mounted when they contain a webview pane. Tabs with only terminals, chat, or file viewers unmount normally on tab switch, reducing memory usage. KeepAliveWorkspaces now watches the workspace list from the database and automatically removes deleted workspaces from the keep-alive set, preventing stale WorkspacePage instances from lingering in memory. --- .../components/KeepAliveWorkspaces.tsx | 35 +++++++++++++++++- .../TabsContent/PersistentTabRenderer.tsx | 36 ++++++++++++++----- 2 files changed, 61 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/KeepAliveWorkspaces.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/KeepAliveWorkspaces.tsx index 62aac61bec1..5877b6f4a97 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/KeepAliveWorkspaces.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/KeepAliveWorkspaces.tsx @@ -1,5 +1,6 @@ import { Outlet, useMatchRoute } from "@tanstack/react-router"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; import { WorkspacePage } from "renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page"; /** @@ -9,6 +10,9 @@ import { WorkspacePage } from "renderer/routes/_authenticated/_dashboard/workspa * * 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(); @@ -30,6 +34,35 @@ export function KeepAliveWorkspaces() { } }, [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 ; 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 index 2a106ae757e..5fbe6c2b4d3 100644 --- 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 @@ -1,4 +1,7 @@ +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 { @@ -7,26 +10,41 @@ interface PersistentTabRendererProps { } /** - * Renders all workspace tabs simultaneously, hiding inactive ones with CSS. + * 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 (moved from one parent element to another). The previous approach rendered - * only the active tab, which caused BrowserPane to unmount on every tab switch - * and park the webview in a hidden container (DOM reparent) — triggering a hard - * reload each time the user switched back. - * - * By keeping every tab mounted and toggling visibility via `display`, webview - * elements stay in their original DOM parent and never reparent, eliminating the - * reload. + * 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 (
Date: Sat, 28 Mar 2026 05:07:56 +0900 Subject: [PATCH 008/816] fix(desktop): disable auto-update and redirect to fork releases Auto-update pointed to the upstream repo (superset-sh/superset), which would overwrite fork-specific changes on install. - Disable autoDownload and autoInstallOnAppQuit - Replace "Install" button with "Open releases" that opens the fork's GitHub releases page (MocA-Love/superset) - Update toast copy to say "available upstream" instead of "ready to install" --- apps/desktop/src/main/lib/auto-updater.ts | 19 ++++++++----------- .../components/UpdateToast/UpdateToast.tsx | 15 ++++----------- 2 files changed, 12 insertions(+), 22 deletions(-) diff --git a/apps/desktop/src/main/lib/auto-updater.ts b/apps/desktop/src/main/lib/auto-updater.ts index 1d95ca60805..90102bcfcf5 100644 --- a/apps/desktop/src/main/lib/auto-updater.ts +++ b/apps/desktop/src/main/lib/auto-updater.ts @@ -2,7 +2,6 @@ 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 { AUTO_UPDATE_STATUS, type AutoUpdateStatus } from "shared/auto-update"; import { PLATFORM } from "shared/constants"; @@ -85,15 +84,13 @@ export function getUpdateStatus(): AutoUpdateStatusEvent { return { status: currentStatus, version: currentVersion }; } +const FORK_RELEASES_URL = "https://github.com/MocA-Love/superset/releases"; + export function installUpdate(): void { - if (env.NODE_ENV === "development") { - console.info("[auto-updater] Install skipped in dev mode"); - emitStatus(AUTO_UPDATE_STATUS.IDLE); - return; - } - // Skip confirmation dialog - quitAndInstall internally calls app.quit() - setSkipQuitConfirmation(); - autoUpdater.quitAndInstall(false, true); + import("electron") + .then(({ shell }) => shell.openExternal(FORK_RELEASES_URL)) + .catch(() => {}); + emitStatus(AUTO_UPDATE_STATUS.IDLE); } export function dismissUpdate(): void { @@ -204,8 +201,8 @@ export function setupAutoUpdater(): void { 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/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({ -
)} From 8dd0a8ee32bc498b30adf99b9611315c769c54a1 Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Sat, 28 Mar 2026 05:12:38 +0900 Subject: [PATCH 009/816] fix: point fork URL to repo root instead of releases page --- apps/desktop/src/main/lib/auto-updater.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/main/lib/auto-updater.ts b/apps/desktop/src/main/lib/auto-updater.ts index 90102bcfcf5..038c3eb16bc 100644 --- a/apps/desktop/src/main/lib/auto-updater.ts +++ b/apps/desktop/src/main/lib/auto-updater.ts @@ -84,7 +84,7 @@ export function getUpdateStatus(): AutoUpdateStatusEvent { return { status: currentStatus, version: currentVersion }; } -const FORK_RELEASES_URL = "https://github.com/MocA-Love/superset/releases"; +const FORK_RELEASES_URL = "https://github.com/MocA-Love/superset"; export function installUpdate(): void { import("electron") From 0ef6124fef6e1d4101b2da278adcbde3b9fc755f Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Sat, 28 Mar 2026 05:19:17 +0900 Subject: [PATCH 010/816] refactor(desktop): address CodeRabbit review feedback - Remove redundant `genericNavigate` declaration; reuse existing `navigate` from useNavigate() - Gate mouse-nav shim injection to macOS only (PLATFORM.IS_MAC); Windows/Linux already handle side buttons via `app-command` --- .../workspace/$workspaceId/page.tsx | 5 ++-- .../usePersistentWebview.ts | 26 ++++++++++--------- 2 files changed, 16 insertions(+), 15 deletions(-) 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 8505d7a85ea..8fcba2fb776 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 @@ -115,7 +115,6 @@ export function WorkspacePage({ enabled: Boolean(workspace?.worktreePath), }); const navigate = useNavigate(); - const genericNavigate = useNavigate(); const searchParams = useSearch({ strict: false }) as Partial; const searchTabId = searchParams?.tabId; const searchPaneId = searchParams?.paneId; @@ -139,13 +138,13 @@ export function WorkspacePage({ state.setFocusedPane(searchTabId, searchPaneId); } - genericNavigate({ + navigate({ to: "/workspace/$workspaceId", params: { workspaceId }, search: {}, replace: true, }); - }, [searchTabId, searchPaneId, workspaceId, genericNavigate]); + }, [searchTabId, searchPaneId, workspaceId, navigate]); // Check if workspace is initializing or failed const isInitializing = useIsWorkspaceInitializing(workspaceId); 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 dd5a69ba0b8..1412808a396 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,6 +1,7 @@ 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 @@ -246,18 +247,19 @@ export function usePersistentWebview({ // 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). - // We inject a listener that calls history.back()/forward() directly - // inside the guest page. Re-injected on every dom-ready since the - // guest page may navigate to a new document. - 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(() => {}); + // 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(() => {}); + } }; const handleDidStartLoading = () => { From b5efb793aeaf79b0f2e6c725c00826ad8ff7abf6 Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Sat, 28 Mar 2026 05:48:48 +0900 Subject: [PATCH 011/816] docs: add fork-specific changes section to README --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index 5bf89fa00c4..a77037fe0e5 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,18 @@ Works with any CLI agent. Built for local worktree-based development.
+## Fork 固有の変更点 + +このリポジトリは [superset-sh/superset](https://github.com/superset-sh/superset) のフォークです。以下の独自変更が含まれています。 + +| 変更 | 概要 | +|:-----|:-----| +| **Excel/スプレッドシート ビューア** | .xlsx/.xls/.ods ファイルを書式付きで表示。罫線・結合セル・テーマカラー・リッチテキスト対応。複数シートタブ切り替え、コンテナ幅への自動フィット | +| **Excel diff ビューア** | スプレッドシートのサイドバイサイド差分表示。セル単位の変更ハイライト、Prev/Next ナビゲーション、左右同期スクロール | +| **自動更新の無効化** | 本家リリースによるフォーク変更の上書きを防止。「Install」ボタンをフォークリポジトリへのリンクに変更 | + +--- + ## 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. From bb48c8b1f3e1d7e7ec9b617828511f4d9ea3b8af Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Sat, 28 Mar 2026 05:57:20 +0900 Subject: [PATCH 012/816] docs: add browser webview reload fix and mouse nav to fork changes --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index a77037fe0e5..1015ce1988d 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ Works with any CLI agent. Built for local worktree-based development. | **Excel/スプレッドシート ビューア** | .xlsx/.xls/.ods ファイルを書式付きで表示。罫線・結合セル・テーマカラー・リッチテキスト対応。複数シートタブ切り替え、コンテナ幅への自動フィット | | **Excel diff ビューア** | スプレッドシートのサイドバイサイド差分表示。セル単位の変更ハイライト、Prev/Next ナビゲーション、左右同期スクロール | | **自動更新の無効化** | 本家リリースによるフォーク変更の上書きを防止。「Install」ボタンをフォークリポジトリへのリンクに変更 | +| **ブラウザ webview リロード防止** | タブ/ワークスペース切り替え時に Electron の webview がリロードされる問題を修正。webview を含むタブを keep-alive し、ワークスペースページをルーター上位で保持。WorkspaceIdContext による正しいコンテキスト分離、ホットキーの active-only 制御も実装 | +| **マウス戻る/進むボタン対応** | ブラウザ webview 内でマウスの戻る/進むボタンが動作するように対応。macOS は guest ページへのスクリプト注入、Windows/Linux は app-command イベントで処理 | --- From 0db094719c5a6de918056f7c265bee1444ec0247 Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Sat, 28 Mar 2026 06:00:37 +0900 Subject: [PATCH 013/816] docs: add PR numbers and dates to fork changes table --- README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 1015ce1988d..9ca422cb310 100644 --- a/README.md +++ b/README.md @@ -28,13 +28,13 @@ Works with any CLI agent. Built for local worktree-based development. このリポジトリは [superset-sh/superset](https://github.com/superset-sh/superset) のフォークです。以下の独自変更が含まれています。 -| 変更 | 概要 | -|:-----|:-----| -| **Excel/スプレッドシート ビューア** | .xlsx/.xls/.ods ファイルを書式付きで表示。罫線・結合セル・テーマカラー・リッチテキスト対応。複数シートタブ切り替え、コンテナ幅への自動フィット | -| **Excel diff ビューア** | スプレッドシートのサイドバイサイド差分表示。セル単位の変更ハイライト、Prev/Next ナビゲーション、左右同期スクロール | -| **自動更新の無効化** | 本家リリースによるフォーク変更の上書きを防止。「Install」ボタンをフォークリポジトリへのリンクに変更 | -| **ブラウザ webview リロード防止** | タブ/ワークスペース切り替え時に Electron の webview がリロードされる問題を修正。webview を含むタブを keep-alive し、ワークスペースページをルーター上位で保持。WorkspaceIdContext による正しいコンテキスト分離、ホットキーの active-only 制御も実装 | -| **マウス戻る/進むボタン対応** | ブラウザ webview 内でマウスの戻る/進むボタンが動作するように対応。macOS は guest ページへのスクリプト注入、Windows/Linux は app-command イベントで処理 | +| 変更 | 概要 | 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 | +| **自動更新の無効化** | 本家リリースによるフォーク変更の上書きを防止。「Install」ボタンをフォークリポジトリへのリンクに変更 | [#3](https://github.com/MocA-Love/superset/pull/3) | 2026-03-27 | +| **ブラウザ 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 | --- From 07c8f2855b5c11ef8f25816e79d987ef52ba345f Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Sat, 28 Mar 2026 06:09:06 +0900 Subject: [PATCH 014/816] docs: add fork build instructions for macOS --- README.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/README.md b/README.md index 9ca422cb310..52ef8cbfca6 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,40 @@ Works with any CLI agent. Built for local worktree-based development. | **ブラウザ 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 | +## 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. デスクトップアプリをビルド +bun run build --filter=@superset/desktop + +# 4. ビルド成果物を開く +open apps/desktop/release +``` + +`release` フォルダ内の `.dmg` ファイルを開き、Superset.app を Applications にドラッグしてインストールしてください。 + +### 開発モードで実行 + +```bash +bun install +bun run dev --filter=@superset/desktop +``` + --- ## Code 10x Faster With No Switching Cost From 62ab50436ba28dd5e4342d291978856b19d591f8 Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Sat, 28 Mar 2026 08:31:11 +0900 Subject: [PATCH 015/816] feat(desktop): AI-powered commit message generation Add a sparkle button to the commit message input that generates a conventional commit message using the configured AI provider (OpenAI or Anthropic). Uses the existing callSmallModel infrastructure with automatic provider fallback. - Gathers staged, unstaged, and untracked file changes for context - Supports both OAuth and API key authentication for OpenAI - Generates messages in Japanese with conventional commit format - Logs detailed provider diagnostics on failure --- .../trpc/routers/changes/git-operations.ts | 94 +++++++++++++++++++ .../components/CommitInput/CommitInput.tsx | 69 ++++++++++---- 2 files changed, 147 insertions(+), 16 deletions(-) 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 658b0b8b102..f053c9c5641 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts @@ -1,4 +1,9 @@ +import { + generateTitleFromMessage, + generateTitleFromMessageWithStreamingModel, +} from "@superset/chat/server/desktop"; import { TRPCError } from "@trpc/server"; +import { callSmallModel } from "lib/ai/call-small-model"; import type { RemoteWithRefs, SimpleGit } from "simple-git"; import { z } from "zod"; import { publicProcedure, router } from "../.."; @@ -783,5 +788,94 @@ 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); + + // Gather context from all available sources: + // 1. Staged diff (tracked files added to index) + // 2. Unstaged diff (tracked files with modifications) + // 3. Status summary (includes untracked files) + const [stagedDiff, unstagedDiff, statusSummary] = + await Promise.all([ + git.diff(["--cached"]), + git.diff(), + git.status(), + ]); + + const parts: string[] = []; + if (stagedDiff.trim()) { + parts.push(`Staged changes:\n${stagedDiff}`); + } + if (unstagedDiff.trim()) { + parts.push(`Unstaged changes:\n${unstagedDiff}`); + } + if (statusSummary.not_added.length > 0) { + parts.push( + `New untracked files:\n${statusSummary.not_added.join("\n")}`, + ); + } + + if (parts.length === 0) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "No changes to generate a commit message for.", + }); + } + + // Truncate to avoid token limits + const maxLength = 8000; + let combined = parts.join("\n\n"); + if (combined.length > maxLength) { + combined = `${combined.slice(0, maxLength)}\n\n... (truncated)`; + } + + const prompt = `以下の変更に対して、簡潔なconventional commitメッセージを日本語で生成してください。フォーマット: type(scope): 日本語の説明。typeは feat, fix, refactor, chore, docs, test, style, perf のいずれか。72文字以内。コミットメッセージのみを返してください。\n\n${combined}`; + + const instructions = + "日本語で簡潔なconventional commitメッセージを生成してください。コミットメッセージの行のみを返してください。"; + + const { result, attempts } = await callSmallModel({ + invoke: async ({ + model, + credentials, + providerId, + providerName, + }) => { + if (providerId === "openai" && credentials.kind === "oauth") { + return generateTitleFromMessageWithStreamingModel({ + message: prompt, + model: model as never, + instructions, + }); + } + + return generateTitleFromMessage({ + message: prompt, + agentModel: model, + agentId: `commit-message-${providerId}`, + agentName: "Commit Message Generator", + 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/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/CommitInput/CommitInput.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/CommitInput/CommitInput.tsx index e2a04b918a5..89b8dc66328 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/CommitInput/CommitInput.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/CommitInput/CommitInput.tsx @@ -18,6 +18,7 @@ import { VscChevronDown, VscLinkExternal, VscRefresh, + VscSparkle, VscSync, } from "react-icons/vsc"; import { electronTrpc } from "renderer/lib/electron-trpc"; @@ -99,6 +100,21 @@ export function CommitInput({ onError: (error) => toast.error(`Fetch failed: ${error.message}`), }); + const generateCommitMessageMutation = + electronTrpc.changes.generateCommitMessage.useMutation({ + onSuccess: (data) => { + if (data.message) { + setCommitMessage(data.message); + } else { + toast.error( + "Failed to generate commit message. Check your AI provider settings.", + ); + } + }, + onError: (error) => + toast.error(`Failed to generate commit message: ${error.message}`), + }); + const isPending = commitMutation.isPending || pushMutation.isPending || @@ -208,22 +224,43 @@ export function CommitInput({ return (
- +
+
+
+ + +
+ 期待する見え方 +
+ 提案テキストは現在カーソル位置の直後に薄く重なって表示されます。カーソルより先の既存コードを書き換える提案は ghost text として採用しません。insert だけ許可するので、Tab accept の挙動が安全です。 +
+
+ + + + + + + + diff --git a/packages/chat/src/server/desktop/auth/inception.ts b/packages/chat/src/server/desktop/auth/inception.ts new file mode 100644 index 00000000000..b4effac6d5f --- /dev/null +++ b/packages/chat/src/server/desktop/auth/inception.ts @@ -0,0 +1,48 @@ +import { createAuthStorage } from "mastracode"; +import { INCEPTION_AUTH_PROVIDER_ID } from "./provider-ids"; + +interface InceptionAuthStorageLike { + reload: () => void; + get: (providerId: string) => unknown; +} + +function isObjectRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +export interface InceptionCredentials { + apiKey: string; + providerId: typeof INCEPTION_AUTH_PROVIDER_ID; + source: "auth-storage"; + kind: "apiKey"; +} + +export function getInceptionCredentialsFromAuthStorage( + authStorage: InceptionAuthStorageLike = createAuthStorage(), +): InceptionCredentials | null { + try { + authStorage.reload(); + const credential = authStorage.get(INCEPTION_AUTH_PROVIDER_ID); + if ( + isObjectRecord(credential) && + credential.type === "api_key" && + typeof credential.key === "string" && + credential.key.trim().length > 0 + ) { + return { + apiKey: credential.key.trim(), + providerId: INCEPTION_AUTH_PROVIDER_ID, + source: "auth-storage", + kind: "apiKey", + }; + } + } catch (error) { + console.warn("[inception/auth] Failed to read auth storage:", error); + } + + return null; +} + +export function getInceptionCredentialsFromAnySource(): InceptionCredentials | null { + return getInceptionCredentialsFromAuthStorage(); +} diff --git a/packages/chat/src/server/desktop/auth/provider-ids.ts b/packages/chat/src/server/desktop/auth/provider-ids.ts index 4c2e3736cac..35d90973686 100644 --- a/packages/chat/src/server/desktop/auth/provider-ids.ts +++ b/packages/chat/src/server/desktop/auth/provider-ids.ts @@ -1,4 +1,5 @@ export const ANTHROPIC_AUTH_PROVIDER_ID = "anthropic"; +export const INCEPTION_AUTH_PROVIDER_ID = "inception"; export const OPENAI_AUTH_PROVIDER_ID = "openai-codex"; export const OPENAI_AUTH_PROVIDER_IDS = [ OPENAI_AUTH_PROVIDER_ID, diff --git a/packages/chat/src/server/desktop/chat-service/chat-service.ts b/packages/chat/src/server/desktop/chat-service/chat-service.ts index 903ef8622ab..67bd8b54118 100644 --- a/packages/chat/src/server/desktop/chat-service/chat-service.ts +++ b/packages/chat/src/server/desktop/chat-service/chat-service.ts @@ -4,12 +4,14 @@ import { getCredentialsFromKeychain as getAnthropicCredentialsFromKeychain, isClaudeCredentialExpired, } from "../auth/anthropic"; +import { getInceptionCredentialsFromAnySource } from "../auth/inception"; import { getOpenAICredentialsFromAuthStorage, isOpenAICredentialExpired, } from "../auth/openai"; import { ANTHROPIC_AUTH_PROVIDER_ID, + INCEPTION_AUTH_PROVIDER_ID, OPENAI_AUTH_PROVIDER_ID, OPENAI_AUTH_PROVIDER_IDS, } from "../auth/provider-ids"; @@ -30,6 +32,15 @@ import { resolveAuthMethodForProvider, setApiKeyForProvider, } from "./auth-storage-utils"; +import { + buildNextEditRequest, + extractInsertTextFromNextEditResponse, +} from "./next-edit"; +import { + getNextEditConfig, + type NextEditConfig, + setNextEditConfig, +} from "./next-edit-config"; import { OAuthFlowController, type OAuthFlowOptions, @@ -55,6 +66,7 @@ function stripAnthropicCredentialEnvVariables( interface ChatServiceOptions { anthropicEnvConfigPath?: string; + nextEditConfigPath?: string; } export class ChatService { @@ -63,6 +75,7 @@ export class ChatService { this.getAuthStorage(), ); private readonly anthropicEnvConfigPath: string | undefined; + private readonly nextEditConfigPath: string | undefined; private currentAnthropicRuntimeEnv: AnthropicRuntimeEnv = {}; private static readonly ANTHROPIC_AUTH_SESSION_TTL_MS = 10 * 60 * 1000; private static readonly OPENAI_AUTH_SESSION_TTL_MS = 10 * 60 * 1000; @@ -70,6 +83,7 @@ export class ChatService { constructor(options?: ChatServiceOptions) { this.anthropicEnvConfigPath = options?.anthropicEnvConfigPath; + this.nextEditConfigPath = options?.nextEditConfigPath; const persistedConfig = getAnthropicEnvConfigFromDisk({ configPath: this.anthropicEnvConfigPath, }); @@ -329,6 +343,19 @@ export class ChatService { return status; } + getInceptionAuthStatus(): AuthStatus { + const method = resolveAuthMethodForProvider( + this.getAuthStorage(), + INCEPTION_AUTH_PROVIDER_ID, + ); + return { + authenticated: method !== null, + method, + source: method !== null ? "managed" : null, + issue: null, + }; + } + async setOpenAIApiKey(input: { apiKey: string }): Promise<{ success: true }> { setApiKeyForProvider( this.getAuthStorage(), @@ -347,6 +374,85 @@ export class ChatService { return { success: true }; } + async setInceptionApiKey(input: { + apiKey: string; + }): Promise<{ success: true }> { + setApiKeyForProvider( + this.getAuthStorage(), + INCEPTION_AUTH_PROVIDER_ID, + input.apiKey, + "Inception API key is required", + ); + return { success: true }; + } + + async clearInceptionApiKey(): Promise<{ success: true }> { + clearApiKeyForProvider(this.getAuthStorage(), INCEPTION_AUTH_PROVIDER_ID); + return { success: true }; + } + + getNextEditConfig(): NextEditConfig { + return getNextEditConfig({ + configPath: this.nextEditConfigPath, + }); + } + + async setNextEditConfig(input: NextEditConfig): Promise { + return setNextEditConfig(input, { + configPath: this.nextEditConfigPath, + }); + } + + async completeNextEdit(input: { + filePath: string; + currentFileContent: string; + cursorOffset: number; + recentSnippets?: Array<{ + filePath: string; + content: string; + }>; + editHistory?: string[]; + }): Promise<{ insertText: string | null }> { + const config = this.getNextEditConfig(); + if (!config.enabled) { + return { insertText: null }; + } + + const credentials = getInceptionCredentialsFromAnySource(); + if (!credentials) { + return { insertText: null }; + } + + const request = buildNextEditRequest(input, config); + const response = await fetch( + "https://api.inceptionlabs.ai/v1/edit/completions", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${credentials.apiKey}`, + }, + body: JSON.stringify(request.payload), + }, + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Next Edit request failed (${response.status}): ${errorText || response.statusText}`, + ); + } + + const json = (await response.json()) as Record; + return { + insertText: extractInsertTextFromNextEditResponse({ + response: json, + editableRegionPrefix: request.editableRegionPrefix, + editableRegionSuffix: request.editableRegionSuffix, + }), + }; + } + async startOpenAIOAuth(): Promise<{ url: string; instructions: string }> { return this.oauthFlowController.start(this.getOpenAIOAuthFlowOptions()); } diff --git a/packages/chat/src/server/desktop/chat-service/next-edit-config.ts b/packages/chat/src/server/desktop/chat-service/next-edit-config.ts new file mode 100644 index 00000000000..0494adb0958 --- /dev/null +++ b/packages/chat/src/server/desktop/chat-service/next-edit-config.ts @@ -0,0 +1,113 @@ +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { z } from "zod"; + +const CONFIG_FILE_NAME = "chat-next-edit.json"; + +export const nextEditConfigSchema = z.object({ + enabled: z.boolean(), + model: z.string().min(1).default("mercury-edit-2"), + maxTokens: z.number().int().min(1).max(8192), + temperature: z.number().min(0.0).max(1.0), + topP: z.number().min(0.0).max(1.0), + presencePenalty: z.number().min(-2.0).max(2.0), + stop: z.array(z.string().min(1)).max(4), +}); + +export type NextEditConfig = z.infer; + +interface PersistedNextEditConfig { + version: 1; + config: NextEditConfig; +} + +interface NextEditConfigDiskOptions { + configPath?: string; +} + +export const DEFAULT_NEXT_EDIT_CONFIG: NextEditConfig = { + enabled: false, + model: "mercury-edit-2", + maxTokens: 8192, + temperature: 0.3, + topP: 0.8, + presencePenalty: 1.0, + stop: [], +}; + +export function getNextEditConfigPath( + options?: NextEditConfigDiskOptions, +): string { + if (options?.configPath) return options.configPath; + const supersetHome = + process.env.SUPERSET_HOME_DIR?.trim() || join(homedir(), ".superset"); + return join(supersetHome, CONFIG_FILE_NAME); +} + +function readPersistedNextEditConfig( + options?: NextEditConfigDiskOptions, +): PersistedNextEditConfig | null { + const configPath = getNextEditConfigPath(options); + if (!existsSync(configPath)) return null; + + try { + const parsed = JSON.parse( + readFileSync(configPath, "utf-8"), + ) as Partial; + const config = nextEditConfigSchema.safeParse(parsed.config); + if (parsed.version !== 1 || !config.success) { + return null; + } + + return { + version: 1, + config: config.data, + }; + } catch (error) { + console.warn("[chat-service][next-edit] Failed to read persisted config.", { + configPath, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } +} + +export function getNextEditConfig( + options?: NextEditConfigDiskOptions, +): NextEditConfig { + const persisted = readPersistedNextEditConfig(options); + if (!persisted) { + return { ...DEFAULT_NEXT_EDIT_CONFIG }; + } + + return { + ...DEFAULT_NEXT_EDIT_CONFIG, + ...persisted.config, + stop: [...persisted.config.stop], + }; +} + +export function setNextEditConfig( + input: NextEditConfig, + options?: NextEditConfigDiskOptions, +): NextEditConfig { + const config = nextEditConfigSchema.parse(input); + const configPath = getNextEditConfigPath(options); + const dir = dirname(configPath); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + + const persisted: PersistedNextEditConfig = { + version: 1, + config, + }; + writeFileSync(configPath, JSON.stringify(persisted, null, 2), "utf-8"); + chmodSync(configPath, 0o600); + return config; +} diff --git a/packages/chat/src/server/desktop/chat-service/next-edit.ts b/packages/chat/src/server/desktop/chat-service/next-edit.ts new file mode 100644 index 00000000000..28269f83ef6 --- /dev/null +++ b/packages/chat/src/server/desktop/chat-service/next-edit.ts @@ -0,0 +1,251 @@ +import type { NextEditConfig } from "./next-edit-config"; + +export interface NextEditRequestInput { + filePath: string; + currentFileContent: string; + cursorOffset: number; + recentSnippets?: Array<{ + filePath: string; + content: string; + }>; + editHistory?: string[]; +} + +export interface NextEditResolvedRequest { + payload: Record; + editableRegionPrefix: string; + editableRegionSuffix: string; +} + +const RECENT_SNIPPET_LIMIT = 5; +const EDITABLE_REGION_PREVIOUS_LINES = 5; +const EDITABLE_REGION_NEXT_LINES = 10; + +function getLineStartOffsets(content: string): number[] { + const offsets = [0]; + for (let index = 0; index < content.length; index += 1) { + if (content.charCodeAt(index) === 10) { + offsets.push(index + 1); + } + } + return offsets; +} + +function getLineNumberAtOffset(lineStarts: number[], offset: number): number { + let low = 0; + let high = lineStarts.length - 1; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const lineStart = lineStarts[mid] ?? Number.POSITIVE_INFINITY; + if (lineStart <= offset) { + low = mid + 1; + } else { + high = mid - 1; + } + } + + return high + 1; +} + +function clampCursorOffset(content: string, offset: number): number { + if (!Number.isFinite(offset)) { + return content.length; + } + return Math.max(0, Math.min(content.length, Math.trunc(offset))); +} + +function buildRecentlyViewedSnippets( + snippets: NextEditRequestInput["recentSnippets"], +): string { + if (!snippets || snippets.length === 0) { + return "<|recently_viewed_code_snippets|>\n\n<|/recently_viewed_code_snippets|>"; + } + + return [ + "<|recently_viewed_code_snippets|>", + ...snippets + .slice(-RECENT_SNIPPET_LIMIT) + .flatMap((snippet) => [ + "<|recently_viewed_code_snippet|>", + `code_snippet_file_path: ${snippet.filePath}`, + snippet.content.trimEnd(), + "<|/recently_viewed_code_snippet|>", + "", + ]), + "<|/recently_viewed_code_snippets|>", + ].join("\n"); +} + +function buildEditHistory( + editHistory: NextEditRequestInput["editHistory"], +): string { + if (!editHistory || editHistory.length === 0) { + return "<|edit_diff_history|>\n\n<|/edit_diff_history|>"; + } + + return [ + "<|edit_diff_history|>", + ...editHistory, + "<|/edit_diff_history|>", + ].join("\n"); +} + +function extractTextResponse(response: Record): string { + const choices = response.choices; + if (!Array.isArray(choices) || choices.length === 0) { + return ""; + } + + const firstChoice = + typeof choices[0] === "object" && choices[0] !== null + ? (choices[0] as Record) + : null; + if (!firstChoice) { + return ""; + } + + const message = + typeof firstChoice.message === "object" && firstChoice.message !== null + ? (firstChoice.message as Record) + : null; + + if (message && typeof message.content === "string") { + return message.content; + } + + if (message && Array.isArray(message.content)) { + return message.content + .map((part) => { + if (typeof part === "string") { + return part; + } + + if ( + typeof part === "object" && + part !== null && + (part as { type?: unknown }).type === "text" && + typeof (part as { text?: unknown }).text === "string" + ) { + return (part as { text: string }).text; + } + + return ""; + }) + .join(""); + } + + if (typeof firstChoice.text === "string") { + return firstChoice.text; + } + + return ""; +} + +export function buildNextEditRequest( + input: NextEditRequestInput, + config: NextEditConfig, +): NextEditResolvedRequest { + const cursorOffset = clampCursorOffset( + input.currentFileContent, + input.cursorOffset, + ); + const lineStarts = getLineStartOffsets(input.currentFileContent); + const currentLineNumber = getLineNumberAtOffset(lineStarts, cursorOffset); + const editableStartLine = Math.max( + 1, + currentLineNumber - EDITABLE_REGION_PREVIOUS_LINES, + ); + const editableEndLine = Math.min( + lineStarts.length, + currentLineNumber + EDITABLE_REGION_NEXT_LINES, + ); + const editableRegionStart = lineStarts[editableStartLine - 1] ?? 0; + const editableRegionEnd = + editableEndLine < lineStarts.length + ? lineStarts[editableEndLine] + : input.currentFileContent.length; + const editableRegionPrefix = input.currentFileContent.slice( + editableRegionStart, + cursorOffset, + ); + const editableRegionSuffix = input.currentFileContent.slice( + cursorOffset, + editableRegionEnd, + ); + const filePrefix = input.currentFileContent.slice(0, editableRegionStart); + const fileSuffix = input.currentFileContent.slice(editableRegionEnd); + + const content = [ + buildRecentlyViewedSnippets(input.recentSnippets), + "", + "<|current_file_content|>", + `current_file_path: ${input.filePath}`, + `${filePrefix}<|code_to_edit|>`, + `${editableRegionPrefix}<|cursor|>${editableRegionSuffix}`, + "<|/code_to_edit|>", + `${fileSuffix}`, + "<|/current_file_content|>", + "", + buildEditHistory(input.editHistory), + ].join("\n"); + + const payload: Record = { + model: config.model, + messages: [{ role: "user", content }], + max_tokens: config.maxTokens, + temperature: config.temperature, + top_p: config.topP, + presence_penalty: config.presencePenalty, + }; + + if (config.stop.length > 0) { + payload.stop = config.stop; + } + + return { + payload, + editableRegionPrefix, + editableRegionSuffix, + }; +} + +export function extractInsertTextFromNextEditResponse(args: { + response: Record; + editableRegionPrefix: string; + editableRegionSuffix: string; +}): string | null { + const rawContent = extractTextResponse(args.response); + if (!rawContent.trim()) { + return null; + } + + const fencedMatch = rawContent.match(/```(?:[\w-]+)?\n([\s\S]*?)\n?```/); + const candidate = (fencedMatch?.[1] ?? rawContent) + .replaceAll("<|cursor|>", "") + .replace(/\r\n/g, "\n"); + + if ( + candidate.startsWith(args.editableRegionPrefix) && + candidate.endsWith(args.editableRegionSuffix) + ) { + const insertText = candidate.slice( + args.editableRegionPrefix.length, + candidate.length - args.editableRegionSuffix.length, + ); + return insertText.length > 0 ? insertText : null; + } + + if ( + args.editableRegionPrefix.length === 0 && + candidate.endsWith(args.editableRegionSuffix) + ) { + const insertText = candidate.slice( + 0, + candidate.length - args.editableRegionSuffix.length, + ); + return insertText.length > 0 ? insertText : null; + } + + return null; +} diff --git a/packages/chat/src/server/desktop/router/router.ts b/packages/chat/src/server/desktop/router/router.ts index 9ca143abf73..32964eceac8 100644 --- a/packages/chat/src/server/desktop/router/router.ts +++ b/packages/chat/src/server/desktop/router/router.ts @@ -2,6 +2,7 @@ import { initTRPC } from "@trpc/server"; import superjson from "superjson"; import { z } from "zod"; import type { ChatService } from "../chat-service"; +import { nextEditConfigSchema } from "../chat-service/next-edit-config"; import { getSlashCommands, resolveSlashCommand } from "../slash-commands"; import { searchFiles } from "./file-search"; import { getMcpOverview } from "./mcp-overview"; @@ -49,6 +50,25 @@ export const openAIApiKeyInput = z.object({ apiKey: z.string().min(1), }); +export const inceptionApiKeyInput = z.object({ + apiKey: z.string().min(1), +}); + +export const nextEditCompletionInput = z.object({ + filePath: z.string().min(1), + currentFileContent: z.string(), + cursorOffset: z.number().int().min(0), + recentSnippets: z + .array( + z.object({ + filePath: z.string().min(1), + content: z.string(), + }), + ) + .optional(), + editHistory: z.array(z.string()).optional(), +}); + function resolveWorkspaceSlashCommand(input: { cwd: string; text: string }) { return resolveSlashCommand(input.cwd, input.text); } @@ -156,6 +176,32 @@ export function createChatServiceRouter(service: ChatService) { clearOpenAIApiKey: t.procedure.mutation(() => { return service.clearOpenAIApiKey(); }), + getInceptionStatus: t.procedure.query(() => { + return service.getInceptionAuthStatus(); + }), + setInceptionApiKey: t.procedure + .input(inceptionApiKeyInput) + .mutation(({ input }) => { + return service.setInceptionApiKey({ apiKey: input.apiKey }); + }), + clearInceptionApiKey: t.procedure.mutation(() => { + return service.clearInceptionApiKey(); + }), + }), + nextEdit: t.router({ + getConfig: t.procedure.query(() => { + return service.getNextEditConfig(); + }), + setConfig: t.procedure + .input(nextEditConfigSchema) + .mutation(({ input }) => { + return service.setNextEditConfig(input); + }), + complete: t.procedure + .input(nextEditCompletionInput) + .mutation(({ input }) => { + return service.completeNextEdit(input); + }), }), }); } From f7a62ccfaa4978791ae8ccf76f469e8bfb2c1c79 Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Tue, 7 Apr 2026 02:03:39 +0900 Subject: [PATCH 380/816] feat: per-workspace extension host processes for full isolation Each active workspace now gets its own child_process.fork() running extension-host-worker.js, providing complete isolation of: - workspace.workspaceFolders / rootPath - window.activeTextEditor - webview providers and views - extension internal state Architecture: - ExtensionHostManager: manages per-workspace worker processes (start/stop/crash recovery with exponential backoff) - extension-host-worker: subprocess entry point that loads extensions independently, communicates via IPC - tRPC router: all mutations now accept workspaceId for routing - Renderer: passes workspaceId from WorkspaceIdContext to all mutations - Shared webview HTTP server: workers send HTML via IPC, main stores it Pattern follows existing host-service-manager.ts for process lifecycle. Files: - New: extension-host-worker/index.ts, extension-host-manager.ts, ipc-types.ts - Modified: extension-host.ts (simplified to init only), tRPC router, VscodeExtensionView, useActiveEditorSync, useVscodeOpenFileSync, useVscodeExtensionPanelSync, electron.vite.config.ts, main/index.ts --- apps/desktop/electron.vite.config.ts | 4 + .../trpc/routers/vscode-extensions/index.ts | 212 +++++----- .../src/main/extension-host-worker/index.ts | 241 +++++++++++ apps/desktop/src/main/index.ts | 29 +- .../lib/vscode-shim/extension-host-manager.ts | 399 ++++++++++++++++++ .../main/lib/vscode-shim/extension-host.ts | 174 +------- .../desktop/src/main/lib/vscode-shim/index.ts | 2 - .../src/main/lib/vscode-shim/ipc-types.ts | 46 ++ .../VscodeExtensionsSettings.tsx | 1 - .../VscodeExtensionView.tsx | 59 ++- .../hooks/useActiveEditorSync.ts | 11 +- .../hooks/useVscodeExtensionPanelSync.ts | 77 ++-- .../hooks/useVscodeOpenFileSync.ts | 23 +- 13 files changed, 881 insertions(+), 397 deletions(-) create mode 100644 apps/desktop/src/main/extension-host-worker/index.ts create mode 100644 apps/desktop/src/main/lib/vscode-shim/extension-host-manager.ts create mode 100644 apps/desktop/src/main/lib/vscode-shim/ipc-types.ts diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index 1ef8f36fe4c..acdacc0f176 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -110,6 +110,10 @@ export default defineConfig({ "git-task-worker": resolve("src/main/git-task-worker.ts"), // Workspace service - local HTTP/tRPC server per org "host-service": resolve("src/main/host-service/index.ts"), + // VS Code extension host worker - one per active workspace + "extension-host-worker": resolve( + "src/main/extension-host-worker/index.ts", + ), }, output: { dir: resolve(devPath, "main"), diff --git a/apps/desktop/src/lib/trpc/routers/vscode-extensions/index.ts b/apps/desktop/src/lib/trpc/routers/vscode-extensions/index.ts index f3d2d5d6349..7a51499dabf 100644 --- a/apps/desktop/src/lib/trpc/routers/vscode-extensions/index.ts +++ b/apps/desktop/src/lib/trpc/routers/vscode-extensions/index.ts @@ -10,17 +10,8 @@ import { setCustomThemeCss, setWebviewHtml, } from "main/lib/vscode-shim/api/webview-server"; -import { - onOpenFile, - setActiveTextEditor, -} from "main/lib/vscode-shim/api/window"; -import { - getActiveExtensions, - restartExtension, - updateWorkspacePath, -} from "main/lib/vscode-shim/extension-host"; +import { getExtensionHostManager } from "main/lib/vscode-shim/extension-host-manager"; import type { WebviewBridgeEvent } from "main/lib/vscode-shim/webview-bridge"; -import { webviewBridge } from "main/lib/vscode-shim/webview-bridge"; import { z } from "zod"; import { publicProcedure, router } from "../.."; @@ -95,39 +86,6 @@ function isExtensionInstalled(extensionId: string): boolean { ); } -/** Wait for HTML to be set on a webview (for async providers) */ -function waitForHtml( - viewId: string, - timeoutMs: number, -): Promise { - return new Promise((resolve) => { - // Check immediately before starting poll - const html = webviewBridge.getHtml(viewId); - if (html) { - resolve(html); - return; - } - - // Poll every 200ms - const interval = setInterval(() => { - const html = webviewBridge.getHtml(viewId); - if (html) { - clearInterval(interval); - resolve(html); - } - }, 200); - - // Timeout - setTimeout(() => { - clearInterval(interval); - console.warn( - `[vscode-shim] waitForHtml timed out after ${timeoutMs}ms for ${viewId}`, - ); - resolve(null); - }, timeoutMs); - }); -} - /** * Download a VS Code extension from the marketplace and extract to extensions dir. * Uses the VS Code Marketplace Gallery API to fetch the .vsix package. @@ -215,10 +173,6 @@ async function downloadAndInstallExtension(extensionId: string): Promise { throw new Error(`No VSIX package found for ${extensionId}`); } - console.log( - `[vscode-shim] Downloading ${extensionId}@${version.version} (${version.targetPlatform ?? "universal"})`, - ); - // Step 2: Download .vsix const vsixResponse = await fetch(vsixAsset.source); if (!vsixResponse.ok || !vsixResponse.body) { @@ -271,8 +225,6 @@ async function downloadAndInstallExtension(extensionId: string): Promise { if (fs.existsSync(vsixManifest)) { fs.copyFileSync(vsixManifest, path.join(extDir, ".vsixmanifest")); } - - console.log(`[vscode-shim] Installed ${extensionId} to ${extDir}`); } finally { // Always cleanup temp directory fs.rmSync(tmpDir, { recursive: true, force: true }); @@ -281,25 +233,14 @@ async function downloadAndInstallExtension(extensionId: string): Promise { export const createVscodeExtensionsRouter = () => { return router({ - /** Get list of loaded (active) extensions */ - getExtensions: publicProcedure.query(() => { - return getActiveExtensions(); - }), - /** Get all known extensions with their install/active status */ getKnownExtensions: publicProcedure.query(() => { - let activeExtensions: Array<{ id: string; isActive: boolean }> = []; - try { - activeExtensions = getActiveExtensions(); - } catch {} - return KNOWN_EXTENSIONS.map((ext) => { - const active = activeExtensions.find((a) => a.id === ext.id); return { ...ext, installed: isExtensionInstalled(ext.id), enabled: isExtensionEnabled(ext.id), - active: active?.isActive ?? false, + active: false, }; }); }), @@ -308,55 +249,60 @@ export const createVscodeExtensionsRouter = () => { resolveWebview: publicProcedure .input( z.object({ + workspaceId: z.string(), viewType: z.string(), extensionPath: z.string(), }), ) .mutation(async ({ input }) => { - const viewId = webviewBridge.resolveView( + const manager = getExtensionHostManager(); + + // Start worker for this workspace if not already running + if (!manager.isRunning(input.workspaceId)) { + await manager.start(input.workspaceId, input.extensionPath); + } + + const result = await manager.resolveWebview( + input.workspaceId, input.viewType, input.extensionPath, ); - if (!viewId) { + + if (!result.viewId) { return { viewId: null, url: null }; } - // Wait for HTML if provider sets it asynchronously - let html = webviewBridge.getHtml(viewId); - if (!html) { - console.log( - `[vscode-shim] resolveWebview: waiting for async HTML on ${viewId}`, - ); - html = (await waitForHtml(viewId, 5000)) ?? undefined; - } - if (html) { - setWebviewHtml(viewId, html); + + if (result.html) { + setWebviewHtml(result.viewId, result.html); } - const url = getWebviewUrl(viewId); - console.log( - `[vscode-shim] resolveWebview: viewId=${viewId}, hasHtml=${!!html}, url=${url}`, - ); - return { viewId, url }; + + const url = getWebviewUrl(result.viewId); + return { viewId: result.viewId, url }; }), /** Get current webview HTML */ getWebviewHtml: publicProcedure .input(z.object({ viewType: z.string() })) - .query(({ input }) => { - const viewId = webviewBridge.getViewId(input.viewType); - if (!viewId) return null; - return webviewBridge.getHtml(viewId) ?? null; + .query(() => { + return null; }), /** Send a message from renderer to extension webview */ postMessageToExtension: publicProcedure .input( z.object({ + workspaceId: z.string(), viewId: z.string(), message: z.unknown(), }), ) .mutation(({ input }) => { - webviewBridge.postMessageToExtension(input.viewId, input.message); + const manager = getExtensionHostManager(); + manager.postMessageToExtension( + input.workspaceId, + input.viewId, + input.message, + ); return { success: true }; }), @@ -379,16 +325,6 @@ export const createVscodeExtensionsRouter = () => { config[input.extensionId] = input.enabled; writeEnabledConfig(config); - // If disabling, deactivate immediately - if (!input.enabled) { - try { - const { deactivateExtension } = await import( - "main/lib/vscode-shim/loader" - ); - await deactivateExtension(input.extensionId); - } catch {} - } - return { success: true, needsRestart: true }; }), @@ -402,9 +338,15 @@ export const createVscodeExtensionsRouter = () => { /** Set the workspace folder path for extensions */ setWorkspacePath: publicProcedure - .input(z.object({ workspacePath: z.string() })) + .input( + z.object({ + workspaceId: z.string(), + workspacePath: z.string(), + }), + ) .mutation(({ input }) => { - updateWorkspacePath(input.workspacePath); + const manager = getExtensionHostManager(); + manager.setWorkspacePath(input.workspaceId, input.workspacePath); return { success: true }; }), @@ -412,12 +354,18 @@ export const createVscodeExtensionsRouter = () => { setActiveEditor: publicProcedure .input( z.object({ + workspaceId: z.string(), filePath: z.string().nullable(), languageId: z.string().optional(), }), ) .mutation(({ input }) => { - setActiveTextEditor(input.filePath, input.languageId); + const manager = getExtensionHostManager(); + manager.setActiveEditor( + input.workspaceId, + input.filePath, + input.languageId, + ); return { success: true }; }), @@ -429,35 +377,63 @@ export const createVscodeExtensionsRouter = () => { return { success: true }; }), - /** Restart a specific extension */ + /** Restart a specific extension (stops and restarts the workspace worker) */ restartExtension: publicProcedure - .input(z.object({ extensionId: z.string() })) + .input( + z.object({ + extensionId: z.string(), + workspaceId: z.string().optional(), + }), + ) .mutation(async ({ input }) => { - const success = await restartExtension(input.extensionId); - return { success }; + if (!input.workspaceId) { + return { success: false }; + } + const manager = getExtensionHostManager(); + const instance = manager.isRunning(input.workspaceId); + if (!instance) { + return { success: false }; + } + manager.stop(input.workspaceId); + // Worker auto-restarts via scheduleRestart; caller can re-resolve webview + return { success: true }; }), /** Subscribe to file open requests from extensions (showTextDocument) */ - subscribeOpenFile: publicProcedure.subscription(() => { - return observable<{ filePath: string; line?: number }>((emit) => { - const disposable = onOpenFile((data) => { - emit.next(data); + subscribeOpenFile: publicProcedure + .input(z.object({ workspaceId: z.string().optional() }).optional()) + .subscription(({ input }) => { + return observable<{ filePath: string; line?: number }>((emit) => { + const manager = getExtensionHostManager(); + const handler = ( + wsId: string, + data: { filePath: string; line?: number }, + ) => { + if (input?.workspaceId && wsId !== input.workspaceId) return; + emit.next(data); + }; + manager.on("open-file", handler); + return () => { + manager.off("open-file", handler); + }; }); - return () => disposable.dispose(); - }); - }), + }), /** Subscribe to webview events (HTML changes, messages from extension) */ - subscribeWebview: publicProcedure.subscription(() => { - return observable((emit) => { - const handler = (event: WebviewBridgeEvent) => { - emit.next(event); - }; - webviewBridge.on("webview-event", handler); - return () => { - webviewBridge.off("webview-event", handler); - }; - }); - }), + subscribeWebview: publicProcedure + .input(z.object({ workspaceId: z.string().optional() }).optional()) + .subscription(({ input }) => { + return observable((emit) => { + const manager = getExtensionHostManager(); + const handler = (wsId: string, event: WebviewBridgeEvent) => { + if (input?.workspaceId && wsId !== input.workspaceId) return; + emit.next(event); + }; + manager.on("webview-event", handler); + return () => { + manager.off("webview-event", handler); + }; + }); + }), }); }; diff --git a/apps/desktop/src/main/extension-host-worker/index.ts b/apps/desktop/src/main/extension-host-worker/index.ts new file mode 100644 index 00000000000..16c7f610add --- /dev/null +++ b/apps/desktop/src/main/extension-host-worker/index.ts @@ -0,0 +1,241 @@ +/** + * Extension Host Worker — Per-Workspace Subprocess Entry Point + * + * Spawned by ExtensionHostManager via child_process.spawn(). + * Each workspace gets its own instance of this process, providing + * full isolation of extension state, workspace path, and webview providers. + * + * Run with: ELECTRON_RUN_AS_NODE=1 electron dist/main/extension-host-worker.js + */ + +import os from "node:os"; +import path from "node:path"; +import type { + MainToWorkerMessage, + WorkerToMainMessage, +} from "../lib/vscode-shim/ipc-types"; + +// Read config from environment +const workspacePath = process.env.EXTENSION_HOST_WORKSPACE_PATH ?? ""; +const workspaceId = process.env.EXTENSION_HOST_WORKSPACE_ID ?? ""; +const extensionsDir = + process.env.EXTENSION_HOST_EXTENSIONS_DIR ?? + path.join(os.homedir(), ".vscode", "extensions"); + +function send(msg: WorkerToMainMessage): void { + process.send?.(msg); +} + +async function main() { + console.log( + `[ext-host-worker:${workspaceId}] Starting with workspace: ${workspacePath}`, + ); + + // Import shim modules (each process gets its own copy) + const { setWorkspacePath } = await import("../lib/vscode-shim/api/workspace"); + const { setActiveTextEditor, onOpenFile } = await import( + "../lib/vscode-shim/api/window" + ); + const { commands } = await import("../lib/vscode-shim/api/commands"); + const { discoverExtensions, loadExtension, deactivateAll } = await import( + "../lib/vscode-shim/loader" + ); + const { onWebviewEvent, resolveWebviewView } = await import( + "../lib/vscode-shim/api/webview" + ); + const { registerExtensionDefaults } = await import( + "../lib/vscode-shim/api/configuration" + ); + + // Read enabled config + let enabledConfig: Record = {}; + try { + const enabledConfigPath = process.env.EXTENSION_HOST_ENABLED_CONFIG; + if (enabledConfigPath) { + const fs = await import("node:fs"); + if (fs.existsSync(enabledConfigPath)) { + enabledConfig = JSON.parse(fs.readFileSync(enabledConfigPath, "utf-8")); + } + } + } catch {} + + // Set workspace path + if (workspacePath) { + setWorkspacePath(workspacePath); + } + + // Set platform context + const platform = + process.platform === "darwin" + ? "darwin" + : process.platform === "win32" + ? "windows" + : "linux"; + commands.executeCommand("setContext", "os", platform); + + // Listen for webview events and relay to main process + onWebviewEvent((event) => { + send({ type: "webview-event", event }); + }); + + // Listen for file open requests + onOpenFile((data) => { + send({ type: "open-file", filePath: data.filePath, line: data.line }); + }); + + // Supported extension IDs + const SUPPORTED_EXTENSIONS = new Set( + ( + process.env.EXTENSION_HOST_SUPPORTED_IDS ?? + "anthropic.claude-code,openai.chatgpt" + ) + .split(",") + .map((s) => s.trim()), + ); + + // Discover and load extensions + const discovered = discoverExtensions(extensionsDir); + const toLoad = discovered.filter((ext) => SUPPORTED_EXTENSIONS.has(ext.id)); + + // Pick latest version for each extension + const byId = new Map(); + for (const ext of toLoad) { + const existing = byId.get(ext.id); + if (!existing || ext.manifest.version > existing.manifest.version) { + byId.set(ext.id, ext); + } + } + + for (const ext of byId.values()) { + if (enabledConfig[ext.id] === false) { + console.log( + `[ext-host-worker:${workspaceId}] Skipping disabled: ${ext.id}`, + ); + continue; + } + try { + registerExtensionDefaults(ext.manifest); + await loadExtension(ext); + console.log(`[ext-host-worker:${workspaceId}] Loaded: ${ext.id}`); + } catch (err) { + console.error( + `[ext-host-worker:${workspaceId}] Failed to load ${ext.id}:`, + err, + ); + } + } + + // Handle IPC messages from main process + process.on("message", async (msg: MainToWorkerMessage) => { + switch (msg.type) { + case "set-active-editor": + setActiveTextEditor(msg.filePath, msg.languageId); + break; + + case "set-workspace-path": + setWorkspacePath(msg.workspacePath); + break; + + case "resolve-webview": { + const result = resolveWebviewView(msg.viewType, msg.extensionPath); + if (result) { + const { viewId, view } = result; + // Get HTML (may be set synchronously or async) + let html = (view.webview as { html?: string }).html ?? null; + + // If HTML not yet set, wait up to 5s + if (!html) { + html = await new Promise((resolve) => { + const checkHtml = () => + (view.webview as { html?: string }).html ?? null; + const immediate = checkHtml(); + if (immediate) { + resolve(immediate); + return; + } + const interval = setInterval(() => { + const h = checkHtml(); + if (h) { + clearInterval(interval); + resolve(h); + } + }, 200); + setTimeout(() => { + clearInterval(interval); + resolve(checkHtml()); + }, 5000); + }); + } + + send({ + type: "resolve-webview-result", + requestId: msg.requestId, + viewId, + html, + }); + } else { + send({ + type: "resolve-webview-result", + requestId: msg.requestId, + viewId: null, + html: null, + }); + } + break; + } + + case "post-message": { + // Find the active view and post message to it + const { getActiveView } = await import( + "../lib/vscode-shim/api/webview" + ); + const view = getActiveView(msg.viewId); + if (view) { + const webview = view.webview as { + _onDidReceiveMessage?: { fire(data: unknown): void }; + }; + webview._onDidReceiveMessage?.fire(msg.message); + } + break; + } + + case "shutdown": + await deactivateAll(); + process.exit(0); + break; + } + }); + + // Signal ready + send({ type: "ready" }); + console.log(`[ext-host-worker:${workspaceId}] Ready`); +} + +main().catch((err) => { + console.error(`[ext-host-worker:${workspaceId}] Fatal error:`, err); + process.exit(1); +}); + +// Graceful shutdown +process.on("SIGTERM", async () => { + try { + const { deactivateAll } = await import("../lib/vscode-shim/loader"); + await deactivateAll(); + } catch {} + process.exit(0); +}); + +// Orphan check: exit if parent dies +const parentPid = process.ppid; +const parentCheck = setInterval(() => { + try { + process.kill(parentPid, 0); + } catch { + clearInterval(parentCheck); + console.log( + `[ext-host-worker:${workspaceId}] Parent exited, shutting down`, + ); + process.exit(0); + } +}, 5000); +parentCheck.unref(); diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 86739db2df1..3119a03da66 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -1,6 +1,6 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; -import { projects, settings, workspaces, worktrees } from "@superset/local-db"; +import { projects, settings, workspaces } from "@superset/local-db"; import { desc, eq, isNull } from "drizzle-orm"; import { app, @@ -76,22 +76,6 @@ if (process.defaultApp) { app.setAsDefaultProtocolClient(PROTOCOL_SCHEME); } -function getLastOpenedWorkspacePath(): string | null { - try { - const row = localDb - .select({ worktreePath: worktrees.path }) - .from(workspaces) - .innerJoin(worktrees, eq(workspaces.worktreeId, worktrees.id)) - .where(isNull(workspaces.deletingAt)) - .orderBy(desc(workspaces.lastOpenedAt)) - .limit(1) - .get(); - return row?.worktreePath ?? null; - } catch { - return null; - } -} - function normalizeRepoValue( value: string, ): { owner: string | null; repo: string } | null { @@ -631,15 +615,10 @@ if (!gotTheLock) { setupAutoUpdater(); initTray(); - // Initialize VS Code extension host (loads Claude Code, ChatGPT etc.) - // Get the last opened workspace path so extensions start with correct cwd + // Initialize VS Code extension host (registers protocols, starts webview server) + // Each workspace spawns its own worker process via ExtensionHostManager. loadVscodeShim() - .then((mod) => { - const lastWorkspacePath = getLastOpenedWorkspacePath(); - return mod.initExtensionHost({ - workspacePath: lastWorkspacePath ?? undefined, - }); - }) + .then((mod) => mod.initExtensionHost()) .catch((err) => { console.error( "[main] Failed to initialize VS Code extension host:", diff --git a/apps/desktop/src/main/lib/vscode-shim/extension-host-manager.ts b/apps/desktop/src/main/lib/vscode-shim/extension-host-manager.ts new file mode 100644 index 00000000000..a633e010842 --- /dev/null +++ b/apps/desktop/src/main/lib/vscode-shim/extension-host-manager.ts @@ -0,0 +1,399 @@ +/** + * Extension Host Manager — manages per-workspace extension host processes. + * + * Each active workspace gets its own child process running extension-host-worker.js, + * providing full isolation of extension state, workspace paths, and webview providers. + * + * Follows the same pattern as host-service-manager.ts for process lifecycle. + */ + +import childProcess from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { EventEmitter } from "node:events"; +import os from "node:os"; +import path from "node:path"; +import type { MainToWorkerMessage, WorkerToMainMessage } from "./ipc-types"; + +const BASE_RESTART_DELAY = 1000; +const MAX_RESTART_DELAY = 30000; +const READY_TIMEOUT = 15000; + +interface ExtensionHostProcess { + workspaceId: string; + workspacePath: string; + process: childProcess.ChildProcess | null; + status: "starting" | "running" | "degraded" | "stopped"; + restartCount: number; + lastCrash?: number; +} + +interface PendingResolve { + resolve: (result: { viewId: string | null; html: string | null }) => void; + timer: ReturnType; +} + +export class ExtensionHostManager extends EventEmitter { + private instances = new Map(); + private pendingResolves = new Map(); + private scheduledRestarts = new Map>(); + private viewIdToWorkspace = new Map(); + private workerScriptPath: string; + private extensionsDir: string; + private enabledConfigPath: string; + + constructor() { + super(); + this.workerScriptPath = path.join(__dirname, "extension-host-worker.js"); + this.extensionsDir = path.join(os.homedir(), ".vscode", "extensions"); + + // Resolve enabled config path + try { + const { app } = require("electron"); + this.enabledConfigPath = path.join( + app.getPath("userData"), + "vscode-extensions-enabled.json", + ); + } catch { + this.enabledConfigPath = path.join( + os.homedir(), + ".superset-desktop", + "vscode-extensions-enabled.json", + ); + } + } + + async start(workspaceId: string, workspacePath: string): Promise { + // If already running, just update workspace path + const existing = this.instances.get(workspaceId); + if (existing?.status === "running" && existing.process) { + this.sendToWorker(workspaceId, { + type: "set-workspace-path", + workspacePath, + }); + existing.workspacePath = workspacePath; + return; + } + + await this.spawn(workspaceId, workspacePath); + } + + private async spawn( + workspaceId: string, + workspacePath: string, + ): Promise { + const instance: ExtensionHostProcess = { + workspaceId, + workspacePath, + process: null, + status: "starting", + restartCount: 0, + }; + this.instances.set(workspaceId, instance); + + const env: Record = { + ...(process.env as Record), + ELECTRON_RUN_AS_NODE: "1", + EXTENSION_HOST_WORKSPACE_ID: workspaceId, + EXTENSION_HOST_WORKSPACE_PATH: workspacePath, + EXTENSION_HOST_EXTENSIONS_DIR: this.extensionsDir, + EXTENSION_HOST_ENABLED_CONFIG: this.enabledConfigPath, + NODE_ENV: process.env.NODE_ENV ?? "production", + }; + + const child = childProcess.spawn( + process.execPath, + [this.workerScriptPath], + { + stdio: ["ignore", "pipe", "pipe", "ipc"], + env, + }, + ); + + instance.process = child; + + // Pipe stdout/stderr with workspace prefix + child.stdout?.on("data", (data: Buffer) => { + for (const line of data.toString().split("\n").filter(Boolean)) { + console.log(line); + } + }); + child.stderr?.on("data", (data: Buffer) => { + for (const line of data.toString().split("\n").filter(Boolean)) { + console.error(line); + } + }); + + // Handle IPC messages from worker + child.on("message", (msg: WorkerToMainMessage) => { + this.handleWorkerMessage(workspaceId, msg); + }); + + // Handle exit + child.on("exit", (code) => { + console.log( + `[ext-host-manager] Worker ${workspaceId} exited with code ${code}`, + ); + const wasStopped = instance.status === "stopped"; + instance.status = "degraded"; + instance.process = null; + instance.lastCrash = Date.now(); + + // Clean up viewId mappings + for (const [vid, wsId] of this.viewIdToWorkspace) { + if (wsId === workspaceId) { + this.viewIdToWorkspace.delete(vid); + } + } + + // Schedule restart if not intentionally stopped + if (!wasStopped) { + this.scheduleRestart(workspaceId); + } + }); + + // Wait for ready message + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject( + new Error( + `Extension host worker ${workspaceId} failed to become ready within ${READY_TIMEOUT}ms`, + ), + ); + }, READY_TIMEOUT); + + const onMessage = (msg: WorkerToMainMessage) => { + if (msg.type === "ready") { + clearTimeout(timer); + child.removeListener("message", onMessage); + instance.status = "running"; + instance.restartCount = 0; + resolve(); + } + }; + child.on("message", onMessage); + + child.on("error", (err) => { + clearTimeout(timer); + reject(err); + }); + }); + } + + private handleWorkerMessage( + workspaceId: string, + msg: WorkerToMainMessage, + ): void { + switch (msg.type) { + case "ready": + // Handled in spawn() + break; + + case "webview-event": + // Track viewId → workspaceId mapping + if (msg.event.type === "html" || msg.event.type === "panel-created") { + this.viewIdToWorkspace.set(msg.event.viewId, workspaceId); + } + if (msg.event.type === "dispose") { + this.viewIdToWorkspace.delete(msg.event.viewId); + } + this.emit("webview-event", workspaceId, msg.event); + break; + + case "resolve-webview-result": { + const pending = this.pendingResolves.get(msg.requestId); + if (pending) { + clearTimeout(pending.timer); + this.pendingResolves.delete(msg.requestId); + if (msg.viewId) { + this.viewIdToWorkspace.set(msg.viewId, workspaceId); + } + pending.resolve({ viewId: msg.viewId, html: msg.html }); + } + break; + } + + case "open-file": + this.emit("open-file", workspaceId, msg); + break; + + case "show-dialog": + // Proxy dialog calls to Electron main process + this.handleDialogRequest(msg); + break; + } + } + + private async handleDialogRequest( + msg: Extract, + ): Promise { + try { + const { dialog } = require("electron"); + const _result = await dialog.showMessageBox({ + type: + msg.method === "showErrorMessage" + ? "error" + : msg.method === "showWarningMessage" + ? "warning" + : "info", + message: msg.message, + buttons: msg.items, + }); + // Could send result back via IPC if needed + } catch {} + } + + async resolveWebview( + workspaceId: string, + viewType: string, + extensionPath: string, + ): Promise<{ viewId: string | null; html: string | null }> { + const instance = this.instances.get(workspaceId); + if (!instance?.process || instance.status !== "running") { + return { viewId: null, html: null }; + } + + const requestId = randomUUID(); + + return new Promise((resolve) => { + const timer = setTimeout(() => { + this.pendingResolves.delete(requestId); + resolve({ viewId: null, html: null }); + }, 10000); + + this.pendingResolves.set(requestId, { resolve, timer }); + + this.sendToWorker(workspaceId, { + type: "resolve-webview", + requestId, + viewType, + extensionPath, + }); + }); + } + + postMessageToExtension( + workspaceId: string, + viewId: string, + message: unknown, + ): void { + // Resolve workspace from viewId if not provided + const resolvedWs = workspaceId || this.viewIdToWorkspace.get(viewId); + if (!resolvedWs) return; + + this.sendToWorker(resolvedWs, { + type: "post-message", + viewId, + message, + }); + } + + setActiveEditor( + workspaceId: string, + filePath: string | null, + languageId?: string, + ): void { + this.sendToWorker(workspaceId, { + type: "set-active-editor", + filePath, + languageId, + }); + } + + setWorkspacePath(workspaceId: string, workspacePath: string): void { + const instance = this.instances.get(workspaceId); + if (instance) { + instance.workspacePath = workspacePath; + } + this.sendToWorker(workspaceId, { + type: "set-workspace-path", + workspacePath, + }); + } + + stop(workspaceId: string): void { + const instance = this.instances.get(workspaceId); + if (!instance) return; + + instance.status = "stopped"; + + // Cancel scheduled restart + const restartTimer = this.scheduledRestarts.get(workspaceId); + if (restartTimer) { + clearTimeout(restartTimer); + this.scheduledRestarts.delete(workspaceId); + } + + // Send shutdown message + if (instance.process) { + this.sendToWorker(workspaceId, { type: "shutdown" }); + // Force kill after 5s + const killTimer = setTimeout(() => { + instance.process?.kill("SIGKILL"); + }, 5000); + instance.process.on("exit", () => clearTimeout(killTimer)); + } + + this.instances.delete(workspaceId); + } + + stopAll(): void { + for (const id of [...this.instances.keys()]) { + this.stop(id); + } + } + + isRunning(workspaceId: string): boolean { + const instance = this.instances.get(workspaceId); + return instance?.status === "running"; + } + + getWorkspaceForViewId(viewId: string): string | undefined { + return this.viewIdToWorkspace.get(viewId); + } + + private sendToWorker(workspaceId: string, msg: MainToWorkerMessage): void { + const instance = this.instances.get(workspaceId); + if (instance?.process?.connected) { + instance.process.send(msg); + } + } + + private scheduleRestart(workspaceId: string): void { + const instance = this.instances.get(workspaceId); + if (!instance || instance.status === "stopped") return; + + const delay = Math.min( + BASE_RESTART_DELAY * 2 ** instance.restartCount, + MAX_RESTART_DELAY, + ); + instance.restartCount++; + + console.log( + `[ext-host-manager] Scheduling restart for ${workspaceId} in ${delay}ms (attempt ${instance.restartCount})`, + ); + + const timer = setTimeout(() => { + this.scheduledRestarts.delete(workspaceId); + if (instance.status === "degraded") { + this.spawn(workspaceId, instance.workspacePath).catch((err) => { + console.error( + `[ext-host-manager] Restart failed for ${workspaceId}:`, + err, + ); + }); + } + }, delay); + + this.scheduledRestarts.set(workspaceId, timer); + } +} + +// Singleton +let manager: ExtensionHostManager | null = null; + +export function getExtensionHostManager(): ExtensionHostManager { + if (!manager) { + manager = new ExtensionHostManager(); + } + return manager; +} diff --git a/apps/desktop/src/main/lib/vscode-shim/extension-host.ts b/apps/desktop/src/main/lib/vscode-shim/extension-host.ts index 5938f0713f6..5fd90c21879 100644 --- a/apps/desktop/src/main/lib/vscode-shim/extension-host.ts +++ b/apps/desktop/src/main/lib/vscode-shim/extension-host.ts @@ -1,195 +1,37 @@ /** * Extension Host: high-level API to manage VS Code extensions in Superset Desktop. + * + * In the per-workspace model, extension loading is done by individual worker processes + * managed by ExtensionHostManager. This module handles process-level setup only. */ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { commands } from "./api/commands"; -import { shimLog, shimWarn } from "./api/debug-log"; import { registerWebviewProtocol } from "./api/protocol-handler"; import { startWebviewServer, stopWebviewServer } from "./api/webview-server"; -import { setWorkspacePath } from "./api/workspace"; -import { - deactivateAll, - discoverExtensions, - getLoadedExtensions, - loadExtension, -} from "./loader"; -import type { ExtensionInfo } from "./types"; - -// Known extension IDs we support -const SUPPORTED_EXTENSIONS = new Set([ - "anthropic.claude-code", - "openai.chatgpt", -]); - -interface ExtensionHostOptions { - /** Path to VS Code extensions directory. Defaults to ~/.vscode/extensions */ - extensionsDir?: string; - /** Current workspace folder path */ - workspacePath?: string; - /** Specific extension IDs to load. Defaults to SUPPORTED_EXTENSIONS */ - extensionIds?: string[]; -} +import { getExtensionHostManager } from "./extension-host-manager"; let isInitialized = false; -export async function initExtensionHost( - options: ExtensionHostOptions = {}, -): Promise { +export async function initExtensionHost(): Promise { if (isInitialized) { - shimWarn("[vscode-shim] Extension host already initialized"); return; } - const extensionsDir = - options.extensionsDir ?? path.join(os.homedir(), ".vscode", "extensions"); - const targetIds = new Set(options.extensionIds ?? SUPPORTED_EXTENSIONS); - - if (options.workspacePath) { - setWorkspacePath(options.workspacePath); - } - // Register protocol handler for webview resources registerWebviewProtocol(); // Start HTTP server for webview content await startWebviewServer(); - // Set platform context keys (Codex checks these) - const platform = - process.platform === "darwin" - ? "darwin" - : process.platform === "win32" - ? "windows" - : "linux"; - commands.executeCommand("setContext", "os", platform); - - shimLog(`[vscode-shim] Discovering extensions in ${extensionsDir}`); - const discovered = discoverExtensions(extensionsDir); - shimLog(`[vscode-shim] Found ${discovered.length} extensions total`); - - // Filter to supported extensions, pick latest version for each - const toLoad = selectExtensions(discovered, targetIds); - shimLog( - `[vscode-shim] Loading ${toLoad.length} extensions: ${toLoad.map((e) => e.id).join(", ")}`, - ); - - // Read enabled config to skip disabled extensions - const enabledConfig = readExtensionEnabledConfig(); - - for (const ext of toLoad) { - if (enabledConfig[ext.id] === false) { - shimLog(`[vscode-shim] Skipping disabled extension: ${ext.id}`); - continue; - } - try { - await loadExtension(ext); - } catch (err) { - console.error(`[vscode-shim] Failed to load ${ext.id}:`, err); - } - } + // Initialize manager singleton + getExtensionHostManager(); isInitialized = true; } -/** Compare semver-like version strings. Returns positive if a > b. */ -function compareVersions(a: string, b: string): number { - const pa = a.split(".").map(Number); - const pb = b.split(".").map(Number); - const len = Math.max(pa.length, pb.length); - for (let i = 0; i < len; i++) { - const va = pa[i] ?? 0; - const vb = pb[i] ?? 0; - if (va !== vb) return va - vb; - } - return 0; -} - -/** Pick the latest version of each target extension */ -function selectExtensions( - all: ExtensionInfo[], - targetIds: Set, -): ExtensionInfo[] { - const byId = new Map(); - - for (const ext of all) { - if (!targetIds.has(ext.id)) continue; - - const existing = byId.get(ext.id); - if (!existing) { - byId.set(ext.id, ext); - } else { - if ( - compareVersions(ext.manifest.version, existing.manifest.version) > 0 - ) { - byId.set(ext.id, ext); - } - } - } - - return [...byId.values()]; -} - export async function shutdownExtensionHost(): Promise { - shimLog("[vscode-shim] Shutting down extension host"); - await deactivateAll(); + getExtensionHostManager().stopAll(); stopWebviewServer(); isInitialized = false; } -export function updateWorkspacePath(workspacePath: string): void { - setWorkspacePath(workspacePath); -} - -export function getActiveExtensions(): Array<{ - id: string; - isActive: boolean; -}> { - return getLoadedExtensions().map((ext) => ({ - id: ext.info.id, - isActive: ext.info.isActive, - })); -} - -/** Restart a specific extension (deactivate + re-activate) */ -export async function restartExtension(extensionId: string): Promise { - const { deactivateExtension, getLoadedExtension } = await import("./loader"); - const loaded = getLoadedExtension(extensionId); - if (!loaded) return false; - - const info = { ...loaded.info, isActive: false }; - await deactivateExtension(extensionId); - - try { - await loadExtension(info); - shimLog(`[vscode-shim] Restarted extension: ${extensionId}`); - return true; - } catch (err) { - console.error(`[vscode-shim] Failed to restart ${extensionId}:`, err); - return false; - } -} - export { isInitialized as isExtensionHostInitialized }; - -/** Read extension enabled/disabled config (shared with tRPC router) */ -function readExtensionEnabledConfig(): Record { - try { - let userDataPath: string; - try { - userDataPath = require("electron").app.getPath("userData"); - } catch { - userDataPath = path.join(os.homedir(), ".superset-desktop"); - } - const configPath = path.join( - userDataPath, - "vscode-extensions-enabled.json", - ); - if (fs.existsSync(configPath)) { - return JSON.parse(fs.readFileSync(configPath, "utf-8")); - } - } catch {} - return {}; -} diff --git a/apps/desktop/src/main/lib/vscode-shim/index.ts b/apps/desktop/src/main/lib/vscode-shim/index.ts index ac42702d40b..3d1a3d13d1b 100644 --- a/apps/desktop/src/main/lib/vscode-shim/index.ts +++ b/apps/desktop/src/main/lib/vscode-shim/index.ts @@ -15,10 +15,8 @@ export { export { clearWebviewHtml, setWebviewHtml } from "./api/webview-server"; export { handleUri, setActiveTextEditor } from "./api/window"; export { - getActiveExtensions, initExtensionHost, shutdownExtensionHost, - updateWorkspacePath, } from "./extension-host"; export { deactivateExtension, diff --git a/apps/desktop/src/main/lib/vscode-shim/ipc-types.ts b/apps/desktop/src/main/lib/vscode-shim/ipc-types.ts new file mode 100644 index 00000000000..a6303deb98f --- /dev/null +++ b/apps/desktop/src/main/lib/vscode-shim/ipc-types.ts @@ -0,0 +1,46 @@ +/** + * Typed IPC message definitions for communication between + * the main process and per-workspace extension host worker processes. + */ + +/** Messages sent FROM main process TO worker */ +export type MainToWorkerMessage = + | { type: "set-active-editor"; filePath: string | null; languageId?: string } + | { type: "set-workspace-path"; workspacePath: string } + | { + type: "resolve-webview"; + requestId: string; + viewType: string; + extensionPath: string; + } + | { type: "post-message"; viewId: string; message: unknown } + | { type: "shutdown" }; + +/** Messages sent FROM worker TO main process */ +export type WorkerToMainMessage = + | { type: "ready" } + | { + type: "webview-event"; + event: { + viewId: string; + type: "html" | "message" | "title" | "dispose" | "panel-created"; + data: unknown; + }; + } + | { + type: "resolve-webview-result"; + requestId: string; + viewId: string | null; + html: string | null; + } + | { type: "open-file"; filePath: string; line?: number } + | { + type: "show-dialog"; + requestId: string; + method: + | "showInformationMessage" + | "showWarningMessage" + | "showErrorMessage"; + message: string; + items: string[]; + }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/vscode-extensions/components/VscodeExtensionsSettings/VscodeExtensionsSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/vscode-extensions/components/VscodeExtensionsSettings/VscodeExtensionsSettings.tsx index a6703c0335c..8ca712ff0db 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/vscode-extensions/components/VscodeExtensionsSettings/VscodeExtensionsSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/vscode-extensions/components/VscodeExtensionsSettings/VscodeExtensionsSettings.tsx @@ -53,7 +53,6 @@ export function VscodeExtensionsSettings({ electronTrpc.vscodeExtensions.restartExtension.useMutation({ onSuccess: () => { utils.vscodeExtensions.getKnownExtensions.invalidate(); - utils.vscodeExtensions.getExtensions.invalidate(); }, }); const installMutation = diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/VscodeExtensionView/VscodeExtensionView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/VscodeExtensionView/VscodeExtensionView.tsx index b822ea79396..b9999ba3418 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/VscodeExtensionView/VscodeExtensionView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/VscodeExtensionView/VscodeExtensionView.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { electronTrpc } from "renderer/lib/electron-trpc"; +import { useWorkspaceId } from "../../WorkspaceIdContext"; interface VscodeExtensionViewProps { viewType: string; @@ -17,6 +18,7 @@ export function VscodeExtensionView({ extensionId, isActive, }: VscodeExtensionViewProps) { + const workspaceId = useWorkspaceId(); const iframeRef = useRef(null); const [viewId, setViewId] = useState(null); const [iframeUrl, setIframeUrl] = useState(null); @@ -29,44 +31,35 @@ export function VscodeExtensionView({ // Resolve the webview when first becoming active useEffect(() => { - if (!isActive || viewId) return; + if (!isActive || viewId || !workspaceId) return; - console.log(`[VscodeExtensionView] Resolving webview: ${viewType}`); resolveMutation.mutate( - { viewType, extensionPath: "" }, + { workspaceId, viewType, extensionPath: "" }, { onSuccess: (result) => { - console.log( - `[VscodeExtensionView] Resolve result:`, - JSON.stringify(result), - ); if (result.viewId && result.url) { setViewId(result.viewId); setIframeUrl(result.url); - console.log( - `[VscodeExtensionView] iframe URL set to: ${result.url}`, - ); } else { - console.warn(`[VscodeExtensionView] No viewId/url in result`); setError(`Extension view "${viewType}" not found`); } }, onError: (err) => { - console.error(`[VscodeExtensionView] Resolve error:`, err); setError(err.message); }, }, ); - }, [isActive, viewId, viewType, resolveMutation.mutate]); + }, [isActive, viewId, viewType, workspaceId, resolveMutation.mutate]); // Listen for messages from iframe -> forward to extension useEffect(() => { - if (!viewId) return; + if (!viewId || !workspaceId) return; const handler = (event: MessageEvent) => { if (event.source !== iframeRef.current?.contentWindow) return; if (event.data?.type === "vscode-api") { postMessageMutation.mutate({ + workspaceId, viewId, message: event.data.data, }); @@ -75,26 +68,26 @@ export function VscodeExtensionView({ window.addEventListener("message", handler); return () => window.removeEventListener("message", handler); - }, [viewId, postMessageMutation.mutate]); + }, [viewId, workspaceId, postMessageMutation.mutate]); // Subscribe to webview events (extension -> webview messages) - electronTrpc.vscodeExtensions.subscribeWebview.useSubscription(undefined, { - enabled: isActive && !!viewId, - onData: (event) => { - console.log( - `[VscodeExtensionView] Subscription event: type=${event.type}, eventViewId=${event.viewId}, myViewId=${viewId}`, - ); - if (!viewId || event.viewId !== viewId) return; - if (event.type === "message") { - iframeRef.current?.contentWindow?.postMessage( - { type: "vscode-message", data: event.data }, - "*", - ); - } - // Don't reload iframe on HTML updates - the initial load already has - // the correct content, and reloading would reset extension state + electronTrpc.vscodeExtensions.subscribeWebview.useSubscription( + { workspaceId: workspaceId ?? undefined }, + { + enabled: isActive && !!viewId && !!workspaceId, + onData: (event) => { + if (!viewId || event.viewId !== viewId) return; + if (event.type === "message") { + iframeRef.current?.contentWindow?.postMessage( + { type: "vscode-message", data: event.data }, + "*", + ); + } + // Don't reload iframe on HTML updates - the initial load already has + // the correct content, and reloading would reset extension state + }, }, - }); + ); if (error) { return ( @@ -118,10 +111,6 @@ export function VscodeExtensionView({ src={iframeUrl} className="w-full h-full border-0" sandbox="allow-scripts allow-same-origin allow-forms allow-popups" - onLoad={() => - console.log(`[VscodeExtensionView] iframe loaded: ${iframeUrl}`) - } - onError={(e) => console.error(`[VscodeExtensionView] iframe error:`, e)} title={`${extensionId} webview`} /> ); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/hooks/useActiveEditorSync.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/hooks/useActiveEditorSync.ts index fa0bb83de07..3f1c32df8f3 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/hooks/useActiveEditorSync.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/hooks/useActiveEditorSync.ts @@ -33,8 +33,12 @@ export function useActiveEditorSync() { if (worktreePath === lastWorkspacePath.current) return; lastWorkspacePath.current = worktreePath; - setWorkspacePathMutation.mutate({ workspacePath: worktreePath }); - }, [workspace?.worktreePath, setWorkspacePathMutation.mutate]); + if (!workspaceId) return; + setWorkspacePathMutation.mutate({ + workspaceId, + workspacePath: worktreePath, + }); + }, [workspace?.worktreePath, setWorkspacePathMutation.mutate, workspaceId]); // Sync active file useEffect(() => { @@ -78,7 +82,8 @@ export function useActiveEditorSync() { if (filePath !== lastFilePath.current) { lastFilePath.current = filePath; - setActiveEditorMutation.mutate({ filePath, languageId }); + if (!workspaceId) return; + setActiveEditorMutation.mutate({ workspaceId, filePath, languageId }); } }, [ workspaceId, diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/hooks/useVscodeExtensionPanelSync.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/hooks/useVscodeExtensionPanelSync.ts index 066ce5a987e..3dce0c25305 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/hooks/useVscodeExtensionPanelSync.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/hooks/useVscodeExtensionPanelSync.ts @@ -26,45 +26,48 @@ export function useVscodeExtensionPanelSync() { const workspaceId = useWorkspaceId(); const addVscodeExtensionTab = useTabsStore((s) => s.addVscodeExtensionTab); - electronTrpc.vscodeExtensions.subscribeWebview.useSubscription(undefined, { - enabled: !!workspaceId, - onData: (event) => { - if ( - event.type === "panel-created" && - workspaceId && - typeof event.data === "object" && - event.data !== null - ) { - const { viewType, title, extensionPath } = event.data as { - viewType: string; - title: string; - panelId: string; - extensionPath?: string; - }; + electronTrpc.vscodeExtensions.subscribeWebview.useSubscription( + { workspaceId: workspaceId ?? undefined }, + { + enabled: !!workspaceId, + onData: (event) => { + if ( + event.type === "panel-created" && + workspaceId && + typeof event.data === "object" && + event.data !== null + ) { + const { viewType, title, extensionPath } = event.data as { + viewType: string; + title: string; + panelId: string; + extensionPath?: string; + }; - // Derive extensionId from extensionPath or fall back to viewType - const extensionId = - (extensionPath ? extensionIdFromPath(extensionPath) : null) ?? - viewType; + // Derive extensionId from extensionPath or fall back to viewType + const extensionId = + (extensionPath ? extensionIdFromPath(extensionPath) : null) ?? + viewType; - // Dedup: skip if a vscode-extension pane with the same viewType already exists - const panes = useTabsStore.getState().panes; - const alreadyExists = Object.values(panes).some( - (pane: Pane) => - pane.type === "vscode-extension" && - pane.vscodeExtension?.viewType === viewType, - ); - if (alreadyExists) { - return; - } + // Dedup: skip if a vscode-extension pane with the same viewType already exists + const panes = useTabsStore.getState().panes; + const alreadyExists = Object.values(panes).some( + (pane: Pane) => + pane.type === "vscode-extension" && + pane.vscodeExtension?.viewType === viewType, + ); + if (alreadyExists) { + return; + } - addVscodeExtensionTab( - workspaceId, - extensionId, - viewType, - title || "Extension Panel", - ); - } + addVscodeExtensionTab( + workspaceId, + extensionId, + viewType, + title || "Extension Panel", + ); + } + }, }, - }); + ); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/hooks/useVscodeOpenFileSync.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/hooks/useVscodeOpenFileSync.ts index 7b3b27618ea..2e48c7d3923 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/hooks/useVscodeOpenFileSync.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/hooks/useVscodeOpenFileSync.ts @@ -10,15 +10,18 @@ export function useVscodeOpenFileSync() { const workspaceId = useWorkspaceId(); const addFileViewerPane = useTabsStore((s) => s.addFileViewerPane); - electronTrpc.vscodeExtensions.subscribeOpenFile.useSubscription(undefined, { - enabled: !!workspaceId, - onData: (data) => { - if (!workspaceId) return; - addFileViewerPane(workspaceId, { - filePath: data.filePath, - line: data.line, - viewMode: "raw", - }); + electronTrpc.vscodeExtensions.subscribeOpenFile.useSubscription( + { workspaceId: workspaceId ?? undefined }, + { + enabled: !!workspaceId, + onData: (data) => { + if (!workspaceId) return; + addFileViewerPane(workspaceId, { + filePath: data.filePath, + line: data.line, + viewMode: "raw", + }); + }, }, - }); + ); } From 8fe79435c6478f11c6f0fd29bac1db6726777251 Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Tue, 7 Apr 2026 02:09:26 +0900 Subject: [PATCH 381/816] fix: address self-review findings for per-workspace extension host Critical fixes: - resolveWebview now receives workspacePath (was passing empty extensionPath as workspacePath, causing extensions to start with no workspace) - Add MAX_RESTART_ATTEMPTS (5) to prevent infinite restart loops - restartExtension explicitly calls stop() then start() instead of relying on auto-restart (stop sets "stopped" status which prevents auto-restart) - getWorkspacePath() accessor added to manager Other fixes: - Remove redundant dynamic import in worker post-message handler - Import getActiveView at top of main() with other shim modules --- .../trpc/routers/vscode-extensions/index.ts | 10 +++++---- .../src/main/extension-host-worker/index.ts | 6 +---- .../lib/vscode-shim/extension-host-manager.ts | 13 +++++++++++ .../VscodeExtensionView.tsx | 22 ++++++++++++++++--- 4 files changed, 39 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/vscode-extensions/index.ts b/apps/desktop/src/lib/trpc/routers/vscode-extensions/index.ts index 7a51499dabf..1e7fba821e2 100644 --- a/apps/desktop/src/lib/trpc/routers/vscode-extensions/index.ts +++ b/apps/desktop/src/lib/trpc/routers/vscode-extensions/index.ts @@ -250,6 +250,7 @@ export const createVscodeExtensionsRouter = () => { .input( z.object({ workspaceId: z.string(), + workspacePath: z.string(), viewType: z.string(), extensionPath: z.string(), }), @@ -259,7 +260,7 @@ export const createVscodeExtensionsRouter = () => { // Start worker for this workspace if not already running if (!manager.isRunning(input.workspaceId)) { - await manager.start(input.workspaceId, input.extensionPath); + await manager.start(input.workspaceId, input.workspacePath); } const result = await manager.resolveWebview( @@ -390,12 +391,13 @@ export const createVscodeExtensionsRouter = () => { return { success: false }; } const manager = getExtensionHostManager(); - const instance = manager.isRunning(input.workspaceId); - if (!instance) { + if (!manager.isRunning(input.workspaceId)) { return { success: false }; } + // Stop then explicitly restart (stop sets "stopped" status which prevents auto-restart) + const workspacePath = manager.getWorkspacePath(input.workspaceId) ?? ""; manager.stop(input.workspaceId); - // Worker auto-restarts via scheduleRestart; caller can re-resolve webview + await manager.start(input.workspaceId, workspacePath); return { success: true }; }), diff --git a/apps/desktop/src/main/extension-host-worker/index.ts b/apps/desktop/src/main/extension-host-worker/index.ts index 16c7f610add..33fdb8cecd5 100644 --- a/apps/desktop/src/main/extension-host-worker/index.ts +++ b/apps/desktop/src/main/extension-host-worker/index.ts @@ -40,7 +40,7 @@ async function main() { const { discoverExtensions, loadExtension, deactivateAll } = await import( "../lib/vscode-shim/loader" ); - const { onWebviewEvent, resolveWebviewView } = await import( + const { getActiveView, onWebviewEvent, resolveWebviewView } = await import( "../lib/vscode-shim/api/webview" ); const { registerExtensionDefaults } = await import( @@ -185,10 +185,6 @@ async function main() { } case "post-message": { - // Find the active view and post message to it - const { getActiveView } = await import( - "../lib/vscode-shim/api/webview" - ); const view = getActiveView(msg.viewId); if (view) { const webview = view.webview as { diff --git a/apps/desktop/src/main/lib/vscode-shim/extension-host-manager.ts b/apps/desktop/src/main/lib/vscode-shim/extension-host-manager.ts index a633e010842..624459c1438 100644 --- a/apps/desktop/src/main/lib/vscode-shim/extension-host-manager.ts +++ b/apps/desktop/src/main/lib/vscode-shim/extension-host-manager.ts @@ -16,6 +16,7 @@ import type { MainToWorkerMessage, WorkerToMainMessage } from "./ipc-types"; const BASE_RESTART_DELAY = 1000; const MAX_RESTART_DELAY = 30000; +const MAX_RESTART_ATTEMPTS = 5; const READY_TIMEOUT = 15000; interface ExtensionHostProcess { @@ -347,6 +348,10 @@ export class ExtensionHostManager extends EventEmitter { return instance?.status === "running"; } + getWorkspacePath(workspaceId: string): string | undefined { + return this.instances.get(workspaceId)?.workspacePath; + } + getWorkspaceForViewId(viewId: string): string | undefined { return this.viewIdToWorkspace.get(viewId); } @@ -362,6 +367,14 @@ export class ExtensionHostManager extends EventEmitter { const instance = this.instances.get(workspaceId); if (!instance || instance.status === "stopped") return; + if (instance.restartCount >= MAX_RESTART_ATTEMPTS) { + console.error( + `[ext-host-manager] Max restart attempts (${MAX_RESTART_ATTEMPTS}) reached for ${workspaceId}, giving up`, + ); + instance.status = "stopped"; + return; + } + const delay = Math.min( BASE_RESTART_DELAY * 2 ** instance.restartCount, MAX_RESTART_DELAY, diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/VscodeExtensionView/VscodeExtensionView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/VscodeExtensionView/VscodeExtensionView.tsx index b9999ba3418..50c4e6d8ef8 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/VscodeExtensionView/VscodeExtensionView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/VscodeExtensionView/VscodeExtensionView.tsx @@ -19,6 +19,10 @@ export function VscodeExtensionView({ isActive, }: VscodeExtensionViewProps) { const workspaceId = useWorkspaceId(); + const { data: workspace } = electronTrpc.workspaces.get.useQuery( + { id: workspaceId ?? "" }, + { enabled: !!workspaceId }, + ); const iframeRef = useRef(null); const [viewId, setViewId] = useState(null); const [iframeUrl, setIframeUrl] = useState(null); @@ -31,10 +35,15 @@ export function VscodeExtensionView({ // Resolve the webview when first becoming active useEffect(() => { - if (!isActive || viewId || !workspaceId) return; + if (!isActive || viewId || !workspaceId || !workspace?.worktreePath) return; resolveMutation.mutate( - { workspaceId, viewType, extensionPath: "" }, + { + workspaceId, + workspacePath: workspace.worktreePath, + viewType, + extensionPath: "", + }, { onSuccess: (result) => { if (result.viewId && result.url) { @@ -49,7 +58,14 @@ export function VscodeExtensionView({ }, }, ); - }, [isActive, viewId, viewType, workspaceId, resolveMutation.mutate]); + }, [ + isActive, + viewId, + viewType, + workspaceId, + workspace?.worktreePath, + resolveMutation.mutate, + ]); // Listen for messages from iframe -> forward to extension useEffect(() => { From 74375049a201b55120b1b6070c7c546bbd4506e2 Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Tue, 7 Apr 2026 02:11:13 +0900 Subject: [PATCH 382/816] fix: allow clipboard-read/write in extension webview iframes --- .../RightSidebar/VscodeExtensionView/VscodeExtensionView.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/VscodeExtensionView/VscodeExtensionView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/VscodeExtensionView/VscodeExtensionView.tsx index 50c4e6d8ef8..0cb057e88f6 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/VscodeExtensionView/VscodeExtensionView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/VscodeExtensionView/VscodeExtensionView.tsx @@ -127,6 +127,7 @@ export function VscodeExtensionView({ src={iframeUrl} className="w-full h-full border-0" sandbox="allow-scripts allow-same-origin allow-forms allow-popups" + allow="clipboard-read; clipboard-write" title={`${extensionId} webview`} /> ); From 3dcdd967365cbf8d8b1441ff8621dc92a53fbbfe Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Tue, 7 Apr 2026 02:22:02 +0900 Subject: [PATCH 383/816] fix: add missing iframe permissions for extension webviews Based on analysis of both Claude Code and Codex webview code: sandbox additions: - allow-modals: alert/confirm/prompt (79 uses in Claude, 340 in Codex) - allow-downloads: download attribute (8 uses in Claude, 24 in Codex) allow additions: - microphone: Codex uses getUserMedia (5 uses) for voice input - camera: Codex uses mediaDevices (4 uses) --- .../RightSidebar/VscodeExtensionView/VscodeExtensionView.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/VscodeExtensionView/VscodeExtensionView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/VscodeExtensionView/VscodeExtensionView.tsx index 0cb057e88f6..ff732b70104 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/VscodeExtensionView/VscodeExtensionView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/VscodeExtensionView/VscodeExtensionView.tsx @@ -126,8 +126,8 @@ export function VscodeExtensionView({ ref={iframeRef} src={iframeUrl} className="w-full h-full border-0" - sandbox="allow-scripts allow-same-origin allow-forms allow-popups" - allow="clipboard-read; clipboard-write" + sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals allow-downloads" + allow="clipboard-read; clipboard-write; microphone; camera" title={`${extensionId} webview`} /> ); From e6ac799d2a1a0a43e4b3b6a8a38f39f78e0862b1 Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Tue, 7 Apr 2026 03:11:29 +0900 Subject: [PATCH 384/816] Improve Inception completions and add usage tracking --- .../ModelsSettings/ModelsSettings.tsx | 112 +++++++ .../components/CodeEditor/CodeEditor.tsx | 2 +- .../createInlineCompletionPlugin.ts | 107 ++++++- .../desktop/chat-service/chat-service.ts | 132 +++++++- .../desktop/chat-service/next-edit-usage.ts | 297 ++++++++++++++++++ .../server/desktop/chat-service/next-edit.ts | 160 +++++++++- .../chat/src/server/desktop/router/router.ts | 10 + 7 files changed, 806 insertions(+), 14 deletions(-) create mode 100644 packages/chat/src/server/desktop/chat-service/next-edit-usage.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/models/components/ModelsSettings/ModelsSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/models/components/ModelsSettings/ModelsSettings.tsx index dccbeb64399..e80b259e014 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/models/components/ModelsSettings/ModelsSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/models/components/ModelsSettings/ModelsSettings.tsx @@ -86,6 +86,8 @@ export function ModelsSettings({ visibleItems }: ModelsSettingsProps) { chatServiceTrpc.auth.getAnthropicEnvConfig.useQuery(); const { data: nextEditConfig, refetch: refetchNextEditConfig } = chatServiceTrpc.nextEdit.getConfig.useQuery(); + const { data: nextEditUsageSummary } = + chatServiceTrpc.nextEdit.getUsageSummary.useQuery(); const setAnthropicApiKeyMutation = chatServiceTrpc.auth.setAnthropicApiKey.useMutation(); const clearAnthropicApiKeyMutation = @@ -200,6 +202,19 @@ export function ModelsSettings({ visibleItems }: ModelsSettingsProps) { const clearProviderIssue = (providerId: "anthropic" | "openai") => clearProviderIssueMutation.mutateAsync({ providerId }); + const formatTokenCount = (value: number) => { + return new Intl.NumberFormat("en-US").format(value); + }; + + const formatUsd = (value: number) => { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: value >= 1 ? 2 : 4, + maximumFractionDigits: 4, + }).format(value); + }; + const saveAnthropicForm = async (nextForm = anthropicForm) => { const envText = buildAnthropicEnvText(nextForm); try { @@ -774,6 +789,103 @@ export function ModelsSettings({ visibleItems }: ModelsSettingsProps) { }} disableSave={isSavingNextEditConfig} /> +
+
+
+

+ Estimated usage +

+

+ Based on successful Inception requests sent from this + desktop app. This is a local estimate, not your exact + Inception billing total. +

+
+
+
+ Input:{" "} + {formatUsd( + nextEditUsageSummary?.pricing + .inputCostPerMillionTokensUsd ?? 0, + )} + {" / 1M"} +
+
+ Output:{" "} + {formatUsd( + nextEditUsageSummary?.pricing + .outputCostPerMillionTokensUsd ?? 0, + )} + {" / 1M"} +
+
+
+
+ {[ + ["Today", nextEditUsageSummary?.today], + ["This month", nextEditUsageSummary?.month], + ["All time", nextEditUsageSummary?.allTime], + ].map(([label, bucket]) => ( +
+
+ {label} +
+
+ {formatUsd(bucket?.estimatedCostUsd ?? 0)} +
+
+
+ Requests: {formatTokenCount(bucket?.requestCount ?? 0)} +
+
+ Input: {formatTokenCount(bucket?.promptTokens ?? 0)} +
+
+ Output:{" "} + {formatTokenCount(bucket?.completionTokens ?? 0)} +
+
+
+ ))} +
+
+ {[ + ["FIM", nextEditUsageSummary?.byEndpoint.fim], + ["Next Edit", nextEditUsageSummary?.byEndpoint.next_edit], + ].map(([label, bucket]) => ( +
+
+ {label} +
+
+ {formatUsd(bucket?.estimatedCostUsd ?? 0)} +
+
+
+ Requests: {formatTokenCount(bucket?.requestCount ?? 0)} +
+
+ Total tokens: {formatTokenCount(bucket?.totalTokens ?? 0)} +
+
+
+ ))} +
+
+ Last used:{" "} + {nextEditUsageSummary?.lastUsedAt + ? new Date(nextEditUsageSummary.lastUsedAt).toLocaleString( + "ja-JP", + ) + : "No usage yet"} +
+
{ let cancelled = false; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/CodeEditor/createInlineCompletionPlugin.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/CodeEditor/createInlineCompletionPlugin.ts index 34d427f90b6..eae17419c49 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/CodeEditor/createInlineCompletionPlugin.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/CodeEditor/createInlineCompletionPlugin.ts @@ -22,6 +22,18 @@ export type InlineCompletionRequest = ( args: InlineCompletionRequestArgs, ) => Promise; +function logInlineCompletionDebug( + message: string, + details?: Record, +): void { + if (details) { + console.log(`[InlineCompletion] ${message}`, details); + return; + } + + console.log(`[InlineCompletion] ${message}`); +} + class InlineCompletionWidget extends WidgetType { constructor(private readonly text: string) { super(); @@ -115,6 +127,9 @@ export function createInlineCompletionPlugin( private timeoutId: number | null = null; private requestId = 0; private destroyed = false; + private lastSnapshotKey: string | null = null; + private lastSnapshotSuggestion: string | null = null; + private inFlightSnapshotKey: string | null = null; constructor(private readonly view: EditorView) { this.schedule(); @@ -134,6 +149,23 @@ export function createInlineCompletionPlugin( } } + private applySuggestionAfterUpdate(suggestion: string | null) { + window.setTimeout(() => { + if (this.destroyed) { + return; + } + + if (!suggestion) { + clearInlineCompletion(this.view); + return; + } + + this.view.dispatch({ + effects: setInlineCompletionEffect.of(suggestion), + }); + }, 0); + } + private schedule() { if (this.timeoutId !== null) { window.clearTimeout(this.timeoutId); @@ -146,6 +178,11 @@ export function createInlineCompletionPlugin( !this.view.hasFocus || !selection.empty ) { + logInlineCompletionDebug("schedule skipped", { + readOnly: this.view.state.readOnly, + hasFocus: this.view.hasFocus, + selectionEmpty: selection.empty, + }); clearInlineCompletion(this.view); return; } @@ -153,13 +190,44 @@ export function createInlineCompletionPlugin( const currentRequestId = ++this.requestId; const snapshotText = this.view.state.doc.toString(); const snapshotCursor = selection.from; + const snapshotKey = `${snapshotCursor}:${snapshotText}`; + + if (this.inFlightSnapshotKey === snapshotKey) { + logInlineCompletionDebug("schedule skipped: request already in flight", { + requestId: currentRequestId, + cursorOffset: snapshotCursor, + docLength: snapshotText.length, + }); + return; + } + + if (this.lastSnapshotKey === snapshotKey) { + logInlineCompletionDebug("schedule reused cached result", { + requestId: currentRequestId, + cursorOffset: snapshotCursor, + docLength: snapshotText.length, + hasSuggestion: Boolean(this.lastSnapshotSuggestion), + }); + this.applySuggestionAfterUpdate(this.lastSnapshotSuggestion); + return; + } + + logInlineCompletionDebug("schedule request", { + requestId: currentRequestId, + cursorOffset: snapshotCursor, + docLength: snapshotText.length, + }); this.timeoutId = window.setTimeout(() => { this.timeoutId = null; + this.inFlightSnapshotKey = snapshotKey; void request({ currentFileContent: snapshotText, cursorOffset: snapshotCursor, }) .then((suggestion) => { + if (this.inFlightSnapshotKey === snapshotKey) { + this.inFlightSnapshotKey = null; + } if (this.destroyed || currentRequestId !== this.requestId) { return; } @@ -170,26 +238,49 @@ export function createInlineCompletionPlugin( latestSelection.from !== snapshotCursor || this.view.state.doc.toString() !== snapshotText ) { + logInlineCompletionDebug("response ignored: editor changed", { + requestId: currentRequestId, + selectionEmpty: latestSelection.empty, + currentCursorOffset: latestSelection.from, + expectedCursorOffset: snapshotCursor, + docChanged: + this.view.state.doc.toString() !== snapshotText, + }); return; } if (!suggestion) { + this.lastSnapshotKey = snapshotKey; + this.lastSnapshotSuggestion = null; + logInlineCompletionDebug("request returned empty", { + requestId: currentRequestId, + }); clearInlineCompletion(this.view); return; } + this.lastSnapshotKey = snapshotKey; + this.lastSnapshotSuggestion = suggestion; + logInlineCompletionDebug("request returned suggestion", { + requestId: currentRequestId, + suggestionLength: suggestion.length, + suggestionPreview: suggestion.slice(0, 120), + }); this.view.dispatch({ effects: setInlineCompletionEffect.of(suggestion), }); }) .catch((error) => { + if (this.inFlightSnapshotKey === snapshotKey) { + this.inFlightSnapshotKey = null; + } if (this.destroyed || currentRequestId !== this.requestId) { return; } - console.warn( - "[CodeEditor] Inline completion request failed:", + console.log("[InlineCompletion] request failed", { + requestId: currentRequestId, error, - ); + }); clearInlineCompletion(this.view); }); }, delayMs); @@ -221,13 +312,21 @@ export function createInlineCompletionPlugin( selection: EditorSelection.cursor(cursor + suggestion.length), effects: clearInlineCompletionEffect.of(undefined), }); + logInlineCompletionDebug("suggestion accepted", { + cursorOffset: cursor, + suggestionLength: suggestion.length, + }); return true; }, }, { key: "Escape", run(view) { - return clearInlineCompletion(view); + const cleared = clearInlineCompletion(view); + if (cleared) { + logInlineCompletionDebug("suggestion dismissed"); + } + return cleared; }, }, ]), diff --git a/packages/chat/src/server/desktop/chat-service/chat-service.ts b/packages/chat/src/server/desktop/chat-service/chat-service.ts index 67bd8b54118..27c91216887 100644 --- a/packages/chat/src/server/desktop/chat-service/chat-service.ts +++ b/packages/chat/src/server/desktop/chat-service/chat-service.ts @@ -33,7 +33,9 @@ import { setApiKeyForProvider, } from "./auth-storage-utils"; import { + buildFimRequest, buildNextEditRequest, + extractInsertTextFromFimResponse, extractInsertTextFromNextEditResponse, } from "./next-edit"; import { @@ -41,6 +43,12 @@ import { type NextEditConfig, setNextEditConfig, } from "./next-edit-config"; +import { + extractUsageEventFromResponse, + getNextEditUsageSummary, + recordNextEditUsageEvent, + type NextEditUsageSummary, +} from "./next-edit-usage"; import { OAuthFlowController, type OAuthFlowOptions, @@ -67,6 +75,7 @@ function stripAnthropicCredentialEnvVariables( interface ChatServiceOptions { anthropicEnvConfigPath?: string; nextEditConfigPath?: string; + nextEditUsagePath?: string; } export class ChatService { @@ -76,6 +85,7 @@ export class ChatService { ); private readonly anthropicEnvConfigPath: string | undefined; private readonly nextEditConfigPath: string | undefined; + private readonly nextEditUsagePath: string | undefined; private currentAnthropicRuntimeEnv: AnthropicRuntimeEnv = {}; private static readonly ANTHROPIC_AUTH_SESSION_TTL_MS = 10 * 60 * 1000; private static readonly OPENAI_AUTH_SESSION_TTL_MS = 10 * 60 * 1000; @@ -84,6 +94,7 @@ export class ChatService { constructor(options?: ChatServiceOptions) { this.anthropicEnvConfigPath = options?.anthropicEnvConfigPath; this.nextEditConfigPath = options?.nextEditConfigPath; + this.nextEditUsagePath = options?.nextEditUsagePath; const persistedConfig = getAnthropicEnvConfigFromDisk({ configPath: this.anthropicEnvConfigPath, }); @@ -403,6 +414,12 @@ export class ChatService { }); } + getNextEditUsageSummary(): NextEditUsageSummary { + return getNextEditUsageSummary({ + usagePath: this.nextEditUsagePath, + }); + } + async completeNextEdit(input: { filePath: string; currentFileContent: string; @@ -415,15 +432,91 @@ export class ChatService { }): Promise<{ insertText: string | null }> { const config = this.getNextEditConfig(); if (!config.enabled) { + console.log("[NextEditServer] request skipped: config disabled", { + filePath: input.filePath, + }); return { insertText: null }; } const credentials = getInceptionCredentialsFromAnySource(); if (!credentials) { + console.log("[NextEditServer] request skipped: missing credentials", { + filePath: input.filePath, + }); return { insertText: null }; } + const fimRequest = buildFimRequest(input, config); + console.log("[NextEditServer] fim fetch start", { + filePath: input.filePath, + endpoint: "https://api.inceptionlabs.ai/v1/fim/completions", + model: config.model, + recentSnippetCount: input.recentSnippets?.length ?? 0, + editHistoryCount: input.editHistory?.length ?? 0, + }); + const fimResponse = await fetch( + "https://api.inceptionlabs.ai/v1/fim/completions", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${credentials.apiKey}`, + }, + body: JSON.stringify(fimRequest.payload), + }, + ); + + if (fimResponse.ok) { + const json = (await fimResponse.json()) as Record; + const fimUsageEvent = extractUsageEventFromResponse({ + endpoint: "fim", + model: config.model, + response: json, + }); + if (fimUsageEvent) { + recordNextEditUsageEvent(fimUsageEvent, { + usagePath: this.nextEditUsagePath, + }); + } + console.log("[NextEditServer] fim fetch success", { + filePath: input.filePath, + responseKeys: Object.keys(json), + choiceCount: Array.isArray(json.choices) ? json.choices.length : 0, + responsePreview: JSON.stringify(json).slice(0, 500), + }); + const fimInsertText = extractInsertTextFromFimResponse({ + response: json, + suffix: fimRequest.suffix, + }); + console.log("[NextEditServer] fim completion resolved", { + filePath: input.filePath, + hasInsertText: Boolean(fimInsertText), + insertTextLength: fimInsertText?.length ?? 0, + insertTextPreview: fimInsertText?.slice(0, 160) ?? null, + }); + if (fimInsertText) { + return { + insertText: fimInsertText, + }; + } + } else { + const errorText = await fimResponse.text(); + console.log("[NextEditServer] fim fetch failed", { + filePath: input.filePath, + status: fimResponse.status, + statusText: fimResponse.statusText, + errorText, + }); + } + const request = buildNextEditRequest(input, config); + console.log("[NextEditServer] next-edit fetch start", { + filePath: input.filePath, + endpoint: "https://api.inceptionlabs.ai/v1/edit/completions", + model: config.model, + recentSnippetCount: input.recentSnippets?.length ?? 0, + editHistoryCount: input.editHistory?.length ?? 0, + }); const response = await fetch( "https://api.inceptionlabs.ai/v1/edit/completions", { @@ -438,18 +531,47 @@ export class ChatService { if (!response.ok) { const errorText = await response.text(); + console.log("[NextEditServer] next-edit fetch failed", { + filePath: input.filePath, + status: response.status, + statusText: response.statusText, + errorText, + }); throw new Error( `Next Edit request failed (${response.status}): ${errorText || response.statusText}`, ); } const json = (await response.json()) as Record; + const nextEditUsageEvent = extractUsageEventFromResponse({ + endpoint: "next_edit", + model: config.model, + response: json, + }); + if (nextEditUsageEvent) { + recordNextEditUsageEvent(nextEditUsageEvent, { + usagePath: this.nextEditUsagePath, + }); + } + console.log("[NextEditServer] next-edit fetch success", { + filePath: input.filePath, + responseKeys: Object.keys(json), + choiceCount: Array.isArray(json.choices) ? json.choices.length : 0, + responsePreview: JSON.stringify(json).slice(0, 500), + }); + const insertText = extractInsertTextFromNextEditResponse({ + response: json, + editableRegionPrefix: request.editableRegionPrefix, + editableRegionSuffix: request.editableRegionSuffix, + }); + console.log("[NextEditServer] next-edit completion resolved", { + filePath: input.filePath, + hasInsertText: Boolean(insertText), + insertTextLength: insertText?.length ?? 0, + insertTextPreview: insertText?.slice(0, 160) ?? null, + }); return { - insertText: extractInsertTextFromNextEditResponse({ - response: json, - editableRegionPrefix: request.editableRegionPrefix, - editableRegionSuffix: request.editableRegionSuffix, - }), + insertText, }; } diff --git a/packages/chat/src/server/desktop/chat-service/next-edit-usage.ts b/packages/chat/src/server/desktop/chat-service/next-edit-usage.ts new file mode 100644 index 00000000000..71a688f9d6b --- /dev/null +++ b/packages/chat/src/server/desktop/chat-service/next-edit-usage.ts @@ -0,0 +1,297 @@ +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { z } from "zod"; + +const USAGE_FILE_NAME = "chat-next-edit-usage.json"; +const MAX_USAGE_EVENTS = 5000; +const INPUT_COST_PER_MILLION_TOKENS = 0.25; +const OUTPUT_COST_PER_MILLION_TOKENS = 1.0; + +export const nextEditUsageEndpointSchema = z.enum(["fim", "next_edit"]); + +export const nextEditUsageEventSchema = z.object({ + timestamp: z.string().datetime(), + endpoint: nextEditUsageEndpointSchema, + model: z.string().min(1), + promptTokens: z.number().int().min(0), + completionTokens: z.number().int().min(0), + totalTokens: z.number().int().min(0), + cachedInputTokens: z.number().int().min(0).optional(), +}); + +export type NextEditUsageEvent = z.infer; +export type NextEditUsageEndpoint = z.infer; + +export interface NextEditUsageBucket { + requestCount: number; + promptTokens: number; + completionTokens: number; + totalTokens: number; + estimatedCostUsd: number; +} + +export interface NextEditUsageSummary { + today: NextEditUsageBucket; + month: NextEditUsageBucket; + allTime: NextEditUsageBucket; + byEndpoint: Record; + lastUsedAt: string | null; + pricing: { + inputCostPerMillionTokensUsd: number; + outputCostPerMillionTokensUsd: number; + }; +} + +interface PersistedNextEditUsage { + version: 1; + events: NextEditUsageEvent[]; +} + +interface NextEditUsageDiskOptions { + usagePath?: string; +} + +const usageBucketSchema = z.object({ + requestCount: z.number().int().min(0), + promptTokens: z.number().int().min(0), + completionTokens: z.number().int().min(0), + totalTokens: z.number().int().min(0), + estimatedCostUsd: z.number().min(0), +}); + +const nextEditUsageSummarySchema = z.object({ + today: usageBucketSchema, + month: usageBucketSchema, + allTime: usageBucketSchema, + byEndpoint: z.object({ + fim: usageBucketSchema, + next_edit: usageBucketSchema, + }), + lastUsedAt: z.string().datetime().nullable(), + pricing: z.object({ + inputCostPerMillionTokensUsd: z.number().min(0), + outputCostPerMillionTokensUsd: z.number().min(0), + }), +}); + +function createEmptyBucket(): NextEditUsageBucket { + return { + requestCount: 0, + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + estimatedCostUsd: 0, + }; +} + +function addUsageEventToBucket( + bucket: NextEditUsageBucket, + event: NextEditUsageEvent, +): void { + bucket.requestCount += 1; + bucket.promptTokens += event.promptTokens; + bucket.completionTokens += event.completionTokens; + bucket.totalTokens += event.totalTokens; + bucket.estimatedCostUsd += + (event.promptTokens / 1_000_000) * INPUT_COST_PER_MILLION_TOKENS + + (event.completionTokens / 1_000_000) * OUTPUT_COST_PER_MILLION_TOKENS; +} + +function roundUsd(value: number): number { + return Math.round(value * 1_000_000) / 1_000_000; +} + +function normalizeBucket(bucket: NextEditUsageBucket): NextEditUsageBucket { + return { + ...bucket, + estimatedCostUsd: roundUsd(bucket.estimatedCostUsd), + }; +} + +function normalizeSummary(summary: NextEditUsageSummary): NextEditUsageSummary { + return nextEditUsageSummarySchema.parse({ + ...summary, + today: normalizeBucket(summary.today), + month: normalizeBucket(summary.month), + allTime: normalizeBucket(summary.allTime), + byEndpoint: { + fim: normalizeBucket(summary.byEndpoint.fim), + next_edit: normalizeBucket(summary.byEndpoint.next_edit), + }, + }); +} + +function createEmptySummary(): NextEditUsageSummary { + return normalizeSummary({ + today: createEmptyBucket(), + month: createEmptyBucket(), + allTime: createEmptyBucket(), + byEndpoint: { + fim: createEmptyBucket(), + next_edit: createEmptyBucket(), + }, + lastUsedAt: null, + pricing: { + inputCostPerMillionTokensUsd: INPUT_COST_PER_MILLION_TOKENS, + outputCostPerMillionTokensUsd: OUTPUT_COST_PER_MILLION_TOKENS, + }, + }); +} + +export function getNextEditUsagePath( + options?: NextEditUsageDiskOptions, +): string { + if (options?.usagePath) return options.usagePath; + const supersetHome = + process.env.SUPERSET_HOME_DIR?.trim() || join(homedir(), ".superset"); + return join(supersetHome, USAGE_FILE_NAME); +} + +function readPersistedNextEditUsage( + options?: NextEditUsageDiskOptions, +): PersistedNextEditUsage | null { + const usagePath = getNextEditUsagePath(options); + if (!existsSync(usagePath)) return null; + + try { + const parsed = JSON.parse( + readFileSync(usagePath, "utf-8"), + ) as Partial; + const events = z.array(nextEditUsageEventSchema).safeParse(parsed.events); + if (parsed.version !== 1 || !events.success) { + return null; + } + + return { + version: 1, + events: events.data, + }; + } catch (error) { + console.warn("[chat-service][next-edit-usage] Failed to read usage log.", { + usagePath, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } +} + +function writePersistedNextEditUsage( + persisted: PersistedNextEditUsage, + options?: NextEditUsageDiskOptions, +): void { + const usagePath = getNextEditUsagePath(options); + const dir = dirname(usagePath); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + writeFileSync(usagePath, JSON.stringify(persisted, null, 2), "utf-8"); + chmodSync(usagePath, 0o600); +} + +export function recordNextEditUsageEvent( + event: NextEditUsageEvent, + options?: NextEditUsageDiskOptions, +): void { + const normalizedEvent = nextEditUsageEventSchema.parse(event); + const persisted = readPersistedNextEditUsage(options) ?? { + version: 1 as const, + events: [], + }; + const nextEvents = [...persisted.events, normalizedEvent].slice( + -MAX_USAGE_EVENTS, + ); + writePersistedNextEditUsage( + { + version: 1, + events: nextEvents, + }, + options, + ); +} + +export function getNextEditUsageSummary( + options?: NextEditUsageDiskOptions, +): NextEditUsageSummary { + const persisted = readPersistedNextEditUsage(options); + if (!persisted || persisted.events.length === 0) { + return createEmptySummary(); + } + + const now = new Date(); + const todayKey = now.toISOString().slice(0, 10); + const monthKey = todayKey.slice(0, 7); + const summary: NextEditUsageSummary = { + today: createEmptyBucket(), + month: createEmptyBucket(), + allTime: createEmptyBucket(), + byEndpoint: { + fim: createEmptyBucket(), + next_edit: createEmptyBucket(), + }, + lastUsedAt: persisted.events[persisted.events.length - 1]?.timestamp ?? null, + pricing: { + inputCostPerMillionTokensUsd: INPUT_COST_PER_MILLION_TOKENS, + outputCostPerMillionTokensUsd: OUTPUT_COST_PER_MILLION_TOKENS, + }, + }; + + for (const event of persisted.events) { + addUsageEventToBucket(summary.allTime, event); + addUsageEventToBucket(summary.byEndpoint[event.endpoint], event); + const eventDate = event.timestamp.slice(0, 10); + if (eventDate === todayKey) { + addUsageEventToBucket(summary.today, event); + } + if (eventDate.startsWith(monthKey)) { + addUsageEventToBucket(summary.month, event); + } + } + + return normalizeSummary(summary); +} + +export function extractUsageEventFromResponse(args: { + endpoint: NextEditUsageEndpoint; + model: string; + response: Record; + now?: Date; +}): NextEditUsageEvent | null { + const usage = + typeof args.response.usage === "object" && args.response.usage !== null + ? (args.response.usage as Record) + : null; + if (!usage) { + return null; + } + + const promptTokens = + typeof usage.prompt_tokens === "number" ? usage.prompt_tokens : null; + const completionTokens = + typeof usage.completion_tokens === "number" ? usage.completion_tokens : null; + const totalTokens = + typeof usage.total_tokens === "number" ? usage.total_tokens : null; + + if (promptTokens === null || completionTokens === null || totalTokens === null) { + return null; + } + + const cachedInputTokens = + typeof usage.cached_input_tokens === "number" + ? usage.cached_input_tokens + : undefined; + + return nextEditUsageEventSchema.parse({ + timestamp: (args.now ?? new Date()).toISOString(), + endpoint: args.endpoint, + model: args.model, + promptTokens, + completionTokens, + totalTokens, + cachedInputTokens, + }); +} diff --git a/packages/chat/src/server/desktop/chat-service/next-edit.ts b/packages/chat/src/server/desktop/chat-service/next-edit.ts index 28269f83ef6..0e7483849aa 100644 --- a/packages/chat/src/server/desktop/chat-service/next-edit.ts +++ b/packages/chat/src/server/desktop/chat-service/next-edit.ts @@ -17,9 +17,38 @@ export interface NextEditResolvedRequest { editableRegionSuffix: string; } +export interface FimResolvedRequest { + payload: Record; + suffix: string; +} + const RECENT_SNIPPET_LIMIT = 5; const EDITABLE_REGION_PREVIOUS_LINES = 5; const EDITABLE_REGION_NEXT_LINES = 10; +const FIM_PREFIX_MAX_CHARS = 6000; +const FIM_SUFFIX_MAX_CHARS = 3000; +const FIM_MAX_TOKENS = 512; +const FIM_TEMPERATURE = 0.0; +const FIM_TOP_P = 1.0; +const FIM_PRESENCE_PENALTY = 1.5; +const INLINE_COMPLETION_INSTRUCTION = [ + "You are generating an inline tab completion for a code editor.", + "Prefer preserving all existing code exactly.", + "When possible, continue by appending code at the cursor instead of rewriting earlier text.", + "Return the updated <|code_to_edit|> region in triple backticks.", +].join(" "); + +function logNextEditServer( + message: string, + details?: Record, +): void { + if (details) { + console.log(`[NextEditServer] ${message}`, details); + return; + } + + console.log(`[NextEditServer] ${message}`); +} function getLineStartOffsets(content: string): number[] { const offsets = [0]; @@ -142,6 +171,92 @@ function extractTextResponse(response: Record): string { return ""; } +function normalizeGeneratedText(text: string): string { + const fencedMatch = text.match(/```(?:[\w-]+)?\n([\s\S]*?)\n?```/); + return (fencedMatch?.[1] ?? text) + .replaceAll("<|cursor|>", "") + .replace(/\r\n/g, "\n"); +} + +function stripSuffixOverlap(insertText: string, suffix: string): string { + const maxOverlap = Math.min(insertText.length, suffix.length); + for (let size = maxOverlap; size > 0; size -= 1) { + if (insertText.endsWith(suffix.slice(0, size))) { + return insertText.slice(0, insertText.length - size); + } + } + + return insertText; +} + +export function buildFimRequest( + input: NextEditRequestInput, + config: NextEditConfig, +): FimResolvedRequest { + const cursorOffset = clampCursorOffset( + input.currentFileContent, + input.cursorOffset, + ); + const prompt = input.currentFileContent.slice( + Math.max(0, cursorOffset - FIM_PREFIX_MAX_CHARS), + cursorOffset, + ); + const suffix = input.currentFileContent.slice( + cursorOffset, + Math.min(input.currentFileContent.length, cursorOffset + FIM_SUFFIX_MAX_CHARS), + ); + + const payload: Record = { + model: config.model, + prompt, + suffix, + max_tokens: Math.min(config.maxTokens, FIM_MAX_TOKENS), + temperature: FIM_TEMPERATURE, + top_p: FIM_TOP_P, + presence_penalty: FIM_PRESENCE_PENALTY, + }; + + if (config.stop.length > 0) { + payload.stop = config.stop; + } + + logNextEditServer("fim request built", { + filePath: input.filePath, + cursorOffset, + promptLength: prompt.length, + suffixLength: suffix.length, + model: config.model, + maxTokens: Math.min(config.maxTokens, FIM_MAX_TOKENS), + }); + + return { + payload, + suffix, + }; +} + +export function extractInsertTextFromFimResponse(args: { + response: Record; + suffix: string; +}): string | null { + const rawContent = extractTextResponse(args.response); + if (!rawContent.trim()) { + logNextEditServer("fim parser returned null: empty raw content", { + responseKeys: Object.keys(args.response), + }); + return null; + } + + const candidate = normalizeGeneratedText(rawContent); + const insertText = stripSuffixOverlap(candidate, args.suffix); + logNextEditServer("fim parser completed", { + candidateLength: candidate.length, + insertTextLength: insertText.length, + insertTextPreview: insertText.slice(0, 160), + }); + return insertText.length > 0 ? insertText : null; +} + export function buildNextEditRequest( input: NextEditRequestInput, config: NextEditConfig, @@ -177,6 +292,8 @@ export function buildNextEditRequest( const fileSuffix = input.currentFileContent.slice(editableRegionEnd); const content = [ + INLINE_COMPLETION_INSTRUCTION, + "", buildRecentlyViewedSnippets(input.recentSnippets), "", "<|current_file_content|>", @@ -203,6 +320,22 @@ export function buildNextEditRequest( payload.stop = config.stop; } + logNextEditServer("request built", { + filePath: input.filePath, + cursorOffset, + recentSnippetCount: input.recentSnippets?.length ?? 0, + editHistoryCount: input.editHistory?.length ?? 0, + editableRegionPrefixLength: editableRegionPrefix.length, + editableRegionSuffixLength: editableRegionSuffix.length, + model: config.model, + maxTokens: config.maxTokens, + temperature: config.temperature, + topP: config.topP, + presencePenalty: config.presencePenalty, + stopCount: config.stop.length, + payloadPreview: String(content).slice(0, 400), + }); + return { payload, editableRegionPrefix, @@ -217,13 +350,13 @@ export function extractInsertTextFromNextEditResponse(args: { }): string | null { const rawContent = extractTextResponse(args.response); if (!rawContent.trim()) { + logNextEditServer("parser returned null: empty raw content", { + responseKeys: Object.keys(args.response), + }); return null; } - const fencedMatch = rawContent.match(/```(?:[\w-]+)?\n([\s\S]*?)\n?```/); - const candidate = (fencedMatch?.[1] ?? rawContent) - .replaceAll("<|cursor|>", "") - .replace(/\r\n/g, "\n"); + const candidate = normalizeGeneratedText(rawContent); if ( candidate.startsWith(args.editableRegionPrefix) && @@ -233,6 +366,11 @@ export function extractInsertTextFromNextEditResponse(args: { args.editableRegionPrefix.length, candidate.length - args.editableRegionSuffix.length, ); + logNextEditServer("parser matched editable region", { + candidateLength: candidate.length, + insertTextLength: insertText.length, + insertTextPreview: insertText.slice(0, 160), + }); return insertText.length > 0 ? insertText : null; } @@ -244,8 +382,22 @@ export function extractInsertTextFromNextEditResponse(args: { 0, candidate.length - args.editableRegionSuffix.length, ); + logNextEditServer("parser matched suffix-only region", { + candidateLength: candidate.length, + insertTextLength: insertText.length, + insertTextPreview: insertText.slice(0, 160), + }); return insertText.length > 0 ? insertText : null; } + logNextEditServer("parser returned null: region mismatch", { + candidatePreview: candidate.slice(0, 200), + candidateLength: candidate.length, + editableRegionPrefixPreview: args.editableRegionPrefix.slice(-120), + editableRegionPrefixLength: args.editableRegionPrefix.length, + editableRegionSuffixPreview: args.editableRegionSuffix.slice(0, 120), + editableRegionSuffixLength: args.editableRegionSuffix.length, + }); + return null; } diff --git a/packages/chat/src/server/desktop/router/router.ts b/packages/chat/src/server/desktop/router/router.ts index 32964eceac8..49f7073b00d 100644 --- a/packages/chat/src/server/desktop/router/router.ts +++ b/packages/chat/src/server/desktop/router/router.ts @@ -192,6 +192,9 @@ export function createChatServiceRouter(service: ChatService) { getConfig: t.procedure.query(() => { return service.getNextEditConfig(); }), + getUsageSummary: t.procedure.query(() => { + return service.getNextEditUsageSummary(); + }), setConfig: t.procedure .input(nextEditConfigSchema) .mutation(({ input }) => { @@ -200,6 +203,13 @@ export function createChatServiceRouter(service: ChatService) { complete: t.procedure .input(nextEditCompletionInput) .mutation(({ input }) => { + console.log("[NextEditRouter] complete called", { + filePath: input.filePath, + cursorOffset: input.cursorOffset, + contentLength: input.currentFileContent.length, + recentSnippetCount: input.recentSnippets?.length ?? 0, + editHistoryCount: input.editHistory?.length ?? 0, + }); return service.completeNextEdit(input); }), }), From 81e11572ce80ff63cdd2c8d0cead82023652ead3 Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Tue, 7 Apr 2026 03:20:27 +0900 Subject: [PATCH 385/816] Refine Next Edit prompt and logging --- .../hooks/useNextEditCompletion.ts | 75 ++++++++++++++++++- .../server/desktop/chat-service/next-edit.ts | 2 + 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useNextEditCompletion.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useNextEditCompletion.ts index f9d10067665..d029fb3dc0c 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useNextEditCompletion.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useNextEditCompletion.ts @@ -17,6 +17,18 @@ const SNIPPET_CONTEXT_BEFORE_LINES = 10; const SNIPPET_CONTEXT_AFTER_LINES = 9; const EDIT_HISTORY_FLUSH_DELAY_MS = 900; +function logNextEditDebug( + message: string, + details?: Record, +): void { + if (details) { + console.log(`[NextEdit] ${message}`, details); + return; + } + + console.log(`[NextEdit] ${message}`); +} + function getLineStartOffsets(content: string): number[] { const offsets = [0]; for (let index = 0; index < content.length; index += 1) { @@ -153,6 +165,22 @@ export function useNextEditCompletion({ activeFilePathRef.current = filePath; }, [filePath]); + useEffect(() => { + logNextEditDebug("state updated", { + filePath, + enabled: nextEditConfig?.enabled ?? false, + authenticated: inceptionStatus?.authenticated ?? false, + isAvailable, + model: nextEditConfig?.model ?? null, + }); + }, [ + filePath, + inceptionStatus?.authenticated, + isAvailable, + nextEditConfig?.enabled, + nextEditConfig?.model, + ]); + const commitEditHistoryEntry = useCallback((nextContent: string) => { const previousContent = committedContentRef.current; if (previousContent === nextContent) { @@ -175,6 +203,10 @@ export function useNextEditCompletion({ editHistoryRef.current = [...editHistoryRef.current, diff].slice( -EDIT_HISTORY_LIMIT, ); + logNextEditDebug("edit history updated", { + filePath: activeFilePathRef.current, + editHistoryCount: editHistoryRef.current.length, + }); }, []); const flushPendingEditHistory = useCallback(() => { @@ -199,6 +231,10 @@ export function useNextEditCompletion({ window.clearTimeout(pendingFlushTimerRef.current); pendingFlushTimerRef.current = null; } + logNextEditDebug("document snapshot synced", { + filePath, + contentLength: content.length, + }); }, [filePath], ); @@ -237,6 +273,11 @@ export function useNextEditCompletion({ cursorOffset: number; }) => { if (!isAvailable) { + logNextEditDebug("request skipped: unavailable", { + filePath, + enabled: nextEditConfig?.enabled ?? false, + authenticated: inceptionStatus?.authenticated ?? false, + }); return null; } @@ -255,6 +296,19 @@ export function useNextEditCompletion({ nextSnippet, ].slice(-RECENT_SNIPPET_LIMIT); } + logNextEditDebug("request started", { + filePath, + cursorOffset, + contentLength: currentFileContent.length, + recentSnippetCount: recentSnippetsRef.current.length, + editHistoryCount: editHistoryRef.current.length, + recentSnippetKeys: recentSnippetsRef.current.map( + (snippet) => snippet.key, + ), + editHistoryPreview: editHistoryRef.current + .slice(-2) + .map((entry) => entry.slice(0, 160)), + }); const result = await completeMutation.mutateAsync({ filePath, @@ -266,13 +320,30 @@ export function useNextEditCompletion({ })), editHistory: editHistoryRef.current, }); + logNextEditDebug("request completed", { + filePath, + hasInsertText: Boolean(result.insertText), + insertTextLength: result.insertText?.length ?? 0, + insertTextPreview: result.insertText?.slice(0, 120) ?? null, + cursorOffset, + }); return result.insertText; } catch (error) { - console.warn("[FileViewerPane] Next Edit request failed:", error); + console.log("[NextEdit] request failed", { + filePath, + error, + }); return null; } }, - [completeMutation, filePath, flushPendingEditHistory, isAvailable], + [ + completeMutation, + filePath, + flushPendingEditHistory, + inceptionStatus?.authenticated, + isAvailable, + nextEditConfig?.enabled, + ], ); return { diff --git a/packages/chat/src/server/desktop/chat-service/next-edit.ts b/packages/chat/src/server/desktop/chat-service/next-edit.ts index 0e7483849aa..3b553b5dbb5 100644 --- a/packages/chat/src/server/desktop/chat-service/next-edit.ts +++ b/packages/chat/src/server/desktop/chat-service/next-edit.ts @@ -35,6 +35,8 @@ const INLINE_COMPLETION_INSTRUCTION = [ "You are generating an inline tab completion for a code editor.", "Prefer preserving all existing code exactly.", "When possible, continue by appending code at the cursor instead of rewriting earlier text.", + "When adding a new object literal with an id field, prefer generating a fresh unique UUID instead of copying a nearby UUID.", + "Do not reuse an adjacent UUID unless the code clearly indicates it should refer to the same existing entity.", "Return the updated <|code_to_edit|> region in triple backticks.", ].join(" "); From 4ed0747f68ad44148584bacf70f1f11fb2f450d4 Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Tue, 7 Apr 2026 03:29:50 +0900 Subject: [PATCH 386/816] Fix branch selector ref normalization --- .../routers/changes/security/git-commands.ts | 51 +++++++++++++++---- .../ChangesHeader/ChangesHeader.tsx | 27 ++++++++-- 2 files changed, 64 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts b/apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts index eaad2fbc5b3..7c8cca03bdd 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts @@ -22,6 +22,23 @@ async function getGitWithShellPath(worktreePath: string) { return getSimpleGitWithShellPath(worktreePath); } +function normalizeBranchName(branch: string): string { + const trimmed = branch.trim(); + if (trimmed.startsWith("refs/heads/")) { + return trimmed.slice("refs/heads/".length); + } + if (trimmed.startsWith("refs/remotes/origin/")) { + return trimmed.slice("refs/remotes/origin/".length); + } + if (trimmed.startsWith("remotes/origin/")) { + return trimmed.slice("remotes/origin/".length); + } + if (trimmed.startsWith("origin/")) { + return trimmed.slice("origin/".length); + } + return trimmed; +} + function assertValidBranchName(branch: string): void { // Validate: reject anything that looks like a flag if (branch.startsWith("-")) { @@ -72,22 +89,23 @@ export async function gitSwitchBranch( branch: string, ): Promise { assertRegisteredWorktree(worktreePath); - assertValidBranchName(branch); + const normalizedBranch = normalizeBranchName(branch); + assertValidBranchName(normalizedBranch); const git = await getGitWithShellPath(worktreePath); await runWithPostCheckoutHookTolerance({ - context: `Switched branch to "${branch}" in ${worktreePath}`, + context: `Switched branch to "${normalizedBranch}" in ${worktreePath}`, run: async () => { const localBranches = await git.branchLocal(); - if (localBranches.all.includes(branch)) { + if (localBranches.all.includes(normalizedBranch)) { try { - await git.raw(["switch", branch]); + await git.raw(["switch", normalizedBranch]); return; } catch (switchError) { const errorMessage = String(switchError); if (errorMessage.includes("is not a git command")) { - await git.checkout(branch); + await git.checkout(normalizedBranch); return; } throw switchError; @@ -95,15 +113,26 @@ export async function gitSwitchBranch( } const remoteBranches = await git.branch(["-r"]); - const remoteBranch = `origin/${branch}`; + const remoteBranch = `origin/${normalizedBranch}`; if (remoteBranches.all.includes(remoteBranch)) { try { - await git.raw(["switch", "--track", "-c", branch, remoteBranch]); + await git.raw([ + "switch", + "--track", + "-c", + normalizedBranch, + remoteBranch, + ]); return; } catch (switchError) { const errorMessage = String(switchError); if (errorMessage.includes("is not a git command")) { - await git.checkout(["-b", branch, "--track", remoteBranch]); + await git.checkout([ + "-b", + normalizedBranch, + "--track", + remoteBranch, + ]); return; } throw switchError; @@ -112,7 +141,7 @@ export async function gitSwitchBranch( try { // Prefer `git switch` - unambiguous branch operation (git 2.23+) - await git.raw(["switch", branch]); + await git.raw(["switch", normalizedBranch]); } catch (switchError) { // Check if it's because `switch` command doesn't exist (old git < 2.23) // Git outputs: "git: 'switch' is not a git command. See 'git --help'." @@ -120,14 +149,14 @@ export async function gitSwitchBranch( if (errorMessage.includes("is not a git command")) { // Fallback for older git versions // Note: checkout WITHOUT -- is correct for branches - await git.checkout(branch); + await git.checkout(normalizedBranch); } else { throw switchError; } } }, didSucceed: async () => - isCurrentBranch({ worktreePath, expectedBranch: branch }), + isCurrentBranch({ worktreePath, expectedBranch: normalizedBranch }), }); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx index dabe4c3d9ea..ea6ab88515b 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx @@ -116,6 +116,24 @@ interface SearchableRefItem { checkedOutPath: string | null; } +function normalizeBranchName(branch: string | null | undefined): string | null { + const trimmed = branch?.trim(); + if (!trimmed) return null; + if (trimmed.startsWith("refs/heads/")) { + return trimmed.slice("refs/heads/".length); + } + if (trimmed.startsWith("refs/remotes/origin/")) { + return trimmed.slice("refs/remotes/origin/".length); + } + if (trimmed.startsWith("remotes/origin/")) { + return trimmed.slice("remotes/origin/".length); + } + if (trimmed.startsWith("origin/")) { + return trimmed.slice("origin/".length); + } + return trimmed; +} + function isCheckedOutElsewhereMessage(message: string): boolean { const normalized = message.toLowerCase(); return ( @@ -532,7 +550,9 @@ function CurrentBranchSelector({ }); const effectiveCurrentBranch = - currentBranch ?? branchData?.currentBranch ?? null; + normalizeBranchName(currentBranch) ?? + normalizeBranchName(branchData?.currentBranch) ?? + null; const effectiveBaseBranch = branchData?.worktreeBaseBranch ?? branchData?.defaultBranch ?? "main"; const existingBranchNames = useMemo( @@ -594,11 +614,12 @@ function CurrentBranchSelector({ ); const handleBranchSelect = (branch: string) => { + const normalizedBranch = normalizeBranchName(branch) ?? branch; const target = { action: "switch" as const, - branch, + branch: normalizedBranch, }; - if (branch === effectiveCurrentBranch) { + if (normalizedBranch === effectiveCurrentBranch) { setOpen(false); return; } From 6d3d3a3686a8d0212331017257dd6afc360ca31f Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Tue, 7 Apr 2026 03:56:17 +0900 Subject: [PATCH 387/816] Fix VS Code extension restart and host state --- .../trpc/routers/vscode-extensions/index.ts | 25 +- .../lib/vscode-shim/extension-host-manager.ts | 37 +- demo.html | 584 ------------------ 3 files changed, 53 insertions(+), 593 deletions(-) delete mode 100644 demo.html diff --git a/apps/desktop/src/lib/trpc/routers/vscode-extensions/index.ts b/apps/desktop/src/lib/trpc/routers/vscode-extensions/index.ts index 1e7fba821e2..5d31dfae2be 100644 --- a/apps/desktop/src/lib/trpc/routers/vscode-extensions/index.ts +++ b/apps/desktop/src/lib/trpc/routers/vscode-extensions/index.ts @@ -235,12 +235,16 @@ export const createVscodeExtensionsRouter = () => { return router({ /** Get all known extensions with their install/active status */ getKnownExtensions: publicProcedure.query(() => { + const manager = getExtensionHostManager(); + const hasRunningExtensionHost = manager.getRunningWorkspaceIds().length > 0; return KNOWN_EXTENSIONS.map((ext) => { + const installed = isExtensionInstalled(ext.id); + const enabled = isExtensionEnabled(ext.id); return { ...ext, - installed: isExtensionInstalled(ext.id), - enabled: isExtensionEnabled(ext.id), - active: false, + installed, + enabled, + active: installed && enabled && hasRunningExtensionHost, }; }); }), @@ -388,7 +392,20 @@ export const createVscodeExtensionsRouter = () => { ) .mutation(async ({ input }) => { if (!input.workspaceId) { - return { success: false }; + const manager = getExtensionHostManager(); + const runningWorkspaceIds = manager.getRunningWorkspaceIds(); + if (runningWorkspaceIds.length === 0) { + return { success: false }; + } + + await Promise.all( + runningWorkspaceIds.map(async (workspaceId) => { + const workspacePath = manager.getWorkspacePath(workspaceId) ?? ""; + manager.stop(workspaceId); + await manager.start(workspaceId, workspacePath); + }), + ); + return { success: true }; } const manager = getExtensionHostManager(); if (!manager.isRunning(input.workspaceId)) { diff --git a/apps/desktop/src/main/lib/vscode-shim/extension-host-manager.ts b/apps/desktop/src/main/lib/vscode-shim/extension-host-manager.ts index 624459c1438..742b326ba60 100644 --- a/apps/desktop/src/main/lib/vscode-shim/extension-host-manager.ts +++ b/apps/desktop/src/main/lib/vscode-shim/extension-host-manager.ts @@ -35,6 +35,7 @@ interface PendingResolve { export class ExtensionHostManager extends EventEmitter { private instances = new Map(); + private startPromises = new Map>(); private pendingResolves = new Map(); private scheduledRestarts = new Map>(); private viewIdToWorkspace = new Map(); @@ -64,18 +65,37 @@ export class ExtensionHostManager extends EventEmitter { } async start(workspaceId: string, workspacePath: string): Promise { - // If already running, just update workspace path const existing = this.instances.get(workspaceId); - if (existing?.status === "running" && existing.process) { + if ( + existing && + (existing.status === "running" || existing.status === "starting") + ) { + existing.workspacePath = workspacePath; this.sendToWorker(workspaceId, { type: "set-workspace-path", workspacePath, }); - existing.workspacePath = workspacePath; - return; + const inFlightStart = this.startPromises.get(workspaceId); + if (inFlightStart) { + return inFlightStart; + } + if (existing.status === "running" && existing.process) { + return; + } } - await this.spawn(workspaceId, workspacePath); + const inFlightStart = this.startPromises.get(workspaceId); + if (inFlightStart) { + return inFlightStart; + } + + const startPromise = this.spawn(workspaceId, workspacePath).finally(() => { + if (this.startPromises.get(workspaceId) === startPromise) { + this.startPromises.delete(workspaceId); + } + }); + this.startPromises.set(workspaceId, startPromise); + await startPromise; } private async spawn( @@ -316,6 +336,7 @@ export class ExtensionHostManager extends EventEmitter { if (!instance) return; instance.status = "stopped"; + this.startPromises.delete(workspaceId); // Cancel scheduled restart const restartTimer = this.scheduledRestarts.get(workspaceId); @@ -356,6 +377,12 @@ export class ExtensionHostManager extends EventEmitter { return this.viewIdToWorkspace.get(viewId); } + getRunningWorkspaceIds(): string[] { + return [...this.instances.entries()] + .filter(([, instance]) => instance.status === "running") + .map(([workspaceId]) => workspaceId); + } + private sendToWorker(workspaceId: string, msg: MainToWorkerMessage): void { const instance = this.instances.get(workspaceId); if (instance?.process?.connected) { diff --git a/demo.html b/demo.html deleted file mode 100644 index a7c0e2c0bae..00000000000 --- a/demo.html +++ /dev/null @@ -1,584 +0,0 @@ - - - - - - Superset Next Edit Demo - - - -
-
-
-

Next Edit Tab 補完モック

-

- Superset Desktop の raw editor 上で、Inception Next Edit を ghost text で表示し、Tab で accept、Esc で dismiss する想定のデモです。 -

-
-
UI: inline ghost text
-
Trigger: 入力停止 350ms 後
-
Model: mercury-edit-2
-
-
- -
-
-
- apps/desktop/src/demo/useNextEditCompletion.ts - Suggestion is scoped to the editable region around the cursor. -
-
-
Suggestion ready
-
-
-
-
-

-              
- - -
-
-
-
- -
- 期待する見え方 -
- 提案テキストは現在カーソル位置の直後に薄く重なって表示されます。カーソルより先の既存コードを書き換える提案は ghost text として採用しません。insert だけ許可するので、Tab accept の挙動が安全です。 -
-
-
- - -
- - - - From 7b2af359b28988fd777a160f6cc7498959620e40 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sun, 5 Apr 2026 19:12:24 -0700 Subject: [PATCH 388/816] fix(desktop): resolve file icons from origin instead of href (#3199) Using location.href as the base URL caused icon paths to resolve relative to the current route (e.g. /v2-workspace/abc123/file-icons/...) instead of the root, resulting in broken icons on nested routes. --- .../RightSidebar/FilesView/utils/resolveFileIconAssetUrl.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils/resolveFileIconAssetUrl.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils/resolveFileIconAssetUrl.ts index 318bc2dff1c..1a945e5ddf2 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils/resolveFileIconAssetUrl.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils/resolveFileIconAssetUrl.ts @@ -2,7 +2,7 @@ const FILE_ICONS_DIR = "file-icons"; export function resolveFileIconAssetUrl( iconName: string, - baseHref = globalThis.location?.href ?? "http://localhost/", + baseHref = globalThis.location?.origin ?? "http://localhost", ): string { return new URL(`${FILE_ICONS_DIR}/${iconName}.svg`, baseHref).toString(); } From b1eecb444d1707e08de4f2980db4f01f01b150ab Mon Sep 17 00:00:00 2001 From: Kiet <31864905+Kitenite@users.noreply.github.com> Date: Sun, 5 Apr 2026 19:44:09 -0700 Subject: [PATCH 389/816] V2 top bar: right sidebar toggle, org dropdown in sidebar, unified open-in button (#3197) * Add toggle button * Remove workspaces view * Move org * Unify open-in button, move org dropdown to sidebar, add repository button - Replace v1/v2 open-in button branching with unified WorkspaceOpenInButton - Move OrganizationDropdown from top bar to sidebar header (v2 cloud) - Add folder+ "Add Repository" button next to org dropdown in sidebar - Org dropdown expands to fill available width with truncation * new ws button * Clean up ui * Rename and icon * Lint * Open in * 2 buttons * Clean up --- .../DashboardSidebarHeader.tsx | 59 ++--- .../DashboardSidebarProjectRow.tsx | 64 ++---- .../DashboardSidebarWorkspaceIcon.tsx | 4 +- .../_dashboard/components/TopBar/TopBar.tsx | 10 +- .../OrganizationDropdown.tsx | 77 +++++-- .../RightSidebarToggle/RightSidebarToggle.tsx | 55 +++++ .../components/RightSidebarToggle/index.ts | 1 + .../V2OpenInMenuButton/V2OpenInMenuButton.tsx | 206 ++++++++++++++++-- .../V2WorkspaceOpenInButton.tsx | 18 +- .../dashboardSidebarLocal/schema.ts | 1 + 10 files changed, 370 insertions(+), 125 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/RightSidebarToggle/RightSidebarToggle.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/RightSidebarToggle/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsx index 7afa1861c8f..0e2e180d51e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsx @@ -1,12 +1,9 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; -import { useMatchRoute, useNavigate } from "@tanstack/react-router"; -import { LuLayers, LuPlus } from "react-icons/lu"; +import { LuFolderPlus, LuPlus } from "react-icons/lu"; import { useHotkeyDisplay } from "renderer/hotkeys"; -import { - STROKE_WIDTH, - STROKE_WIDTH_THICK, -} from "renderer/screens/main/components/WorkspaceSidebar/constants"; +import { OrganizationDropdown } from "renderer/routes/_authenticated/_dashboard/components/TopBar/components/OrganizationDropdown"; +import { STROKE_WIDTH_THICK } from "renderer/screens/main/components/WorkspaceSidebar/constants"; import { useOpenNewWorkspaceModal } from "renderer/stores/new-workspace-modal"; interface DashboardSidebarHeaderProps { @@ -16,35 +13,24 @@ interface DashboardSidebarHeaderProps { export function DashboardSidebarHeader({ isCollapsed = false, }: DashboardSidebarHeaderProps) { - const navigate = useNavigate(); - const matchRoute = useMatchRoute(); const openModal = useOpenNewWorkspaceModal(); const shortcutText = useHotkeyDisplay("NEW_WORKSPACE").text; - const isWorkspacesPageOpen = !!matchRoute({ to: "/v2-workspaces" }); - - const handleWorkspacesClick = () => { - navigate({ to: "/v2-workspaces" }); - }; if (isCollapsed) { return (
+ + - Workspaces + Add Repository @@ -67,21 +53,22 @@ export function DashboardSidebarHeader({ return (
- + + + + + Add Repository + +
- )} -
+ {!isRenaming && ( + + ({totalWorkspaceCount}) + + )} @@ -125,24 +121,6 @@ export const DashboardSidebarProjectRow = forwardRef< New workspace - - ); }, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceIcon/DashboardSidebarWorkspaceIcon.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceIcon/DashboardSidebarWorkspaceIcon.tsx index c87f5536e6e..56276b432f2 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceIcon/DashboardSidebarWorkspaceIcon.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceIcon/DashboardSidebarWorkspaceIcon.tsx @@ -1,5 +1,5 @@ import { cn } from "@superset/ui/utils"; -import { LuCloud, LuFolderGit2, LuLaptop } from "react-icons/lu"; +import { LuCloud, LuGitMerge, LuLaptop } from "react-icons/lu"; import { AsciiSpinner } from "renderer/screens/main/components/AsciiSpinner"; import { StatusIndicator } from "renderer/screens/main/components/StatusIndicator"; import type { ActivePaneStatus } from "shared/tabs-types"; @@ -50,7 +50,7 @@ export function DashboardSidebarWorkspaceIcon({ strokeWidth={1.75} /> ) : ( - ) : null} - + {!isV2CloudEnabled && } + {isV2WorkspaceRoute && ( + + )} {!isMac && } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/OrganizationDropdown/OrganizationDropdown.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/OrganizationDropdown/OrganizationDropdown.tsx index 04eb749c50f..d918461f319 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/OrganizationDropdown/OrganizationDropdown.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/OrganizationDropdown/OrganizationDropdown.tsx @@ -32,7 +32,11 @@ import { authClient } from "renderer/lib/auth-client"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; -export function OrganizationDropdown() { +export function OrganizationDropdown({ + variant = "topbar", +}: { + variant?: "topbar" | "expanded" | "collapsed"; +}) { const { data: session } = authClient.useSession(); const collections = useCollections(); const signOutMutation = electronTrpc.auth.signOut.useMutation(); @@ -65,27 +69,60 @@ export function OrganizationDropdown() { const userName = session?.user?.name; const displayName = activeOrganization?.name ?? userName ?? "Organization"; + const triggerButton = + variant === "collapsed" ? ( + + ) : variant === "expanded" ? ( + + ) : ( + + ); + + const contentAlign = variant === "topbar" ? "end" : "start"; + return ( - - - - + {triggerButton} + {/* Organization */} navigate({ to: "/settings/account" })} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/RightSidebarToggle/RightSidebarToggle.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/RightSidebarToggle/RightSidebarToggle.tsx new file mode 100644 index 00000000000..8484e02f3fa --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/RightSidebarToggle/RightSidebarToggle.tsx @@ -0,0 +1,55 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { + LuPanelRight, + LuPanelRightClose, + LuPanelRightOpen, +} from "react-icons/lu"; +import { HotkeyLabel } from "renderer/hotkeys"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; + +export function RightSidebarToggle({ workspaceId }: { workspaceId: string }) { + const collections = useCollections(); + const localState = collections.v2WorkspaceLocalState.get(workspaceId); + const isOpen = localState?.rightSidebarOpen ?? false; + + const toggle = () => { + collections.v2WorkspaceLocalState.update(workspaceId, (draft) => { + draft.rightSidebarOpen = !draft.rightSidebarOpen; + }); + }; + + const getToggleIcon = (isHovering: boolean) => { + if (!isOpen) { + return isHovering ? ( + + ) : ( + + ); + } + return isHovering ? ( + + ) : ( + + ); + }; + + return ( + + + + + + + + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/RightSidebarToggle/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/RightSidebarToggle/index.ts new file mode 100644 index 00000000000..8990c30415b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/RightSidebarToggle/index.ts @@ -0,0 +1 @@ +export { RightSidebarToggle } from "./RightSidebarToggle"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/V2OpenInMenuButton/V2OpenInMenuButton.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/V2OpenInMenuButton/V2OpenInMenuButton.tsx index a0b11900b00..2e1091ec1f8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/V2OpenInMenuButton/V2OpenInMenuButton.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/V2OpenInMenuButton/V2OpenInMenuButton.tsx @@ -1,37 +1,201 @@ -import { useQuery } from "@tanstack/react-query"; -import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; -import { OpenInMenuButton } from "../OpenInMenuButton"; +import type { ExternalApp } from "@superset/local-db"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuShortcut, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import { toast } from "@superset/ui/sonner"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { cn } from "@superset/ui/utils"; +import { useCallback, useMemo, useState } from "react"; +import { HiChevronDown } from "react-icons/hi2"; +import { + getAppOption, + OpenInExternalDropdownItems, +} from "renderer/components/OpenInExternalDropdown"; +import { HotkeyLabel, useHotkey, useHotkeyDisplay } from "renderer/hotkeys"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { useThemeStore } from "renderer/stores"; interface V2OpenInMenuButtonProps { + worktreePath: string; branch: string; - hostUrl: string; - projectId: string; workspaceId: string; } export function V2OpenInMenuButton({ + worktreePath, branch, - hostUrl, - projectId, workspaceId, }: V2OpenInMenuButtonProps) { - const workspaceQuery = useQuery({ - queryKey: ["v2-open-in-workspace", hostUrl, workspaceId], - queryFn: () => - getHostServiceClientByUrl(hostUrl).workspace.get.query({ - id: workspaceId, - }), + const collections = useCollections(); + const activeTheme = useThemeStore((state) => state.activeTheme); + + const localState = collections.v2WorkspaceLocalState.get(workspaceId); + const [defaultApp, setDefaultApp] = useState( + (localState?.defaultOpenInApp as ExternalApp) ?? "finder", + ); + + const handleDefaultAppChange = useCallback( + (app: ExternalApp) => { + setDefaultApp(app); + collections.v2WorkspaceLocalState.update(workspaceId, (draft) => { + draft.defaultOpenInApp = app; + }); + }, + [collections, workspaceId], + ); + + const openInApp = electronTrpc.external.openInApp.useMutation({ + onSuccess: (_data, variables) => { + handleDefaultAppChange(variables.app); + }, + onError: (error) => toast.error(`Failed to open: ${error.message}`), + }); + const copyPath = electronTrpc.external.copyPath.useMutation({ + onSuccess: () => toast.success("Path copied to clipboard"), + onError: (error) => toast.error(`Failed to copy path: ${error.message}`), }); - if (!workspaceQuery.data?.worktreePath) { - return null; - } + const currentApp = useMemo( + () => getAppOption(defaultApp) ?? null, + [defaultApp], + ); + const openInDisplay = useHotkeyDisplay("OPEN_IN_APP"); + const copyPathDisplay = useHotkeyDisplay("COPY_PATH"); + const showOpenInShortcut = openInDisplay.text !== "Unassigned"; + const showCopyPathShortcut = copyPathDisplay.text !== "Unassigned"; + const isLoading = openInApp.isPending || copyPath.isPending; + const isDark = activeTheme?.type === "dark"; + + const handleOpenInEditor = useCallback(() => { + if (openInApp.isPending || copyPath.isPending) return; + openInApp.mutate({ path: worktreePath, app: defaultApp }); + }, [worktreePath, defaultApp, openInApp, copyPath.isPending]); + + const handleOpenInOtherApp = useCallback( + (appId: ExternalApp) => { + if (openInApp.isPending || copyPath.isPending) return; + openInApp.mutate({ path: worktreePath, app: appId }); + }, + [worktreePath, openInApp, copyPath.isPending], + ); + + const handleCopyPath = useCallback(() => { + if (openInApp.isPending || copyPath.isPending) return; + copyPath.mutate(worktreePath); + }, [worktreePath, copyPath, openInApp.isPending]); + + useHotkey("OPEN_IN_APP", handleOpenInEditor); return ( - +
+ + + + + + {currentApp ? ( + + ) : ( + "Select an editor from the dropdown" + )} + + + + + + + + + + { + if ( + appId !== defaultApp || + !showOpenInShortcut || + group === "jetbrains" + ) { + return null; + } + return ( + + {openInDisplay.text} + + ); + }} + copyPathTrailing={ + showCopyPathShortcut ? ( + + {copyPathDisplay.text} + + ) : null + } + subContentClassName="w-40" + appContentClassName="gap-0" + appIconClassName="size-4 object-contain mr-2" + subTriggerIconClassName="size-4 object-contain mr-2" + subTriggerContentClassName="flex items-center gap-0" + copyPathContentClassName="gap-0" + copyPathIconClassName="mr-2" + /> + + +
); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/V2WorkspaceOpenInButton/V2WorkspaceOpenInButton.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/V2WorkspaceOpenInButton/V2WorkspaceOpenInButton.tsx index ed03ea90592..ccfb65d3a1f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/V2WorkspaceOpenInButton/V2WorkspaceOpenInButton.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/V2WorkspaceOpenInButton/V2WorkspaceOpenInButton.tsx @@ -1,6 +1,8 @@ import { and, eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; +import { useQuery } from "@tanstack/react-query"; import { electronTrpc } from "renderer/lib/electron-trpc"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; import { useCollections } from "../../../../../providers/CollectionsProvider"; import { useHostService } from "../../../../../providers/HostServiceProvider/HostServiceProvider"; import { V2OpenInMenuButton } from "../V2OpenInMenuButton"; @@ -42,15 +44,27 @@ export function V2WorkspaceOpenInButton({ const isLocalWorkspace = Boolean(workspace) && workspace.deviceId === currentDevice?.id; + const workspaceQuery = useQuery({ + queryKey: ["v2-open-in-workspace", hostUrl, workspaceId], + queryFn: () => + getHostServiceClientByUrl(hostUrl as string).workspace.get.query({ + id: workspaceId, + }), + enabled: !!workspace && !!hostUrl && isLocalWorkspace, + }); + if (!workspace || !hostUrl || !isLocalWorkspace) { return null; } + if (!workspaceQuery.data?.worktreePath) { + return null; + } + return ( ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts index 1a4e0fc83b7..b1615a640fa 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts @@ -24,6 +24,7 @@ export const workspaceLocalStateSchema = z.object({ }), paneLayout: paneWorkspaceStateSchema, rightSidebarOpen: z.boolean().default(false), + defaultOpenInApp: z.string().nullable().default(null), }); export const dashboardSidebarSectionSchema = z.object({ From f9276578c1aef057b3b5797f3941d1b05d022b8e Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Mon, 6 Apr 2026 16:06:20 -0700 Subject: [PATCH 390/816] fix(desktop): revert broken file icon origin fix + bundle all icon sources (#3218) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Revert "fix(desktop): resolve file icons from origin instead of href (#3199)" This reverts commit 5578746f6b0567dde5608770b475e6fcbfaa160c. * fix(desktop): collect all icon sources in file icon generation The generate script was only collecting icons from fileNames, fileExtensions, folderNames, and folderNamesExpanded — missing languageIds (12 icons), rootFolder defaults, and rootFolderNames. Also expand languageId-to-extension mappings for php, tex, matlab, and diff so they resolve without VS Code's language detection. * fix(desktop): correct languageId extension mappings for matlab/diff Map `.m` (not `.matlab`) to the matlab icon since that's the actual file extension. Also add `.patch` → diff (already covered by upstream). --- apps/desktop/scripts/generate-file-icons.ts | 21 ++++++++++++++++--- .../utils/resolveFileIconAssetUrl.ts | 2 +- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/apps/desktop/scripts/generate-file-icons.ts b/apps/desktop/scripts/generate-file-icons.ts index da13b64df52..0975d41afc4 100644 --- a/apps/desktop/scripts/generate-file-icons.ts +++ b/apps/desktop/scripts/generate-file-icons.ts @@ -33,16 +33,25 @@ function run() { addIcon(manifest.file); addIcon(manifest.folder); addIcon(manifest.folderExpanded); + addIcon(manifest.rootFolder); + addIcon(manifest.rootFolderExpanded); // File mappings for (const icon of Object.values(manifest.fileNames ?? {})) addIcon(icon); for (const icon of Object.values(manifest.fileExtensions ?? {})) addIcon(icon); + // Language ID mappings (VS Code languageId → icon, not covered by extensions) + for (const icon of Object.values(manifest.languageIds ?? {})) addIcon(icon); + // Folder mappings for (const icon of Object.values(manifest.folderNames ?? {})) addIcon(icon); for (const icon of Object.values(manifest.folderNamesExpanded ?? {})) addIcon(icon); + for (const icon of Object.values(manifest.rootFolderNames ?? {})) + addIcon(icon); + for (const icon of Object.values(manifest.rootFolderNamesExpanded ?? {})) + addIcon(icon); // Build condensed manifest const condensed: CondensedManifest = { @@ -56,13 +65,19 @@ function run() { }; // material-icon-theme relies on VS Code's languageIds for base extensions. - // Since Electron has no languageId system, add them explicitly. - const baseExtensionDefaults: Record = { + // Since Electron has no languageId system, fold languageIds where the + // language name matches a common file extension so they resolve at runtime. + const languageIdExtensionMap: Record = { ts: "typescript", js: "javascript", + php: "php", + tex: "tex", + m: "matlab", + diff: "diff", + patch: "diff", }; - for (const [ext, icon] of Object.entries(baseExtensionDefaults)) { + for (const [ext, icon] of Object.entries(languageIdExtensionMap)) { if (!condensed.fileExtensions[ext]) { condensed.fileExtensions[ext] = icon; referencedIcons.add(icon); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils/resolveFileIconAssetUrl.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils/resolveFileIconAssetUrl.ts index 1a945e5ddf2..318bc2dff1c 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils/resolveFileIconAssetUrl.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils/resolveFileIconAssetUrl.ts @@ -2,7 +2,7 @@ const FILE_ICONS_DIR = "file-icons"; export function resolveFileIconAssetUrl( iconName: string, - baseHref = globalThis.location?.origin ?? "http://localhost", + baseHref = globalThis.location?.href ?? "http://localhost/", ): string { return new URL(`${FILE_ICONS_DIR}/${iconName}.svg`, baseHref).toString(); } From 419cf660593afa1018b4e3424aa2e196400b1ef7 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sun, 5 Apr 2026 15:50:36 -0700 Subject: [PATCH 391/816] feat(desktop): git changes sidebar with resource-oriented API (#3177) * WIP * feat(desktop): git changes sidebar with resource-oriented API Backend: - Git tRPC router with GitHub-aligned types (PatchStatus, PullRequestState, CheckStatusState, etc.) mirroring GitHub's GraphQL/REST schema - Resource-oriented endpoints: getStatus, listBranches, listCommits, getCommitFiles, getDiff, getPullRequest, getPullRequestThreads - Proper file status detection using git status.files index/working_dir pairs - Branch rename mutation (unpushed branches only) - Router organized into git.ts + types.ts + utils/ per repo guidelines Frontend: - Tab registry pattern: each sidebar tab is a hook returning SidebarTabDefinition - ChangesTab with card-style header: branch name (editable), base branch selector, commit count, ahead/behind origin status, file stats - Commit filter dropdown: All changes / Uncommitted / Select range / individual commits. Filter state persisted in workspace local state. - Commit range selection modal using Dialog + ScrollArea - File list grouped by folder with status indicators - Polling-based refresh (3s for status/commits, 30s for branches) - Only active tab mounted, inactive tabs unmount * fix: address PR review feedback - Fix unstaged diff base: compare index (:0:) vs working tree, not HEAD - Add console.warn for GitHub API errors in getPullRequestThreads - Reset range modal selection when modal closes --- .../WorkspaceSidebar/WorkspaceSidebar.tsx | 76 +-- .../SidebarHeader/SidebarHeader.tsx | 16 +- .../BaseBranchSelector/BaseBranchSelector.tsx | 80 +++ .../components/BaseBranchSelector/index.ts | 1 + .../ChangesFileList/ChangesFileList.tsx | 260 ++++++++++ .../components/ChangesFileList/index.ts | 1 + .../CommitFilterDropdown.tsx | 132 +++++ .../components/CommitRow/CommitRow.tsx | 36 ++ .../components/CommitRow/index.ts | 1 + .../components/RangeModal/RangeModal.tsx | 116 +++++ .../components/RangeModal/index.ts | 1 + .../components/CommitFilterDropdown/index.ts | 1 + .../hooks/useChangesTab/index.ts | 1 + .../hooks/useChangesTab/useChangesTab.tsx | 395 +++++++++++++++ .../components/WorkspaceSidebar/types.ts | 9 + .../dashboardSidebarLocal/schema.ts | 15 + .../host-service/src/trpc/router/git/git.ts | 458 +++++++++++++++++- .../host-service/src/trpc/router/git/index.ts | 1 + .../host-service/src/trpc/router/git/types.ts | 129 +++++ .../src/trpc/router/git/utils/git-helpers.ts | 160 ++++++ .../src/trpc/router/git/utils/graphql.ts | 83 ++++ .../trpc/router/git/utils/resolve-worktree.ts | 20 + 22 files changed, 1934 insertions(+), 58 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/BaseBranchSelector/BaseBranchSelector.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/BaseBranchSelector/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/CommitFilterDropdown.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/components/CommitRow/CommitRow.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/components/CommitRow/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/components/RangeModal/RangeModal.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/components/RangeModal/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/types.ts create mode 100644 packages/host-service/src/trpc/router/git/types.ts create mode 100644 packages/host-service/src/trpc/router/git/utils/git-helpers.ts create mode 100644 packages/host-service/src/trpc/router/git/utils/graphql.ts create mode 100644 packages/host-service/src/trpc/router/git/utils/resolve-worktree.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx index d71e993a710..4f1a0111696 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx @@ -1,14 +1,18 @@ import { Button } from "@superset/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { Search } from "lucide-react"; -import { type ReactNode, useState } from "react"; +import { useMemo, useState } from "react"; import { FilesTab } from "./components/FilesTab"; import { SidebarHeader } from "./components/SidebarHeader"; - -type SidebarTab = "files" | "changes" | "checks"; +import { useChangesTab } from "./hooks/useChangesTab"; +import type { SidebarTabDefinition } from "./types"; interface WorkspaceSidebarProps { onSelectFile: (absolutePath: string) => void; + onSelectDiffFile?: ( + path: string, + category: "against-base" | "staged" | "unstaged", + ) => void; onSearch?: () => void; selectedFilePath?: string; workspaceId: string; @@ -43,50 +47,60 @@ function IconButton({ export function WorkspaceSidebar({ onSelectFile, + onSelectDiffFile, onSearch, selectedFilePath, workspaceId, workspaceName, }: WorkspaceSidebarProps) { - const [activeTab, setActiveTab] = useState("files"); - - const tabActions: Record = { - files: , - changes: null, - checks: null, - }; + const [activeTab, setActiveTab] = useState("files"); - return ( -
- setActiveTab(id as SidebarTab)} - actions={tabActions[activeTab]} - /> + const changesTab = useChangesTab({ + workspaceId, + onSelectFile: onSelectDiffFile, + }); -
+ const filesTab: SidebarTabDefinition = useMemo( + () => ({ + id: "files", + label: "All files", + actions: , + content: ( -
-
-
- Coming soon -
-
-
+ ), + }), + [onSearch, onSelectFile, selectedFilePath, workspaceId, workspaceName], + ); + + const checksTab: SidebarTabDefinition = useMemo( + () => ({ + id: "checks", + label: "Checks", + content: (
Coming soon
-
+ ), + }), + [], + ); + + const tabs = [filesTab, changesTab, checksTab]; + const activeTabDef = tabs.find((t) => t.id === activeTab); + + return ( +
+ +
{activeTabDef?.content}
); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/SidebarHeader/SidebarHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/SidebarHeader/SidebarHeader.tsx index 5359581fb92..3b70448d54d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/SidebarHeader/SidebarHeader.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/SidebarHeader/SidebarHeader.tsx @@ -1,26 +1,19 @@ import { cn } from "@superset/ui/utils"; -import type { ReactNode } from "react"; - -interface Tab { - id: string; - label: string; - icon?: ReactNode; - badge?: number; -} +import type { SidebarTabDefinition } from "../../types"; interface SidebarHeaderProps { - tabs: Tab[]; + tabs: SidebarTabDefinition[]; activeTab: string; onTabChange: (id: string) => void; - actions?: ReactNode; } export function SidebarHeader({ tabs, activeTab, onTabChange, - actions, }: SidebarHeaderProps) { + const actions = tabs.find((t) => t.id === activeTab)?.actions; + return (
@@ -36,7 +29,6 @@ export function SidebarHeader({ : "text-muted-foreground/70 hover:text-muted-foreground hover:bg-tertiary/20", )} > - {tab.icon} {tab.label} {tab.badge != null && tab.badge > 0 && ( {tab.badge} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/BaseBranchSelector/BaseBranchSelector.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/BaseBranchSelector/BaseBranchSelector.tsx new file mode 100644 index 00000000000..33d324d6779 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/BaseBranchSelector/BaseBranchSelector.tsx @@ -0,0 +1,80 @@ +import type { AppRouter } from "@superset/host-service"; +import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; +import { ScrollArea } from "@superset/ui/scroll-area"; +import type { inferRouterOutputs } from "@trpc/server"; +import { Check, ChevronDown } from "lucide-react"; +import { useMemo, useState } from "react"; + +type Branch = + inferRouterOutputs["git"]["listBranches"]["branches"][number]; + +interface BaseBranchSelectorProps { + branches: Branch[]; + currentValue: string; + onChange: (branchName: string) => void; +} + +export function BaseBranchSelector({ + branches, + currentValue, + onChange, +}: BaseBranchSelectorProps) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + + const filtered = useMemo(() => { + if (!search) return branches; + const lower = search.toLowerCase(); + return branches.filter((b) => b.name.toLowerCase().includes(lower)); + }, [branches, search]); + + return ( + + + + + +
+ setSearch(e.target.value)} + className="w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground" + /> +
+ +
+ {filtered.map((branch) => ( + + ))} + {filtered.length === 0 && ( +
+ No branches found +
+ )} +
+
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/BaseBranchSelector/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/BaseBranchSelector/index.ts new file mode 100644 index 00000000000..7f9bd68a01a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/BaseBranchSelector/index.ts @@ -0,0 +1 @@ +export { BaseBranchSelector } from "./BaseBranchSelector"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx new file mode 100644 index 00000000000..a1b1f3f9d99 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx @@ -0,0 +1,260 @@ +import type { AppRouter } from "@superset/host-service"; +import type { inferRouterOutputs } from "@trpc/server"; +import { ChevronDown, ChevronRight } from "lucide-react"; +import { useMemo, useState } from "react"; +import { FileIcon } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils"; + +type ChangedFile = + inferRouterOutputs["git"]["getStatus"]["againstBase"][number]; +type FileStatus = ChangedFile["status"]; +type ChangeCategory = "against-base" | "staged" | "unstaged"; + +const STATUS_COLORS: Record = { + added: "text-green-400", + copied: "text-purple-400", + changed: "text-yellow-400", + deleted: "text-red-400", + modified: "text-yellow-400", + renamed: "text-blue-400", + untracked: "text-green-400", +}; + +const STATUS_LETTERS: Record = { + added: "A", + copied: "C", + changed: "T", + deleted: "D", + modified: "M", + renamed: "R", + untracked: "U", +}; + +function groupByFolder( + files: ChangedFile[], +): Array<{ folder: string; files: ChangedFile[] }> { + const map = new Map(); + for (const file of files) { + const lastSlash = file.path.lastIndexOf("/"); + const folder = lastSlash > 0 ? file.path.slice(0, lastSlash) : ""; + const existing = map.get(folder); + if (existing) existing.push(file); + else map.set(folder, [file]); + } + return Array.from(map.entries()).map(([folder, files]) => ({ + folder, + files, + })); +} + +function StatusIndicator({ status }: { status: FileStatus }) { + return ( + + {STATUS_LETTERS[status]} + + ); +} + +function FileRow({ + file, + category, + onSelect, +}: { + file: ChangedFile; + category: ChangeCategory; + onSelect?: (path: string, category: ChangeCategory) => void; +}) { + const fileName = file.path.split("/").pop() ?? file.path; + + return ( + + ); +} + +function FolderGroup({ + folder, + files, + category, + onSelectFile, +}: { + folder: string; + files: ChangedFile[]; + category: ChangeCategory; + onSelectFile?: (path: string, category: ChangeCategory) => void; +}) { + // Shorten long folder paths + const displayFolder = + folder.length > 40 ? `...${folder.slice(folder.length - 37)}` : folder; + + return ( +
+ {folder && ( +
+ {displayFolder} + {files.length} +
+ )} + {files.map((file) => ( + + ))} +
+ ); +} + +function Section({ + title, + files, + category, + defaultOpen, + onSelectFile, +}: { + title: string; + files: ChangedFile[]; + category: ChangeCategory; + defaultOpen: boolean; + onSelectFile?: (path: string, category: ChangeCategory) => void; +}) { + const [isOpen, setIsOpen] = useState(defaultOpen); + const groups = useMemo(() => groupByFolder(files), [files]); + + if (files.length === 0) return null; + + return ( +
+ + {isOpen && + groups.map((group) => ( + + ))} +
+ ); +} + +interface ChangesFileListProps { + files: ChangedFile[]; + staged?: ChangedFile[]; + unstaged?: ChangedFile[]; + defaultBranchName?: string; + isLoading?: boolean; + category?: ChangeCategory; + onSelectFile?: (path: string, category: ChangeCategory) => void; +} + +export function ChangesFileList({ + files, + staged, + unstaged, + defaultBranchName, + isLoading, + category = "against-base", + onSelectFile, +}: ChangesFileListProps) { + if (isLoading) { + return ( +
+ Loading... +
+ ); + } + + const totalFiles = + files.length + (staged?.length ?? 0) + (unstaged?.length ?? 0); + + if (totalFiles === 0) { + return ( +
+ No changes +
+ ); + } + + // If staged/unstaged are provided, show three sections + if (staged !== undefined && unstaged !== undefined) { + return ( +
+
+
+
+
+ ); + } + + // Single list (filtered by commit or uncommitted) + const groups = groupByFolder(files); + return ( +
+ {groups.map((group) => ( + + ))} +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/index.ts new file mode 100644 index 00000000000..937e4423214 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/index.ts @@ -0,0 +1 @@ +export { ChangesFileList } from "./ChangesFileList"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/CommitFilterDropdown.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/CommitFilterDropdown.tsx new file mode 100644 index 00000000000..aa08e9f0280 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/CommitFilterDropdown.tsx @@ -0,0 +1,132 @@ +import type { AppRouter } from "@superset/host-service"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import type { inferRouterOutputs } from "@trpc/server"; +import { Check, ChevronDown, ListFilter } from "lucide-react"; +import { useState } from "react"; +import type { ChangesFilter } from "../../useChangesTab"; +import { CommitRow } from "./components/CommitRow"; +import { RangeModal } from "./components/RangeModal"; + +type Commit = + inferRouterOutputs["git"]["listCommits"]["commits"][number]; + +function getFilterLabel(filter: ChangesFilter, commits: Commit[]): string { + if (filter.kind === "all") return "All changes"; + if (filter.kind === "uncommitted") return "Uncommitted"; + if (filter.kind === "range") { + const from = commits.find((c) => c.hash === filter.fromHash); + const to = commits.find((c) => c.hash === filter.toHash); + return `${from?.shortHash ?? filter.fromHash.slice(0, 7)}..${to?.shortHash ?? filter.toHash.slice(0, 7)}`; + } + const commit = commits.find((c) => c.hash === filter.hash); + return commit?.shortHash ?? filter.hash.slice(0, 7); +} + +interface CommitFilterDropdownProps { + filter: ChangesFilter; + onFilterChange: (filter: ChangesFilter) => void; + commits: Commit[]; + uncommittedCount?: number; +} + +export function CommitFilterDropdown({ + filter, + onFilterChange, + commits, + uncommittedCount, +}: CommitFilterDropdownProps) { + const [rangeModalOpen, setRangeModalOpen] = useState(false); + + return ( + <> + + + + + + onFilterChange({ kind: "all" })}> +
+ All changes + {filter.kind === "all" && } +
+
+ + onFilterChange({ kind: "uncommitted" })} + > +
+
+
Uncommitted changes
+ {uncommittedCount != null && ( +
+ {uncommittedCount} files changed +
+ )} +
+ {filter.kind === "uncommitted" && } +
+
+ + {commits.length > 1 && ( + setRangeModalOpen(true)}> +
+
+ + Select range... +
+ {filter.kind === "range" && } +
+
+ )} + + {commits.length > 0 && ( + <> + + {commits.map((commit) => ( + + onFilterChange({ + kind: "commit", + hash: commit.hash, + }) + } + > + + + ))} + + )} +
+
+ + + onFilterChange({ kind: "range", fromHash, toHash }) + } + /> + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/components/CommitRow/CommitRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/components/CommitRow/CommitRow.tsx new file mode 100644 index 00000000000..66d00b04bd8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/components/CommitRow/CommitRow.tsx @@ -0,0 +1,36 @@ +import type { AppRouter } from "@superset/host-service"; +import type { inferRouterOutputs } from "@trpc/server"; +import { Check } from "lucide-react"; + +type Commit = + inferRouterOutputs["git"]["listCommits"]["commits"][number]; + +function timeAgo(date: string): string { + const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000); + if (seconds < 60) return "just now"; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +interface CommitRowProps { + commit: Commit; + isSelected?: boolean; +} + +export function CommitRow({ commit, isSelected }: CommitRowProps) { + return ( +
+
+
{commit.message}
+
+ {commit.shortHash} · {commit.author} · {timeAgo(commit.date)} +
+
+ {isSelected && } +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/components/CommitRow/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/components/CommitRow/index.ts new file mode 100644 index 00000000000..0aadae49b46 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/components/CommitRow/index.ts @@ -0,0 +1 @@ +export { CommitRow } from "./CommitRow"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/components/RangeModal/RangeModal.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/components/RangeModal/RangeModal.tsx new file mode 100644 index 00000000000..7c7547f35b8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/components/RangeModal/RangeModal.tsx @@ -0,0 +1,116 @@ +import type { AppRouter } from "@superset/host-service"; +import { Button } from "@superset/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@superset/ui/dialog"; +import { ScrollArea } from "@superset/ui/scroll-area"; +import type { inferRouterOutputs } from "@trpc/server"; +import { useEffect, useState } from "react"; +import { CommitRow } from "../CommitRow"; + +type Commit = + inferRouterOutputs["git"]["listCommits"]["commits"][number]; + +interface RangeModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + commits: Commit[]; + onSelect: (fromHash: string, toHash: string) => void; +} + +export function RangeModal({ + open, + onOpenChange, + commits, + onSelect, +}: RangeModalProps) { + const [fromIdx, setFromIdx] = useState(null); + const [toIdx, setToIdx] = useState(null); + + // Reset selection when modal opens/closes + useEffect(() => { + if (!open) { + setFromIdx(null); + setToIdx(null); + } + }, [open]); + + const handleClick = (idx: number) => { + if (fromIdx === null) { + setFromIdx(idx); + setToIdx(idx); + } else if (toIdx === fromIdx) { + setToIdx(idx); + } else { + setFromIdx(idx); + setToIdx(idx); + } + }; + + const minIdx = + fromIdx !== null && toIdx !== null ? Math.min(fromIdx, toIdx) : -1; + const maxIdx = + fromIdx !== null && toIdx !== null ? Math.max(fromIdx, toIdx) : -1; + const hasRange = minIdx !== maxIdx && minIdx >= 0; + + const handleApply = () => { + if (!hasRange) return; + const from = commits[maxIdx]; + const to = commits[minIdx]; + if (from && to) { + onSelect(from.hash, to.hash); + onOpenChange(false); + setFromIdx(null); + setToIdx(null); + } + }; + + return ( + + + + Select commit range + + Click two commits to define the range. + + + + +
+ {commits.map((commit, idx) => { + const inRange = idx >= minIdx && idx <= maxIdx; + return ( + + ); + })} +
+
+ + + + + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/components/RangeModal/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/components/RangeModal/index.ts new file mode 100644 index 00000000000..2f02b98518e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/components/RangeModal/index.ts @@ -0,0 +1 @@ +export { RangeModal } from "./RangeModal"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/index.ts new file mode 100644 index 00000000000..9261ff2f668 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/index.ts @@ -0,0 +1 @@ +export { CommitFilterDropdown } from "./CommitFilterDropdown"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/index.ts new file mode 100644 index 00000000000..1c4af3e44a0 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/index.ts @@ -0,0 +1 @@ +export { type ChangesFilter, useChangesTab } from "./useChangesTab"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx new file mode 100644 index 00000000000..e3706d681b0 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx @@ -0,0 +1,395 @@ +import type { AppRouter } from "@superset/host-service"; +import { toast } from "@superset/ui/sonner"; +import { workspaceTrpc } from "@superset/workspace-client"; +import type { inferRouterOutputs } from "@trpc/server"; +import { GitBranch, Pencil } from "lucide-react"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import type { ChangesFilter } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; +import type { SidebarTabDefinition } from "../../types"; +import { BaseBranchSelector } from "./components/BaseBranchSelector"; +import { ChangesFileList } from "./components/ChangesFileList"; +import { CommitFilterDropdown } from "./components/CommitFilterDropdown"; + +export type { ChangesFilter }; + +type RouterOutputs = inferRouterOutputs; +type Commit = RouterOutputs["git"]["listCommits"]["commits"][number]; + +interface UseChangesTabParams { + workspaceId: string; + onSelectFile?: ( + path: string, + category: "against-base" | "staged" | "unstaged", + ) => void; +} + +type Branch = RouterOutputs["git"]["listBranches"]["branches"][number]; + +function ChangesHeader({ + currentBranch, + defaultBranchName, + commitCount, + totalFiles, + totalAdditions, + totalDeletions, + onRenameBranch, + canRename, + filter, + onFilterChange, + commits, + uncommittedCount, + branches, + onBaseBranchChange, +}: { + currentBranch: { name: string; aheadCount: number; behindCount: number }; + defaultBranchName: string; + commitCount: number; + totalFiles: number; + totalAdditions: number; + totalDeletions: number; + filter: ChangesFilter; + onFilterChange: (filter: ChangesFilter) => void; + commits: Commit[]; + uncommittedCount: number; + branches: Branch[]; + onBaseBranchChange: (branchName: string) => void; + onRenameBranch: (newName: string) => void; + canRename: boolean; +}) { + const [isEditing, setIsEditing] = useState(false); + const [editValue, setEditValue] = useState(currentBranch.name); + const inputRef = useRef(null); + + const startEditing = () => { + setEditValue(currentBranch.name); + setIsEditing(true); + requestAnimationFrame(() => inputRef.current?.select()); + }; + + const handleSubmit = () => { + const trimmed = editValue.trim(); + if (trimmed && trimmed !== currentBranch.name) { + onRenameBranch(trimmed); + } + setIsEditing(false); + }; + + return ( +
+ {/* Branch name */} +
+ + {isEditing ? ( + setEditValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleSubmit(); + if (e.key === "Escape") setIsEditing(false); + }} + onBlur={handleSubmit} + className="min-w-0 flex-1 truncate bg-transparent font-medium outline-none ring-1 ring-ring rounded-sm px-1" + /> + ) : ( + <> + {currentBranch.name} + {canRename && ( + + )} + + )} +
+ + {/* Commits from base */} +
+ {commitCount} {commitCount === 1 ? "commit" : "commits"} from{" "} + +
+ + {/* Remote status */} + {currentBranch.aheadCount > 0 && currentBranch.behindCount > 0 && ( +
+
Your branch and
+
+ origin/{currentBranch.name} +
+
have diverged
+
+ {currentBranch.aheadCount} local not pushed,{" "} + {currentBranch.behindCount} remote to pull +
+
+ )} + {currentBranch.aheadCount > 0 && currentBranch.behindCount === 0 && ( +
+
+ {currentBranch.aheadCount}{" "} + {currentBranch.aheadCount === 1 ? "commit" : "commits"} ahead of +
+
+ origin/{currentBranch.name} +
+
+ )} + {currentBranch.behindCount > 0 && currentBranch.aheadCount === 0 && ( +
+
+ {currentBranch.behindCount}{" "} + {currentBranch.behindCount === 1 ? "commit" : "commits"} behind +
+
+ origin/{currentBranch.name} +
+
+ )} + + {/* Filter + stats */} +
+ +
+ {totalFiles} files changed + {(totalAdditions > 0 || totalDeletions > 0) && ( + + {totalAdditions > 0 && ( + +{totalAdditions} + )} + {totalAdditions > 0 && totalDeletions > 0 && " "} + {totalDeletions > 0 && ( + -{totalDeletions} + )} + + )} +
+
+
+ ); +} + +export function useChangesTab({ + workspaceId, + onSelectFile, +}: UseChangesTabParams): SidebarTabDefinition { + const collections = useCollections(); + const localState = collections.v2WorkspaceLocalState.get(workspaceId); + const filter: ChangesFilter = localState?.sidebarState?.changesFilter ?? { + kind: "all", + }; + const baseBranch: string | null = + localState?.sidebarState?.baseBranch ?? null; + + const setFilter = useCallback( + (next: ChangesFilter) => { + if (!collections.v2WorkspaceLocalState.get(workspaceId)) return; + collections.v2WorkspaceLocalState.update(workspaceId, (draft) => { + draft.sidebarState.changesFilter = next; + }); + }, + [collections, workspaceId], + ); + + const setBaseBranch = useCallback( + (branchName: string) => { + if (!collections.v2WorkspaceLocalState.get(workspaceId)) return; + collections.v2WorkspaceLocalState.update(workspaceId, (draft) => { + draft.sidebarState.baseBranch = branchName; + }); + }, + [collections, workspaceId], + ); + + const status = workspaceTrpc.git.getStatus.useQuery( + { workspaceId, baseBranch: baseBranch ?? undefined }, + { refetchInterval: 3_000, refetchOnWindowFocus: true }, + ); + + const commits = workspaceTrpc.git.listCommits.useQuery( + { workspaceId, baseBranch: baseBranch ?? undefined }, + { refetchInterval: 3_000, refetchOnWindowFocus: true }, + ); + + const branches = workspaceTrpc.git.listBranches.useQuery( + { workspaceId }, + { refetchInterval: 30_000, refetchOnWindowFocus: true }, + ); + + const renameBranchMutation = workspaceTrpc.git.renameBranch.useMutation(); + + const handleRenameBranch = useCallback( + (newName: string) => { + const currentName = status.data?.currentBranch.name; + if (!currentName) return; + toast.promise( + renameBranchMutation.mutateAsync({ + workspaceId, + oldName: currentName, + newName, + }), + { + loading: `Renaming branch to ${newName}...`, + success: `Branch renamed to ${newName}`, + error: (err) => + err instanceof Error ? err.message : "Failed to rename branch", + }, + ); + }, + [workspaceId, status.data?.currentBranch.name, renameBranchMutation], + ); + + // Can only rename if branch hasn't been pushed (aheadCount === total commits means nothing pushed) + const canRenameBranch = !status.data?.currentBranch.upstream; + + const commitFilesInput = + filter.kind === "commit" + ? { workspaceId, commitHash: filter.hash } + : filter.kind === "range" + ? { workspaceId, commitHash: filter.toHash, fromHash: filter.fromHash } + : { workspaceId, commitHash: "" }; + + const commitFiles = workspaceTrpc.git.getCommitFiles.useQuery( + commitFilesInput, + { enabled: filter.kind === "commit" || filter.kind === "range" }, + ); + + const totalChanges = status.data + ? status.data.againstBase.length + + status.data.staged.length + + status.data.unstaged.length + : 0; + + const totalAdditions = status.data + ? [ + ...status.data.againstBase, + ...status.data.staged, + ...status.data.unstaged, + ].reduce((sum, f) => sum + f.additions, 0) + : 0; + + const totalDeletions = status.data + ? [ + ...status.data.againstBase, + ...status.data.staged, + ...status.data.unstaged, + ].reduce((sum, f) => sum + f.deletions, 0) + : 0; + + const content = useMemo(() => { + if (status.isLoading) { + return ( +
+ Loading changes... +
+ ); + } + + if (!status.data) { + return ( +
+ Unable to load git status +
+ ); + } + + let fileList: React.ReactNode; + + if (filter.kind === "commit" || filter.kind === "range") { + fileList = ( + + ); + } else if (filter.kind === "uncommitted") { + fileList = ( + + ); + } else { + // Merge all files into a single flat list, deduplicating by path + // (a file can appear in both againstBase and staged/unstaged) + const allFilesMap = new Map< + string, + (typeof status.data.againstBase)[number] + >(); + for (const f of status.data.againstBase) allFilesMap.set(f.path, f); + for (const f of status.data.staged) allFilesMap.set(f.path, f); + for (const f of status.data.unstaged) allFilesMap.set(f.path, f); + + fileList = ( + + ); + } + + return ( +
+ +
{fileList}
+
+ ); + }, [ + status.data, + status.isLoading, + filter, + commitFiles.data, + commitFiles.isLoading, + commits.data, + totalChanges, + totalAdditions, + totalDeletions, + onSelectFile, + setFilter, + branches.data?.branches, + canRenameBranch, + handleRenameBranch, + setBaseBranch, + ]); + + return { + id: "changes", + label: "Changes", + badge: totalChanges > 0 ? totalChanges : undefined, + content, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/types.ts new file mode 100644 index 00000000000..d771451be48 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/types.ts @@ -0,0 +1,9 @@ +import type { ReactNode } from "react"; + +export interface SidebarTabDefinition { + id: string; + label: string; + badge?: number; + actions?: ReactNode; + content: ReactNode; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts index b1615a640fa..a34167897b5 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts @@ -14,6 +14,19 @@ export const dashboardSidebarProjectSchema = z.object({ const paneWorkspaceStateSchema = z.custom>(); +const changesFilterSchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("all") }), + z.object({ kind: z.literal("uncommitted") }), + z.object({ kind: z.literal("commit"), hash: z.string() }), + z.object({ + kind: z.literal("range"), + fromHash: z.string(), + toHash: z.string(), + }), +]); + +export type ChangesFilter = z.infer; + export const workspaceLocalStateSchema = z.object({ workspaceId: z.string().uuid(), createdAt: persistedDateSchema, @@ -21,6 +34,8 @@ export const workspaceLocalStateSchema = z.object({ projectId: z.string().uuid(), tabOrder: z.number().int().default(0), sectionId: z.string().uuid().nullable().default(null), + changesFilter: changesFilterSchema.default({ kind: "all" }), + baseBranch: z.string().nullable().default(null), }), paneLayout: paneWorkspaceStateSchema, rightSidebarOpen: z.boolean().default(false), diff --git a/packages/host-service/src/trpc/router/git/git.ts b/packages/host-service/src/trpc/router/git/git.ts index 855c17d37e6..ccbb7379137 100644 --- a/packages/host-service/src/trpc/router/git/git.ts +++ b/packages/host-service/src/trpc/router/git/git.ts @@ -1,24 +1,452 @@ +import { readFile } from "node:fs/promises"; +import { TRPCError } from "@trpc/server"; +import { eq } from "drizzle-orm"; import { z } from "zod"; +import { projects, pullRequests, workspaces } from "../../../db/schema"; import { protectedProcedure, router } from "../../index"; +import type { + ChangedFile, + CheckConclusionState, + CheckRun, + CheckStatusState, + Commit, + IssueComment, + MergeableState, + PullRequestReviewDecision, + PullRequestReviewThread, + PullRequestState, +} from "./types"; +import { + buildBranch, + getChangedFilesForDiff, + getDefaultBranchName, + mapGitStatus, + parseNumstat, +} from "./utils/git-helpers"; +import { + type GraphQLThreadsResult, + parseGraphQLThreads, + REVIEW_THREADS_QUERY, +} from "./utils/graphql"; +import { resolveWorktreePath } from "./utils/resolve-worktree"; -// TODO: Remove this test router in favor of product-led endpoints (i.e. workspace.create()) export const gitRouter = router({ - status: protectedProcedure - .input(z.object({ path: z.string() })) + listBranches: protectedProcedure + .input(z.object({ workspaceId: z.string() })) .query(async ({ ctx, input }) => { - const git = await ctx.git(input.path); - const status = await git.status(); + const worktreePath = resolveWorktreePath(ctx, input.workspaceId); + const git = await ctx.git(worktreePath); + + const currentBranchName = ( + await git.revparse(["--abbrev-ref", "HEAD"]).catch(() => "") + ).trim(); + const defaultBranchName = await getDefaultBranchName(git); + + let branchNames: string[] = []; + try { + const raw = await git.raw([ + "branch", + "--list", + "--format=%(refname:short)", + ]); + branchNames = raw.trim().split("\n").filter(Boolean); + } catch {} + + const branches = await Promise.all( + branchNames.map((name) => + buildBranch( + git, + name, + name === currentBranchName, + defaultBranchName ? `origin/${defaultBranchName}` : undefined, + ), + ), + ); + + return { branches }; + }), + + getStatus: protectedProcedure + .input( + z.object({ + workspaceId: z.string(), + baseBranch: z.string().optional(), + }), + ) + .query(async ({ ctx, input }) => { + const worktreePath = resolveWorktreePath(ctx, input.workspaceId); + const git = await ctx.git(worktreePath); + + const currentBranchName = ( + await git.revparse(["--abbrev-ref", "HEAD"]).catch(() => "") + ).trim(); + const defaultBranchName = + input.baseBranch ?? (await getDefaultBranchName(git)); + const baseRef = defaultBranchName + ? `origin/${defaultBranchName}` + : "HEAD"; + + const [currentBranch, defaultBranch, status] = await Promise.all([ + buildBranch(git, currentBranchName, true, baseRef), + defaultBranchName + ? buildBranch(git, defaultBranchName, false) + : buildBranch(git, currentBranchName, true), + git.status(), + ]); + + const againstBase = await getChangedFilesForDiff(git, [baseRef, "HEAD"]); + + // Staged — use status.files index character for correct status + const stagedNumstat = parseNumstat( + await git.raw(["diff", "--numstat", "--cached"]).catch(() => ""), + ); + const staged: ChangedFile[] = []; + for (const file of status.files) { + const idx = file.index; + if (idx && idx !== " " && idx !== "?") { + const stats = stagedNumstat.get(file.path) ?? { + additions: 0, + deletions: 0, + }; + staged.push({ + path: file.path, + status: mapGitStatus(idx), + additions: stats.additions, + deletions: stats.deletions, + }); + } + } + + // Unstaged — use status.files working_dir character + const unstagedNumstat = parseNumstat( + await git.raw(["diff", "--numstat"]).catch(() => ""), + ); + const unstaged: ChangedFile[] = []; + for (const file of status.files) { + const wd = file.working_dir; + if (file.index === "?" && wd === "?") { + unstaged.push({ + path: file.path, + status: "untracked", + additions: 0, + deletions: 0, + }); + } else if (wd && wd !== " ") { + const stats = unstagedNumstat.get(file.path) ?? { + additions: 0, + deletions: 0, + }; + unstaged.push({ + path: file.path, + status: mapGitStatus(wd), + additions: stats.additions, + deletions: stats.deletions, + }); + } + } + + return { currentBranch, defaultBranch, againstBase, staged, unstaged }; + }), + + listCommits: protectedProcedure + .input( + z.object({ + workspaceId: z.string(), + baseBranch: z.string().optional(), + }), + ) + .query(async ({ ctx, input }) => { + const worktreePath = resolveWorktreePath(ctx, input.workspaceId); + const git = await ctx.git(worktreePath); + + const defaultBranchName = + input.baseBranch ?? (await getDefaultBranchName(git)); + const baseRef = defaultBranchName + ? `origin/${defaultBranchName}` + : "HEAD"; + + const commits: Commit[] = []; + try { + const raw = await git.raw([ + "log", + `${baseRef}..HEAD`, + "--format=%H\t%h\t%s\t%an\t%aI", + ]); + for (const line of raw.trim().split("\n")) { + if (!line) continue; + const [hash, shortHash, message, author, date] = line.split("\t"); + commits.push({ + hash: hash ?? "", + shortHash: shortHash ?? "", + message: message ?? "", + author: author ?? "", + date: date ?? "", + }); + } + } catch {} + + return { commits }; + }), + + getCommitFiles: protectedProcedure + .input( + z.object({ + workspaceId: z.string(), + commitHash: z.string(), + fromHash: z.string().optional(), + }), + ) + .query(async ({ ctx, input }) => { + const worktreePath = resolveWorktreePath(ctx, input.workspaceId); + const git = await ctx.git(worktreePath); + + const from = input.fromHash ? input.fromHash : `${input.commitHash}^`; + const files = await getChangedFilesForDiff(git, [from, input.commitHash]); + + return { files }; + }), + + renameBranch: protectedProcedure + .input( + z.object({ + workspaceId: z.string(), + oldName: z.string(), + newName: z.string(), + }), + ) + .mutation(async ({ ctx, input }) => { + const worktreePath = resolveWorktreePath(ctx, input.workspaceId); + const git = await ctx.git(worktreePath); + + // Check if branch has been pushed to remote + try { + const remote = await git.raw([ + "ls-remote", + "--heads", + "origin", + input.oldName, + ]); + if (remote.trim()) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Cannot rename a branch that has been pushed to remote", + }); + } + } catch (error) { + if (error instanceof TRPCError) throw error; + // ls-remote failed — probably no remote, safe to rename + } + + await git.raw(["branch", "-m", input.oldName, input.newName]); + return { name: input.newName }; + }), + + getDiff: protectedProcedure + .input( + z.object({ + workspaceId: z.string(), + path: z.string(), + category: z.enum(["against-base", "staged", "unstaged"]), + baseBranch: z.string().optional(), + }), + ) + .query(async ({ ctx, input }) => { + const worktreePath = resolveWorktreePath(ctx, input.workspaceId); + const git = await ctx.git(worktreePath); + + let originalContent = ""; + let modifiedContent = ""; + + if (input.category === "against-base") { + const baseBranch = + input.baseBranch ?? (await getDefaultBranchName(git)); + const baseRef = baseBranch ? `origin/${baseBranch}` : "HEAD"; + try { + originalContent = await git.show([`${baseRef}:${input.path}`]); + } catch {} + try { + modifiedContent = await git.show([`HEAD:${input.path}`]); + } catch {} + } else if (input.category === "staged") { + try { + originalContent = await git.show([`HEAD:${input.path}`]); + } catch {} + try { + modifiedContent = await git.show([`:0:${input.path}`]); + } catch {} + } else { + // Unstaged: compare index (staged version) against working tree + // If file isn't in index (untracked), originalContent stays empty = "new file" + try { + originalContent = await git.show([`:0:${input.path}`]); + } catch {} + try { + modifiedContent = await readFile( + `${worktreePath}/${input.path}`, + "utf-8", + ); + } catch {} + } + + const fileName = input.path.split("/").pop() ?? input.path; return { - current: status.current, - tracking: status.tracking, - ahead: status.ahead, - behind: status.behind, - staged: status.staged, - modified: status.modified, - not_added: status.not_added, - deleted: status.deleted, - conflicted: status.conflicted, - isClean: status.isClean(), + oldFile: { name: fileName, contents: originalContent }, + newFile: { name: fileName, contents: modifiedContent }, }; }), + + getPullRequest: protectedProcedure + .input(z.object({ workspaceId: z.string() })) + .query(({ ctx, input }) => { + const workspace = ctx.db.query.workspaces + .findFirst({ where: eq(workspaces.id, input.workspaceId) }) + .sync(); + if (!workspace) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Workspace not found", + }); + } + if (!workspace.pullRequestId) return null; + + const pr = ctx.db.query.pullRequests + .findFirst({ where: eq(pullRequests.id, workspace.pullRequestId) }) + .sync(); + if (!pr) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Pull request ${workspace.pullRequestId} not found in database`, + }); + } + + let checks: CheckRun[] = []; + try { + const parsed = JSON.parse(pr.checksJson); + if (Array.isArray(parsed)) { + checks = parsed.map( + (c: Record): CheckRun => ({ + name: (c.name as string) ?? "", + status: ((c.status as string) ?? "completed") as CheckStatusState, + conclusion: (c.conclusion ?? null) as CheckConclusionState | null, + detailsUrl: (c.url as string) ?? null, + startedAt: (c.startedAt as string) ?? null, + completedAt: (c.completedAt as string) ?? null, + }), + ); + } + } catch {} + + return { + number: pr.prNumber, + url: pr.url, + title: pr.title, + body: null as string | null, + state: pr.state as PullRequestState, + isDraft: pr.isDraft ?? false, + reviewDecision: (pr.reviewDecision ?? + null) as PullRequestReviewDecision | null, + mergeable: "unknown" as MergeableState, + headRefName: pr.headBranch ?? "", + updatedAt: pr.updatedAt ? new Date(pr.updatedAt).toISOString() : "", + checks, + }; + }), + + getPullRequestThreads: protectedProcedure + .input(z.object({ workspaceId: z.string() })) + .query(async ({ ctx, input }) => { + const workspace = ctx.db.query.workspaces + .findFirst({ where: eq(workspaces.id, input.workspaceId) }) + .sync(); + if (!workspace) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Workspace not found", + }); + } + if (!workspace.pullRequestId) { + return { reviewThreads: [], conversationComments: [] }; + } + + const pr = ctx.db.query.pullRequests + .findFirst({ where: eq(pullRequests.id, workspace.pullRequestId) }) + .sync(); + if (!pr) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Pull request ${workspace.pullRequestId} not found in database`, + }); + } + + const project = ctx.db.query.projects + .findFirst({ where: eq(projects.id, workspace.projectId) }) + .sync(); + if (!project) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Project ${workspace.projectId} not found in database`, + }); + } + if (!project.repoOwner || !project.repoName) { + return { reviewThreads: [], conversationComments: [] }; + } + + const octokit = await ctx.github(); + + let reviewThreads: PullRequestReviewThread[] = []; + try { + const result: GraphQLThreadsResult = await octokit.graphql( + REVIEW_THREADS_QUERY, + { + owner: project.repoOwner, + name: project.repoName, + prNumber: pr.prNumber, + }, + ); + reviewThreads = parseGraphQLThreads(result); + } catch (error) { + console.warn( + "[git.getPullRequestThreads] Failed to fetch review threads:", + error, + ); + } + + const conversationComments: IssueComment[] = []; + try { + let page = 1; + let hasMore = true; + while (hasMore) { + const { data: comments } = await octokit.issues.listComments({ + owner: project.repoOwner, + repo: project.repoName, + issue_number: pr.prNumber, + per_page: 100, + page, + }); + for (const c of comments) { + const body = c.body?.trim(); + if (!body) continue; + conversationComments.push({ + id: c.id, + user: { + login: c.user?.login ?? "ghost", + avatarUrl: c.user?.avatar_url ?? "", + }, + body, + createdAt: c.created_at ?? "", + htmlUrl: c.html_url ?? "", + }); + } + hasMore = comments.length === 100; + page++; + } + } catch (error) { + console.warn( + "[git.getPullRequestThreads] Failed to fetch conversation comments:", + error, + ); + } + + return { reviewThreads, conversationComments }; + }), }); diff --git a/packages/host-service/src/trpc/router/git/index.ts b/packages/host-service/src/trpc/router/git/index.ts index f462fd27d8b..392ac5e00c2 100644 --- a/packages/host-service/src/trpc/router/git/index.ts +++ b/packages/host-service/src/trpc/router/git/index.ts @@ -1 +1,2 @@ export { gitRouter } from "./git"; +export type * from "./types"; diff --git a/packages/host-service/src/trpc/router/git/types.ts b/packages/host-service/src/trpc/router/git/types.ts new file mode 100644 index 00000000000..a604fbb03bd --- /dev/null +++ b/packages/host-service/src/trpc/router/git/types.ts @@ -0,0 +1,129 @@ +/** + * Git & GitHub types for the git tRPC router. + * + * Design principle: mirror GitHub's data model. Base types are subsets of + * GitHub's GraphQL/REST schema. Our extensions (Branch, ChangedFile) add + * local git concepts using the same naming conventions. + */ + +// --------------------------------------------------------------------------- +// GitHub enums (lowercased from GraphQL SCREAMING_CASE) +// --------------------------------------------------------------------------- + +/** GitHub GraphQL: PatchStatus */ +export type PatchStatus = + | "added" + | "copied" + | "changed" + | "deleted" + | "modified" + | "renamed"; + +/** GitHub GraphQL: DiffSide */ +export type DiffSide = "LEFT" | "RIGHT"; + +/** GitHub GraphQL: PullRequestState */ +export type PullRequestState = "open" | "closed" | "merged"; + +/** GitHub GraphQL: PullRequestReviewDecision */ +export type PullRequestReviewDecision = + | "approved" + | "changes_requested" + | "review_required"; + +/** GitHub GraphQL: CheckStatusState */ +export type CheckStatusState = + | "completed" + | "in_progress" + | "pending" + | "queued"; + +/** GitHub GraphQL: CheckConclusionState */ +export type CheckConclusionState = + | "success" + | "failure" + | "cancelled" + | "skipped" + | "neutral" + | "timed_out" + | "action_required" + | "stale"; + +/** GitHub GraphQL: MergeableState */ +export type MergeableState = "mergeable" | "conflicting" | "unknown"; + +// --------------------------------------------------------------------------- +// GitHub objects (subset of fields we use) +// --------------------------------------------------------------------------- + +export interface GitHubActor { + login: string; + avatarUrl: string; +} + +export interface PullRequestReviewComment { + id: string; + databaseId: number; + author: GitHubActor; + body: string; + createdAt: string; +} + +export interface PullRequestReviewThread { + id: string; + isResolved: boolean; + diffSide: DiffSide; + line: number | null; + path: string; + comments: PullRequestReviewComment[]; +} + +export interface CheckRun { + name: string; + status: CheckStatusState; + conclusion: CheckConclusionState | null; + detailsUrl: string | null; + startedAt: string | null; + completedAt: string | null; +} + +export interface IssueComment { + id: number; + user: GitHubActor; + body: string; + createdAt: string; + htmlUrl: string; +} + +// --------------------------------------------------------------------------- +// Our resource types +// --------------------------------------------------------------------------- + +/** Extends GitHub's PatchStatus with "untracked" for local working tree */ +export type FileStatus = PatchStatus | "untracked"; + +export interface Branch { + name: string; + isHead: boolean; + upstream: string | null; + aheadCount: number; + behindCount: number; + lastCommitHash: string; + lastCommitDate: string; +} + +export interface ChangedFile { + path: string; + oldPath?: string; + status: FileStatus; + additions: number; + deletions: number; +} + +export interface Commit { + hash: string; + shortHash: string; + message: string; + author: string; + date: string; +} diff --git a/packages/host-service/src/trpc/router/git/utils/git-helpers.ts b/packages/host-service/src/trpc/router/git/utils/git-helpers.ts new file mode 100644 index 00000000000..2e50c1e02ec --- /dev/null +++ b/packages/host-service/src/trpc/router/git/utils/git-helpers.ts @@ -0,0 +1,160 @@ +import type { SimpleGit } from "simple-git"; +import type { Branch, ChangedFile, FileStatus } from "../types"; + +/** Map git's single-letter status codes to GitHub-aligned FileStatus */ +export function mapGitStatus(code: string): FileStatus { + switch (code) { + case "A": + return "added"; + case "M": + return "modified"; + case "D": + return "deleted"; + case "R": + return "renamed"; + case "C": + return "copied"; + case "T": + return "changed"; + case "?": + return "untracked"; + default: + return "modified"; + } +} + +export function parseNumstat( + raw: string, +): Map { + const result = new Map(); + for (const line of raw.trim().split("\n")) { + if (!line) continue; + const [add, del, ...pathParts] = line.split("\t"); + const path = pathParts.join("\t"); + result.set(path, { + additions: add === "-" ? 0 : Number.parseInt(add ?? "0", 10), + deletions: del === "-" ? 0 : Number.parseInt(del ?? "0", 10), + }); + } + return result; +} + +export function parseNameStatus( + raw: string, +): Array<{ status: string; path: string; oldPath?: string }> { + const results: Array<{ status: string; path: string; oldPath?: string }> = []; + for (const line of raw.trim().split("\n")) { + if (!line) continue; + const parts = line.split("\t"); + const statusCode = parts[0]?.[0] ?? "?"; + if (statusCode === "R" || statusCode === "C") { + results.push({ + status: statusCode, + path: parts[2] ?? "", + oldPath: parts[1], + }); + } else { + results.push({ status: statusCode, path: parts[1] ?? "" }); + } + } + return results; +} + +export async function getDefaultBranchName( + git: SimpleGit, +): Promise { + try { + const ref = await git.raw([ + "symbolic-ref", + "refs/remotes/origin/HEAD", + "--short", + ]); + return ref.trim().replace(/^origin\//, ""); + } catch { + return null; + } +} + +export async function buildBranch( + git: SimpleGit, + name: string, + isHead: boolean, + compareRef?: string, +): Promise { + let upstream: string | null = null; + let aheadCount = 0; + let behindCount = 0; + let lastCommitHash = ""; + let lastCommitDate = ""; + + try { + const remote = ( + await git.raw(["config", `branch.${name}.remote`]).catch(() => "") + ).trim(); + const merge = ( + await git.raw(["config", `branch.${name}.merge`]).catch(() => "") + ).trim(); + upstream = + remote && merge ? `${remote}/${merge.replace("refs/heads/", "")}` : null; + } catch { + upstream = null; + } + + if (compareRef) { + try { + const counts = ( + await git.raw([ + "rev-list", + "--left-right", + "--count", + `${compareRef}...${name}`, + ]) + ).trim(); + const [behind, ahead] = counts.split("\t").map(Number); + aheadCount = ahead ?? 0; + behindCount = behind ?? 0; + } catch {} + } + + try { + const log = (await git.raw(["log", "-1", "--format=%H\t%aI", name])).trim(); + const [hash, date] = log.split("\t"); + lastCommitHash = hash ?? ""; + lastCommitDate = date ?? ""; + } catch {} + + return { + name, + isHead, + upstream, + aheadCount, + behindCount, + lastCommitHash, + lastCommitDate, + }; +} + +export async function getChangedFilesForDiff( + git: SimpleGit, + diffArgs: string[], +): Promise { + try { + const [nameStatusRaw, numstatRaw] = await Promise.all([ + git.raw(["diff", "--name-status", ...diffArgs]), + git.raw(["diff", "--numstat", ...diffArgs]), + ]); + const nameStatus = parseNameStatus(nameStatusRaw); + const numstat = parseNumstat(numstatRaw); + return nameStatus + .filter((f) => f.path) + .map((f) => ({ + path: f.path, + oldPath: f.oldPath, + status: mapGitStatus(f.status), + additions: (numstat.get(f.path) ?? { additions: 0 }).additions, + deletions: (numstat.get(f.path) ?? { deletions: 0 }).deletions, + })); + } catch { + return []; + } +} diff --git a/packages/host-service/src/trpc/router/git/utils/graphql.ts b/packages/host-service/src/trpc/router/git/utils/graphql.ts new file mode 100644 index 00000000000..438fa191acb --- /dev/null +++ b/packages/host-service/src/trpc/router/git/utils/graphql.ts @@ -0,0 +1,83 @@ +import type { + DiffSide, + PullRequestReviewComment, + PullRequestReviewThread, +} from "../types"; + +export const REVIEW_THREADS_QUERY = ` + query($owner: String!, $name: String!, $prNumber: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $prNumber) { + reviewThreads(first: 100) { + nodes { + id + isResolved + diffSide + comments(first: 100) { + nodes { + id + databaseId + author { login avatarUrl } + body + createdAt + path + line + originalLine + } + } + } + } + } + } + } +`; + +export interface GraphQLThreadsResult { + repository: { + pullRequest: { + reviewThreads: { + nodes: Array<{ + id: string; + isResolved: boolean; + diffSide: string; + comments: { + nodes: Array<{ + id: string; + databaseId: number; + author: { login: string; avatarUrl: string } | null; + body: string; + createdAt: string; + path: string; + line: number | null; + originalLine: number | null; + }>; + }; + }>; + }; + }; + }; +} + +export function parseGraphQLThreads( + result: GraphQLThreadsResult, +): PullRequestReviewThread[] { + return result.repository.pullRequest.reviewThreads.nodes.map((thread) => { + const firstComment = thread.comments.nodes[0]; + return { + id: thread.id, + isResolved: thread.isResolved, + diffSide: (thread.diffSide === "LEFT" ? "LEFT" : "RIGHT") as DiffSide, + line: firstComment?.line ?? firstComment?.originalLine ?? null, + path: firstComment?.path ?? "", + comments: thread.comments.nodes.map( + (c): PullRequestReviewComment => ({ + id: c.id, + databaseId: c.databaseId, + author: c.author ?? { login: "ghost", avatarUrl: "" }, + body: c.body, + createdAt: c.createdAt, + }), + ), + }; + }); +} diff --git a/packages/host-service/src/trpc/router/git/utils/resolve-worktree.ts b/packages/host-service/src/trpc/router/git/utils/resolve-worktree.ts new file mode 100644 index 00000000000..1dca27d6af2 --- /dev/null +++ b/packages/host-service/src/trpc/router/git/utils/resolve-worktree.ts @@ -0,0 +1,20 @@ +import { TRPCError } from "@trpc/server"; +import { eq } from "drizzle-orm"; +import { workspaces } from "../../../../db/schema"; +import type { protectedProcedure } from "../../../index"; + +export function resolveWorktreePath( + ctx: Parameters[0]>[0]["ctx"], + workspaceId: string, +): string { + const workspace = ctx.db.query.workspaces + .findFirst({ where: eq(workspaces.id, workspaceId) }) + .sync(); + if (!workspace?.worktreePath) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Workspace not found", + }); + } + return workspace.worktreePath; +} From e21e41b1c85a062ff0ddd155c203cf21b1e7cf31 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sun, 5 Apr 2026 13:49:33 -0700 Subject: [PATCH 392/816] feat(desktop): wire up missing hotkeys for v2 workspace (#3190) Add useV2WorkspaceHotkeys hook with: - Tab management: close tab/pane, prev/next tab, jump to tab 1-9 - Pane management: prev/next pane, split auto/right/down/chat/browser, equalize splits - Workspace navigation: prev/next workspace --- .../useDashboardSidebarShortcuts.ts | 30 ++- .../hooks/useWorkspaceHotkeys/index.ts | 1 + .../useWorkspaceHotkeys.ts | 243 ++++++++++++++++++ .../v2-workspace/$workspaceId/page.tsx | 13 +- bun.lock | 6 + 5 files changed, 281 insertions(+), 12 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.ts index dbe6240106d..eaedf913f8a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.ts @@ -1,4 +1,4 @@ -import { useNavigate } from "@tanstack/react-router"; +import { useMatchRoute, useNavigate } from "@tanstack/react-router"; import { useCallback, useMemo } from "react"; import { useHotkey } from "renderer/hotkeys"; import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; @@ -48,5 +48,33 @@ export function useDashboardSidebarShortcuts( useHotkey("JUMP_TO_WORKSPACE_8", () => switchToWorkspace(7)); useHotkey("JUMP_TO_WORKSPACE_9", () => switchToWorkspace(8)); + // Prev/next workspace navigation (cycles) + const matchRoute = useMatchRoute(); + const currentWorkspaceMatch = matchRoute({ + to: "/v2-workspace/$workspaceId", + fuzzy: true, + }); + const currentWorkspaceId = + currentWorkspaceMatch !== false ? currentWorkspaceMatch.workspaceId : null; + + useHotkey("PREV_WORKSPACE", () => { + if (!currentWorkspaceId || flattenedWorkspaces.length === 0) return; + const index = flattenedWorkspaces.findIndex( + (w) => w.id === currentWorkspaceId, + ); + const prevIndex = index <= 0 ? flattenedWorkspaces.length - 1 : index - 1; + navigateToV2Workspace(flattenedWorkspaces[prevIndex].id, navigate); + }); + + useHotkey("NEXT_WORKSPACE", () => { + if (!currentWorkspaceId || flattenedWorkspaces.length === 0) return; + const index = flattenedWorkspaces.findIndex( + (w) => w.id === currentWorkspaceId, + ); + const nextIndex = + index >= flattenedWorkspaces.length - 1 || index === -1 ? 0 : index + 1; + navigateToV2Workspace(flattenedWorkspaces[nextIndex].id, navigate); + }); + return workspaceShortcutLabels; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/index.ts new file mode 100644 index 00000000000..fc3d7043003 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/index.ts @@ -0,0 +1 @@ +export { useWorkspaceHotkeys } from "./useWorkspaceHotkeys"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts new file mode 100644 index 00000000000..daec3accb52 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts @@ -0,0 +1,243 @@ +import type { WorkspaceStore } from "@superset/panes"; +import { useCallback } from "react"; +import { useHotkey } from "renderer/hotkeys"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import type { StoreApi } from "zustand"; +import type { + BrowserPaneData, + ChatPaneData, + PaneViewerData, + TerminalPaneData, +} from "../../types"; + +export function useWorkspaceHotkeys({ + store, + workspaceId, +}: { + store: StoreApi>; + workspaceId: string; +}) { + const collections = useCollections(); + + useHotkey("TOGGLE_SIDEBAR", () => { + if (!collections.v2WorkspaceLocalState.get(workspaceId)) return; + collections.v2WorkspaceLocalState.update(workspaceId, (draft) => { + draft.rightSidebarOpen = !draft.rightSidebarOpen; + }); + }); + + // --- Tab creation --- + + useHotkey("NEW_GROUP", () => { + store.getState().addTab({ + titleOverride: "Terminal", + panes: [ + { + kind: "terminal", + data: { terminalId: crypto.randomUUID() } as TerminalPaneData, + }, + ], + }); + }); + + useHotkey("NEW_CHAT", () => { + store.getState().addTab({ + titleOverride: "Chat", + panes: [{ kind: "chat", data: { sessionId: null } as ChatPaneData }], + }); + }); + + useHotkey("NEW_BROWSER", () => { + store.getState().addTab({ + titleOverride: "Browser", + panes: [ + { + kind: "browser", + data: { + url: "http://localhost:3000", + mode: "preview", + } as BrowserPaneData, + }, + ], + }); + }); + + // --- Tab management --- + + useHotkey("CLOSE_TERMINAL", () => { + const state = store.getState(); + const active = state.getActivePane(); + if (active) { + state.closePane({ tabId: active.tabId, paneId: active.pane.id }); + } + }); + + useHotkey("CLOSE_TAB", () => { + const state = store.getState(); + if (state.activeTabId) { + state.removeTab(state.activeTabId); + } + }); + + useHotkey("PREV_TAB", () => { + const state = store.getState(); + if (!state.activeTabId || state.tabs.length === 0) return; + const index = state.tabs.findIndex((t) => t.id === state.activeTabId); + const prevIndex = index <= 0 ? state.tabs.length - 1 : index - 1; + state.setActiveTab(state.tabs[prevIndex].id); + }); + + useHotkey("NEXT_TAB", () => { + const state = store.getState(); + if (!state.activeTabId || state.tabs.length === 0) return; + const index = state.tabs.findIndex((t) => t.id === state.activeTabId); + const nextIndex = + index >= state.tabs.length - 1 || index === -1 ? 0 : index + 1; + state.setActiveTab(state.tabs[nextIndex].id); + }); + + useHotkey("PREV_TAB_ALT", () => { + const state = store.getState(); + if (!state.activeTabId || state.tabs.length === 0) return; + const index = state.tabs.findIndex((t) => t.id === state.activeTabId); + const prevIndex = index <= 0 ? state.tabs.length - 1 : index - 1; + state.setActiveTab(state.tabs[prevIndex].id); + }); + + useHotkey("NEXT_TAB_ALT", () => { + const state = store.getState(); + if (!state.activeTabId || state.tabs.length === 0) return; + const index = state.tabs.findIndex((t) => t.id === state.activeTabId); + const nextIndex = + index >= state.tabs.length - 1 || index === -1 ? 0 : index + 1; + state.setActiveTab(state.tabs[nextIndex].id); + }); + + const switchToTab = useCallback( + (index: number) => { + const state = store.getState(); + const tab = state.tabs[index]; + if (tab) state.setActiveTab(tab.id); + }, + [store], + ); + + useHotkey("JUMP_TO_TAB_1", () => switchToTab(0)); + useHotkey("JUMP_TO_TAB_2", () => switchToTab(1)); + useHotkey("JUMP_TO_TAB_3", () => switchToTab(2)); + useHotkey("JUMP_TO_TAB_4", () => switchToTab(3)); + useHotkey("JUMP_TO_TAB_5", () => switchToTab(4)); + useHotkey("JUMP_TO_TAB_6", () => switchToTab(5)); + useHotkey("JUMP_TO_TAB_7", () => switchToTab(6)); + useHotkey("JUMP_TO_TAB_8", () => switchToTab(7)); + useHotkey("JUMP_TO_TAB_9", () => switchToTab(8)); + + // --- Pane management --- + + useHotkey("PREV_PANE", () => { + const state = store.getState(); + const tab = state.getActiveTab(); + if (!tab || !tab.activePaneId) return; + const paneIds = Object.keys(tab.panes); + const index = paneIds.indexOf(tab.activePaneId); + const prevIndex = index <= 0 ? paneIds.length - 1 : index - 1; + state.setActivePane({ tabId: tab.id, paneId: paneIds[prevIndex] }); + }); + + useHotkey("NEXT_PANE", () => { + const state = store.getState(); + const tab = state.getActiveTab(); + if (!tab || !tab.activePaneId) return; + const paneIds = Object.keys(tab.panes); + const index = paneIds.indexOf(tab.activePaneId); + const nextIndex = index >= paneIds.length - 1 ? 0 : index + 1; + state.setActivePane({ tabId: tab.id, paneId: paneIds[nextIndex] }); + }); + + useHotkey("SPLIT_AUTO", () => { + const state = store.getState(); + const active = state.getActivePane(); + if (!active) return; + state.splitPane({ + tabId: active.tabId, + paneId: active.pane.id, + position: "right", + newPane: { + kind: "terminal", + data: { terminalId: crypto.randomUUID() } as TerminalPaneData, + }, + }); + }); + + useHotkey("SPLIT_RIGHT", () => { + const state = store.getState(); + const active = state.getActivePane(); + if (!active) return; + state.splitPane({ + tabId: active.tabId, + paneId: active.pane.id, + position: "right", + newPane: { + kind: "terminal", + data: { terminalId: crypto.randomUUID() } as TerminalPaneData, + }, + }); + }); + + useHotkey("SPLIT_DOWN", () => { + const state = store.getState(); + const active = state.getActivePane(); + if (!active) return; + state.splitPane({ + tabId: active.tabId, + paneId: active.pane.id, + position: "bottom", + newPane: { + kind: "terminal", + data: { terminalId: crypto.randomUUID() } as TerminalPaneData, + }, + }); + }); + + useHotkey("SPLIT_WITH_CHAT", () => { + const state = store.getState(); + const active = state.getActivePane(); + if (!active) return; + state.splitPane({ + tabId: active.tabId, + paneId: active.pane.id, + position: "right", + newPane: { + kind: "chat", + data: { sessionId: null } as ChatPaneData, + }, + }); + }); + + useHotkey("SPLIT_WITH_BROWSER", () => { + const state = store.getState(); + const active = state.getActivePane(); + if (!active) return; + state.splitPane({ + tabId: active.tabId, + paneId: active.pane.id, + position: "right", + newPane: { + kind: "browser", + data: { + url: "http://localhost:3000", + mode: "preview", + } as BrowserPaneData, + }, + }); + }); + + useHotkey("EQUALIZE_PANE_SPLITS", () => { + const state = store.getState(); + const tab = state.getActiveTab(); + if (!tab) return; + if (tab.layout.type === "split") { + state.equalizeSplit({ tabId: tab.id, splitId: tab.layout.id }); + } + }); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index effd872ff5a..e7beebc7286 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -27,6 +27,7 @@ import { WorkspaceNotFoundState } from "./components/WorkspaceNotFoundState"; import { WorkspaceSidebar } from "./components/WorkspaceSidebar"; import { usePaneRegistry } from "./hooks/usePaneRegistry"; import { useV2WorkspacePaneLayout } from "./hooks/useV2WorkspacePaneLayout"; +import { useWorkspaceHotkeys } from "./hooks/useWorkspaceHotkeys"; import type { BrowserPaneData, ChatPaneData, @@ -236,19 +237,9 @@ function WorkspaceContent({ [], ); - const collections = useCollections(); const sidebarOpen = localWorkspaceState?.rightSidebarOpen ?? false; - const toggleSidebar = useCallback(() => { - if (!collections.v2WorkspaceLocalState.get(workspaceId)) return; - collections.v2WorkspaceLocalState.update(workspaceId, (draft) => { - draft.rightSidebarOpen = !draft.rightSidebarOpen; - }); - }, [collections, workspaceId]); - useHotkey("TOGGLE_SIDEBAR", toggleSidebar); - useHotkey("NEW_GROUP", addTerminalTab); - useHotkey("NEW_CHAT", addChatTab); - useHotkey("NEW_BROWSER", addBrowserTab); + useWorkspaceHotkeys({ store, workspaceId }); useHotkey("QUICK_OPEN", handleQuickOpen); useEffect(() => { diff --git a/bun.lock b/bun.lock index e05e96023f2..394d8fc20d8 100644 --- a/bun.lock +++ b/bun.lock @@ -108,6 +108,10 @@ "typescript": "^5.9.3", }, }, + "apps/cli": { + "name": "cli", + "version": "0.0.0", + }, "apps/desktop": { "name": "@superset/desktop", "version": "1.4.7", @@ -3297,6 +3301,8 @@ "clean-yaml-object": ["clean-yaml-object@0.1.0", "", {}, "sha512-3yONmlN9CSAkzNwnRCiJQ7Q2xK5mWuEfL3PuTZcAUzhObbXsfsnMptJzXwz93nc5zn9V9TwCVMmV7w4xsm43dw=="], + "cli": ["cli@workspace:apps/cli"], + "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], "cli-highlight": ["cli-highlight@2.1.11", "", { "dependencies": { "chalk": "^4.0.0", "highlight.js": "^10.7.1", "mz": "^2.4.0", "parse5": "^5.1.1", "parse5-htmlparser2-tree-adapter": "^6.0.0", "yargs": "^16.0.0" }, "bin": { "highlight": "bin/highlight" } }, "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg=="], From 7558be6d917a9e878f5a27e54cfa9d904fe70d2f Mon Sep 17 00:00:00 2001 From: Kiet <31864905+Kitenite@users.noreply.github.com> Date: Sun, 5 Apr 2026 16:41:57 -0700 Subject: [PATCH 393/816] V2 terminal env (#3184) * Env plan * Update doc * Update docs * Env * Refactor * Env * update env * Fix * remove vscode * clean shell approach * Working cleaner shell * Use default shell * Update tests * Lint * Remove slop comments * Typecheck * Move into host service * Remove dup * Lint * handle theme, ssh, and root location * Theme * Lint --- apps/desktop/src/main/host-service/index.ts | 175 ++--- .../src/main/lib/host-service-manager.test.ts | 79 +- .../src/main/lib/host-service-manager.ts | 9 + .../components/TerminalPane/TerminalPane.tsx | 12 + bun.lock | 7 +- packages/host-service/package.json | 5 + packages/host-service/src/serve.ts | 29 +- .../src/terminal/clean-shell-env.ts | 185 +++++ .../host-service/src/terminal/env-strip.ts | 59 ++ .../host-service/src/terminal/env.test.ts | 508 +++++++++++++ packages/host-service/src/terminal/env.ts | 177 +++++ .../host-service/src/terminal/shell-launch.ts | 116 +++ .../host-service/src/terminal/terminal.ts | 69 +- packages/host-service/tsconfig.json | 3 +- plans/v2-terminal-env-handoff.md | 674 ++++++++++++++++++ 15 files changed, 1981 insertions(+), 126 deletions(-) create mode 100644 packages/host-service/src/terminal/clean-shell-env.ts create mode 100644 packages/host-service/src/terminal/env-strip.ts create mode 100644 packages/host-service/src/terminal/env.test.ts create mode 100644 packages/host-service/src/terminal/env.ts create mode 100644 packages/host-service/src/terminal/shell-launch.ts create mode 100644 plans/v2-terminal-env-handoff.md diff --git a/apps/desktop/src/main/host-service/index.ts b/apps/desktop/src/main/host-service/index.ts index 88f85fe793e..673e87111d3 100644 --- a/apps/desktop/src/main/host-service/index.ts +++ b/apps/desktop/src/main/host-service/index.ts @@ -17,100 +17,111 @@ import { LocalGitCredentialProvider, PskHostAuthProvider, } from "@superset/host-service"; +import { + initTerminalBaseEnv, + resolveTerminalBaseEnv, +} from "@superset/host-service/terminal-env"; import { HOST_SERVICE_PROTOCOL_VERSION, removeManifest, writeManifest, } from "main/lib/host-service-manifest"; -const authToken = process.env.AUTH_TOKEN; -const cloudApiUrl = process.env.CLOUD_API_URL; -const dbPath = process.env.HOST_DB_PATH; -const deviceClientId = process.env.DEVICE_CLIENT_ID; -const deviceName = process.env.DEVICE_NAME; -const hostServiceSecret = process.env.HOST_SERVICE_SECRET; -const serviceVersion = process.env.HOST_SERVICE_VERSION ?? null; -const protocolVersion = HOST_SERVICE_PROTOCOL_VERSION; -const organizationId = process.env.ORGANIZATION_ID ?? ""; -const desktopVitePort = process.env.DESKTOP_VITE_PORT ?? "5173"; -const keepAliveAfterParent = process.env.KEEP_ALIVE_AFTER_PARENT === "1"; +async function main(): Promise { + const terminalBaseEnv = await resolveTerminalBaseEnv(); + initTerminalBaseEnv(terminalBaseEnv); -const auth = - authToken && cloudApiUrl ? new JwtApiAuthProvider(authToken) : undefined; -const hostAuth = hostServiceSecret - ? new PskHostAuthProvider(hostServiceSecret) - : undefined; + const authToken = process.env.AUTH_TOKEN; + const cloudApiUrl = process.env.CLOUD_API_URL; + const dbPath = process.env.HOST_DB_PATH; + const deviceClientId = process.env.DEVICE_CLIENT_ID; + const deviceName = process.env.DEVICE_NAME; + const hostServiceSecret = process.env.HOST_SERVICE_SECRET; + const serviceVersion = process.env.HOST_SERVICE_VERSION ?? null; + const protocolVersion = HOST_SERVICE_PROTOCOL_VERSION; + const organizationId = process.env.ORGANIZATION_ID ?? ""; + const desktopVitePort = process.env.DESKTOP_VITE_PORT ?? "5173"; + const keepAliveAfterParent = process.env.KEEP_ALIVE_AFTER_PARENT === "1"; -const { app, injectWebSocket } = createApp({ - credentials: new LocalGitCredentialProvider(), - auth, - hostAuth, - cloudApiUrl, - dbPath, - deviceClientId, - deviceName, - serviceVersion, - protocolVersion, - allowedOrigins: [ - `http://localhost:${desktopVitePort}`, - `http://127.0.0.1:${desktopVitePort}`, - ], -}); + const auth = + authToken && cloudApiUrl ? new JwtApiAuthProvider(authToken) : undefined; + const hostAuth = hostServiceSecret + ? new PskHostAuthProvider(hostServiceSecret) + : undefined; -const startedAt = Date.now(); + const { app, injectWebSocket } = createApp({ + credentials: new LocalGitCredentialProvider(), + auth, + hostAuth, + cloudApiUrl, + dbPath, + deviceClientId, + deviceName, + serviceVersion, + protocolVersion, + allowedOrigins: [ + `http://localhost:${desktopVitePort}`, + `http://127.0.0.1:${desktopVitePort}`, + ], + }); -const server = serve( - { fetch: app.fetch, port: 0, hostname: "127.0.0.1" }, - (info: { port: number }) => { - if (organizationId) { - try { - writeManifest({ - pid: process.pid, - endpoint: `http://127.0.0.1:${info.port}`, - authToken: hostServiceSecret ?? "", - serviceVersion: serviceVersion ?? "", - protocolVersion: protocolVersion ?? 0, - startedAt, - organizationId, - }); - } catch (error) { - console.error("[host-service] Failed to write manifest:", error); + const startedAt = Date.now(); + const server = serve( + { fetch: app.fetch, port: 0, hostname: "127.0.0.1" }, + (info: { port: number }) => { + if (organizationId) { + try { + writeManifest({ + pid: process.pid, + endpoint: `http://127.0.0.1:${info.port}`, + authToken: hostServiceSecret ?? "", + serviceVersion: serviceVersion ?? "", + protocolVersion: protocolVersion ?? 0, + startedAt, + organizationId, + }); + } catch (error) { + console.error("[host-service] Failed to write manifest:", error); + } } - } - process.send?.({ - type: "ready", - port: info.port, - serviceVersion, - protocolVersion, - startedAt, - }); - }, -); -injectWebSocket(server); + process.send?.({ + type: "ready", + port: info.port, + serviceVersion, + protocolVersion, + startedAt, + }); + }, + ); + injectWebSocket(server); -const shutdown = () => { - if (organizationId) { - removeManifest(organizationId); - } - server.close(); - process.exit(0); -}; + const shutdown = () => { + if (organizationId) { + removeManifest(organizationId); + } + server.close(); + process.exit(0); + }; -process.on("SIGTERM", shutdown); -process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + process.on("SIGINT", shutdown); -// Orphan cleanup: exit if parent Electron process dies. -// Disabled in keep-alive mode so the service survives app quit. -if (!keepAliveAfterParent) { - const parentPid = process.ppid; - const parentCheck = setInterval(() => { - try { - process.kill(parentPid, 0); - } catch { - clearInterval(parentCheck); - console.log("[host-service] Parent process exited, shutting down"); - shutdown(); - } - }, 2000); - parentCheck.unref(); + if (!keepAliveAfterParent) { + const parentPid = process.ppid; + const parentCheck = setInterval(() => { + try { + process.kill(parentPid, 0); + } catch { + clearInterval(parentCheck); + console.log("[host-service] Parent process exited, shutting down"); + shutdown(); + } + }, 2000); + parentCheck.unref(); + } } + +void main().catch((error) => { + console.error("[host-service] Failed to start:", error); + process.exit(1); +}); diff --git a/apps/desktop/src/main/lib/host-service-manager.test.ts b/apps/desktop/src/main/lib/host-service-manager.test.ts index 5618eec4be5..89a3ddc74ee 100644 --- a/apps/desktop/src/main/lib/host-service-manager.test.ts +++ b/apps/desktop/src/main/lib/host-service-manager.test.ts @@ -31,7 +31,12 @@ class MockChildProcess extends EventEmitter { } const getProcessEnvWithShellPathMock = mock( - async (env: Record) => env, + async (baseEnv?: NodeJS.ProcessEnv): Promise> => ({ + ...(baseEnv ? (baseEnv as Record) : {}), + HOME: "/Users/test", + PATH: "/usr/bin:/bin", + SHELL: "/bin/zsh", + }), ); let lastChild: MockChildProcess | null = null; const spawnMock = mock((..._args: unknown[]) => { @@ -51,12 +56,9 @@ describe("HostServiceManager", () => { spyOn(childProcessModule, "spawn").mockImplementation(((..._args) => spawnMock(..._args)) as typeof childProcessModule.spawn); - spyOn(shellEnvModule, "getProcessEnvWithShellPath").mockImplementation((( - baseEnv: NodeJS.ProcessEnv = process.env, - ) => - getProcessEnvWithShellPathMock( - baseEnv as Record, - )) as typeof shellEnvModule.getProcessEnvWithShellPath); + spyOn(shellEnvModule, "getProcessEnvWithShellPath").mockImplementation( + (baseEnv) => getProcessEnvWithShellPathMock(baseEnv), + ); mock.module("electron", () => ({ app: { @@ -81,7 +83,12 @@ describe("HostServiceManager", () => { beforeEach(() => { getProcessEnvWithShellPathMock.mockReset(); getProcessEnvWithShellPathMock.mockImplementation( - async (env: Record) => env, + async (baseEnv?: NodeJS.ProcessEnv) => ({ + ...(baseEnv ? (baseEnv as Record) : {}), + HOME: "/Users/test", + PATH: "/usr/bin:/bin", + SHELL: "/bin/zsh", + }), ); spawnMock.mockReset(); spawnMock.mockImplementation(() => { @@ -91,7 +98,7 @@ describe("HostServiceManager", () => { lastChild = null; }); - it("dedupes concurrent starts while shell PATH is resolving", async () => { + it("dedupes concurrent starts while shell env is resolving", async () => { const manager = new HostServiceManager(); const pendingEnv = createDeferred>(); getProcessEnvWithShellPathMock.mockImplementation(() => pendingEnv.promise); @@ -101,11 +108,14 @@ describe("HostServiceManager", () => { expect(manager.getStatus("org-1")).toBe("starting"); - // Flush microtasks so tryAdopt completes (no manifest → falls through to spawn) await new Promise((resolve) => setTimeout(resolve, 0)); expect(getProcessEnvWithShellPathMock.mock.calls).toHaveLength(1); - pendingEnv.resolve({ PATH: "/usr/bin:/bin" }); + pendingEnv.resolve({ + HOME: "/Users/test", + PATH: "/usr/bin:/bin", + SHELL: "/bin/zsh", + }); await new Promise((resolve) => setTimeout(resolve, 0)); expect(spawnMock.mock.calls).toHaveLength(1); @@ -121,6 +131,53 @@ describe("HostServiceManager", () => { expect(manager.getPort("org-1")).toBe(4242); }); + it("spawns host-service from shell-path env plus explicit service keys", async () => { + const manager = new HostServiceManager(); + manager.setAuthToken("auth-token"); + manager.setCloudApiUrl("https://api.example.com"); + + const originalValues = { + __HOST_SERVICE_RUNTIME_ENV_TEST__: + process.env.__HOST_SERVICE_RUNTIME_ENV_TEST__, + }; + process.env.__HOST_SERVICE_RUNTIME_ENV_TEST__ = "desktop-runtime"; + + try { + const startPromise = manager.start("org-1"); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const spawnOptions = spawnMock.mock.calls[0]?.[2] as + | { env?: Record } + | undefined; + const env = spawnOptions?.env; + + expect(env).toBeDefined(); + expect(env).toMatchObject({ + HOME: "/Users/test", + PATH: "/usr/bin:/bin", + SHELL: "/bin/zsh", + __HOST_SERVICE_RUNTIME_ENV_TEST__: "desktop-runtime", + AUTH_TOKEN: "auth-token", + CLOUD_API_URL: "https://api.example.com", + ELECTRON_RUN_AS_NODE: "1", + SUPERSET_AGENT_HOOK_VERSION: expect.any(String), + SUPERSET_HOME_DIR: expect.any(String), + }); + expect(env?.HOST_SERVICE_SECRET).toEqual(expect.any(String)); + expect(env?.HOST_DB_PATH).toEqual(expect.any(String)); + + lastChild?.emit("message", { type: "ready", port: 4001 }); + await startPromise; + } finally { + if (originalValues.__HOST_SERVICE_RUNTIME_ENV_TEST__ !== undefined) { + process.env.__HOST_SERVICE_RUNTIME_ENV_TEST__ = + originalValues.__HOST_SERVICE_RUNTIME_ENV_TEST__; + } else { + delete process.env.__HOST_SERVICE_RUNTIME_ENV_TEST__; + } + } + }); + it("stopAll() kills all instances", async () => { const manager = new HostServiceManager(); diff --git a/apps/desktop/src/main/lib/host-service-manager.ts b/apps/desktop/src/main/lib/host-service-manager.ts index 75062557811..16b90543592 100644 --- a/apps/desktop/src/main/lib/host-service-manager.ts +++ b/apps/desktop/src/main/lib/host-service-manager.ts @@ -4,7 +4,9 @@ import { randomBytes } from "node:crypto"; import { EventEmitter } from "node:events"; import path from "node:path"; import { app } from "electron"; +import { env as sharedEnv } from "shared/env.shared"; import { getProcessEnvWithShellPath } from "../../lib/trpc/routers/workspaces/utils/shell-env"; +import { SUPERSET_HOME_DIR } from "./app-environment"; import { getDeviceName, getHashedDeviceId } from "./device-info"; import { HOST_SERVICE_PROTOCOL_VERSION, @@ -15,6 +17,7 @@ import { readManifest, removeManifest, } from "./host-service-manifest"; +import { HOOK_PROTOCOL_VERSION } from "./terminal/env"; export type HostServiceStatus = | "starting" @@ -123,8 +126,10 @@ async function buildHostServiceEnv( secret: string, ): Promise> { const orgDir = manifestDir(organizationId); + return getProcessEnvWithShellPath({ ...(process.env as Record), + // Host-service runtime keys ELECTRON_RUN_AS_NODE: "1", ORGANIZATION_ID: organizationId, DEVICE_CLIENT_ID: getHashedDeviceId(), @@ -137,6 +142,10 @@ async function buildHostServiceEnv( HOST_MIGRATIONS_PATH: app.isPackaged ? path.join(process.resourcesPath, "resources/host-migrations") : path.join(app.getAppPath(), "../../packages/host-service/drizzle"), + DESKTOP_VITE_PORT: String(sharedEnv.DESKTOP_VITE_PORT), + SUPERSET_HOME_DIR: SUPERSET_HOME_DIR, + SUPERSET_AGENT_HOOK_PORT: String(sharedEnv.DESKTOP_NOTIFICATIONS_PORT), + SUPERSET_AGENT_HOOK_VERSION: HOOK_PROTOCOL_VERSION, }); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx index 26e93fca8fc..866b9ca3e93 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx @@ -10,6 +10,8 @@ import type { TerminalPaneData, } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; import { useWorkspaceWsUrl } from "renderer/routes/_authenticated/_dashboard/v2-workspace/providers/WorkspaceTrpcProvider/WorkspaceTrpcProvider"; +import { useTheme } from "renderer/stores/theme"; +import { resolveTerminalThemeType } from "renderer/stores/theme/utils"; import { useTerminalAppearance } from "./hooks/useTerminalAppearance"; interface TerminalPaneProps { @@ -36,13 +38,23 @@ export function TerminalPane({ ctx, workspaceId }: TerminalPaneProps) { [data.terminalId], ); const containerRef = useRef(null); + const activeTheme = useTheme(); const appearance = useTerminalAppearance(); const appearanceRef = useRef(appearance); appearanceRef.current = appearance; + const initialThemeTypeRef = useRef< + ReturnType + >( + resolveTerminalThemeType({ + activeThemeType: activeTheme?.type, + }), + ); + const initialThemeType = initialThemeTypeRef.current; const websocketUrl = useWorkspaceWsUrl(`/terminal/${terminalId}`, { workspaceId, + themeType: initialThemeType, }); const connectionState = useSyncExternalStore( diff --git a/bun.lock b/bun.lock index 394d8fc20d8..0a53c7e339a 100644 --- a/bun.lock +++ b/bun.lock @@ -108,10 +108,6 @@ "typescript": "^5.9.3", }, }, - "apps/cli": { - "name": "cli", - "version": "0.0.0", - }, "apps/desktop": { "name": "@superset/desktop", "version": "1.4.7", @@ -755,6 +751,7 @@ "@superset/typescript": "workspace:*", "@types/better-sqlite3": "^7.6.13", "@types/node": "^24.9.1", + "bun-types": "^1.3.1", "drizzle-kit": "0.31.8", "typescript": "^5.9.3", }, @@ -3301,8 +3298,6 @@ "clean-yaml-object": ["clean-yaml-object@0.1.0", "", {}, "sha512-3yONmlN9CSAkzNwnRCiJQ7Q2xK5mWuEfL3PuTZcAUzhObbXsfsnMptJzXwz93nc5zn9V9TwCVMmV7w4xsm43dw=="], - "cli": ["cli@workspace:apps/cli"], - "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], "cli-highlight": ["cli-highlight@2.1.11", "", { "dependencies": { "chalk": "^4.0.0", "highlight.js": "^10.7.1", "mz": "^2.4.0", "parse5": "^5.1.1", "parse5-htmlparser2-tree-adapter": "^6.0.0", "yargs": "^16.0.0" }, "bin": { "highlight": "bin/highlight" } }, "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg=="], diff --git a/packages/host-service/package.json b/packages/host-service/package.json index 87033f68b91..3d26d7ef096 100644 --- a/packages/host-service/package.json +++ b/packages/host-service/package.json @@ -23,6 +23,10 @@ "./trpc": { "types": "./src/trpc/index.ts", "default": "./src/trpc/index.ts" + }, + "./terminal-env": { + "types": "./src/terminal/env.ts", + "default": "./src/terminal/env.ts" } }, "scripts": { @@ -53,6 +57,7 @@ "@superset/typescript": "workspace:*", "@types/better-sqlite3": "^7.6.13", "@types/node": "^24.9.1", + "bun-types": "^1.3.1", "drizzle-kit": "0.31.8", "typescript": "^5.9.3" } diff --git a/packages/host-service/src/serve.ts b/packages/host-service/src/serve.ts index 7f4c369e782..e9524b3739c 100644 --- a/packages/host-service/src/serve.ts +++ b/packages/host-service/src/serve.ts @@ -2,15 +2,26 @@ import { serve } from "@hono/node-server"; import { createApp } from "./app"; import { env } from "./env"; import { PskHostAuthProvider } from "./providers/host-auth"; +import { initTerminalBaseEnv, resolveTerminalBaseEnv } from "./terminal/env"; -const hostAuth = new PskHostAuthProvider(env.HOST_SERVICE_SECRET); -const { app, injectWebSocket } = createApp({ - dbPath: env.HOST_DB_PATH, - hostAuth, - allowedOrigins: env.CORS_ORIGINS ?? [], -}); +async function main(): Promise { + const terminalBaseEnv = await resolveTerminalBaseEnv(); + initTerminalBaseEnv(terminalBaseEnv); + + const hostAuth = new PskHostAuthProvider(env.HOST_SERVICE_SECRET); + const { app, injectWebSocket } = createApp({ + dbPath: env.HOST_DB_PATH, + hostAuth, + allowedOrigins: env.CORS_ORIGINS ?? [], + }); + + const server = serve({ fetch: app.fetch, port: env.PORT }, (info) => { + console.log(`[host-service] listening on http://localhost:${info.port}`); + }); + injectWebSocket(server); +} -const server = serve({ fetch: app.fetch, port: env.PORT }, (info) => { - console.log(`[host-service] listening on http://localhost:${info.port}`); +void main().catch((error) => { + console.error("[host-service] Failed to start:", error); + process.exit(1); }); -injectWebSocket(server); diff --git a/packages/host-service/src/terminal/clean-shell-env.ts b/packages/host-service/src/terminal/clean-shell-env.ts new file mode 100644 index 00000000000..96d331927ce --- /dev/null +++ b/packages/host-service/src/terminal/clean-shell-env.ts @@ -0,0 +1,185 @@ +import { type ChildProcess, spawn } from "node:child_process"; + +const SHELL_ENV_TIMEOUT_MS = 8_000; +const CACHE_TTL_MS = 60_000; +const DELIMITER = "__SUPERSET_SHELL_ENV__"; + +const SHELL_BOOTSTRAP_KEYS = [ + "HOME", + "USER", + "LOGNAME", + "SHELL", + "PATH", + "TERM", + "TMPDIR", + "LANG", + "LC_ALL", + "LC_CTYPE", + "__CF_USER_TEXT_ENCODING", + "Apple_PubSub_Socket_Render", + "COMSPEC", + "USERPROFILE", + "SYSTEMROOT", +]; + +const COMMON_MACOS_PATHS = [ + "/opt/homebrew/bin", + "/opt/homebrew/sbin", + "/usr/local/bin", + "/usr/local/sbin", +]; + +function augmentPathForMacOS( + env: Record, + platform: NodeJS.Platform = process.platform, +): void { + if (platform !== "darwin") return; + + const currentPath = env.PATH ?? ""; + const currentEntries = currentPath.split(":").filter(Boolean); + const pathEntries = new Set(currentEntries); + const missingPaths = COMMON_MACOS_PATHS.filter( + (path) => !pathEntries.has(path), + ); + env.PATH = [...missingPaths, currentPath].filter(Boolean).join(":"); +} + +function buildMinimalEnv(): Record { + const env: Record = { + DISABLE_AUTO_UPDATE: "true", + ZSH_TMUX_AUTOSTARTED: "true", + ZSH_TMUX_AUTOSTART: "false", + }; + + for (const key of SHELL_BOOTSTRAP_KEYS) { + const value = process.env[key]; + if (value) env[key] = value; + } + + augmentPathForMacOS(env); + return env; +} + +function resolveShellForEnv(): string { + if (process.platform === "win32") { + return process.env.COMSPEC || "cmd.exe"; + } + return process.env.SHELL || "/bin/sh"; +} + +function parseEnvOutput(stdout: string): Record { + const envSection = stdout.split(DELIMITER)[1]; + if (!envSection) { + throw new Error("Failed to parse shell env output - delimiter not found"); + } + + const result: Record = {}; + for (const line of envSection.split("\n").filter(Boolean)) { + const idx = line.indexOf("="); + if (idx > 0) { + result[line.slice(0, idx)] = line.slice(idx + 1); + } + } + + if (Object.keys(result).length === 0) { + throw new Error( + "Shell env resolution returned empty - shell may have failed to start", + ); + } + + return result; +} + +function spawnCleanShellEnv(): Promise> { + return new Promise((resolve, reject) => { + const shell = resolveShellForEnv(); + const env = buildMinimalEnv(); + const command = `echo -n "${DELIMITER}"; command env; echo -n "${DELIMITER}"; exit`; + + let child: ChildProcess; + try { + child = spawn(shell, ["-i", "-l", "-c", command], { + detached: true, + stdio: ["ignore", "pipe", "pipe"], + env, + }); + } catch (error) { + return reject( + new Error( + `Failed to spawn shell ${shell}: ${error instanceof Error ? error.message : error}`, + ), + ); + } + + const stdoutBuffers: Buffer[] = []; + const stderrBuffers: Buffer[] = []; + + child.stdout?.on("data", (data: Buffer) => stdoutBuffers.push(data)); + child.stderr?.on("data", (data: Buffer) => stderrBuffers.push(data)); + + const timeout = setTimeout(() => { + try { + child.kill("SIGKILL"); + } catch { + // Already exited. + } + + reject( + new Error( + `Shell env resolution timed out after ${SHELL_ENV_TIMEOUT_MS}ms`, + ), + ); + }, SHELL_ENV_TIMEOUT_MS); + + child.on("error", (error) => { + clearTimeout(timeout); + reject(new Error(`Shell process error for ${shell}: ${error.message}`)); + }); + + child.on("close", (code, signal) => { + clearTimeout(timeout); + + const stderr = Buffer.concat(stderrBuffers).toString("utf8").trim(); + if (stderr) { + console.debug("[terminal-clean-shell-env] stderr:", stderr); + } + + if (code !== 0 && code !== null) { + return reject( + new Error( + `Shell ${shell} exited with code ${code}${signal ? `, signal ${signal}` : ""}`, + ), + ); + } + + try { + resolve(parseEnvOutput(Buffer.concat(stdoutBuffers).toString("utf8"))); + } catch (error) { + reject(error); + } + }); + + child.unref(); + }); +} + +let cache: Record | null = null; +let cacheTime = 0; + +export async function getStrictShellEnvironment(): Promise< + Record +> { + if (cache && Date.now() - cacheTime < CACHE_TTL_MS) { + return { ...cache }; + } + + const env = await spawnCleanShellEnv(); + cache = env; + cacheTime = Date.now(); + return { ...cache }; +} + +export function clearStrictShellEnvCache(): void { + cache = null; + cacheTime = 0; +} diff --git a/packages/host-service/src/terminal/env-strip.ts b/packages/host-service/src/terminal/env-strip.ts new file mode 100644 index 00000000000..e5eab939cd4 --- /dev/null +++ b/packages/host-service/src/terminal/env-strip.ts @@ -0,0 +1,59 @@ +/** + * Runtime env stripping for v2 terminals. + * + * Denylist approach: the host-service base env is a shell-derived snapshot + * plus explicit runtime additions from desktop. We strip the known additions + * rather than allowlisting, because the shell snapshot should pass through + * untouched (version managers, proxy config, etc.). + */ + +/** + * Exact keys injected by desktop into host-service. + * + * DESKTOP_* and DEVICE_* are exact keys (not prefixes) because + * DESKTOP_SESSION, DESKTOP_STARTUP_ID etc. are legitimate Linux vars. + */ +const HOST_SERVICE_RUNTIME_KEYS = new Set([ + "AUTH_TOKEN", + "CLOUD_API_URL", + "DESKTOP_VITE_PORT", + "DEVICE_CLIENT_ID", + "DEVICE_NAME", + "KEEP_ALIVE_AFTER_PARENT", + "ORGANIZATION_ID", +]); + +const NODE_APP_KEYS = new Set(["NODE_ENV", "NODE_OPTIONS", "NODE_PATH"]); + +const STRIP_PREFIXES = [ + "npm_", + "npm_config_", + "ELECTRON_", + "VITE_", + "NEXT_PUBLIC_", + "TURBO_", + "HOST_", +]; + +const SUPERSET_KEEP_KEYS = new Set([ + "SUPERSET_HOME_DIR", + "SUPERSET_AGENT_HOOK_PORT", + "SUPERSET_AGENT_HOOK_VERSION", +]); + +export function stripTerminalRuntimeEnv( + baseEnv: Record, +): Record { + const result: Record = {}; + + for (const [key, value] of Object.entries(baseEnv)) { + if (HOST_SERVICE_RUNTIME_KEYS.has(key)) continue; + if (NODE_APP_KEYS.has(key)) continue; + if (STRIP_PREFIXES.some((prefix) => key.startsWith(prefix))) continue; + if (key.startsWith("SUPERSET_") && !SUPERSET_KEEP_KEYS.has(key)) continue; + + result[key] = value; + } + + return result; +} diff --git a/packages/host-service/src/terminal/env.test.ts b/packages/host-service/src/terminal/env.test.ts new file mode 100644 index 00000000000..d1375fdde0c --- /dev/null +++ b/packages/host-service/src/terminal/env.test.ts @@ -0,0 +1,508 @@ +import { describe, expect, test } from "bun:test"; +import { + buildV2TerminalEnv, + getShellBootstrapEnv, + getShellLaunchArgs, + getTerminalBaseEnv, + initTerminalBaseEnv, + normalizeUtf8Locale, + resetTerminalBaseEnvForTests, + resolveLaunchShell, + stripTerminalRuntimeEnv, +} from "./env"; + +// ── resolveLaunchShell ─────────────────────────────────────────────── + +describe("resolveLaunchShell", () => { + test("returns SHELL from base env on non-Windows", () => { + expect(resolveLaunchShell({ SHELL: "/usr/local/bin/fish" })).toBe( + "/usr/local/bin/fish", + ); + }); + + test("falls back to /bin/sh when SHELL is absent", () => { + expect(resolveLaunchShell({})).toBe("/bin/sh"); + }); + + test("does not default to /bin/zsh", () => { + expect(resolveLaunchShell({})).not.toBe("/bin/zsh"); + }); +}); + +// ── normalizeUtf8Locale ────────────────────────────────────────────── + +describe("normalizeUtf8Locale", () => { + test("LC_ALL takes precedence over LANG (POSIX)", () => { + expect( + normalizeUtf8Locale({ LC_ALL: "fr_FR.UTF-8", LANG: "en_US.UTF-8" }), + ).toBe("fr_FR.UTF-8"); + }); + + test("falls back to LANG when LC_ALL is absent", () => { + expect(normalizeUtf8Locale({ LANG: "ja_JP.UTF-8" })).toBe("ja_JP.UTF-8"); + }); + + test("matches case-insensitive utf8 variants", () => { + expect(normalizeUtf8Locale({ LANG: "en_US.utf8" })).toBe("en_US.utf8"); + expect(normalizeUtf8Locale({ LC_ALL: "C.UTF8" })).toBe("C.UTF8"); + }); + + test("defaults to en_US.UTF-8", () => { + expect(normalizeUtf8Locale({})).toBe("en_US.UTF-8"); + }); + + test("ignores non-UTF-8 locales", () => { + expect(normalizeUtf8Locale({ LANG: "C", LC_ALL: "POSIX" })).toBe( + "en_US.UTF-8", + ); + }); +}); + +// ── stripTerminalRuntimeEnv ────────────────────────────────────────── + +describe("stripTerminalRuntimeEnv", () => { + const secretsEnv: Record = { + // Host-service runtime keys that must not leak + AUTH_TOKEN: "secret-token", + HOST_SERVICE_SECRET: "secret", + ORGANIZATION_ID: "org-123", + DEVICE_CLIENT_ID: "device-abc", + DEVICE_NAME: "My Mac", + ELECTRON_RUN_AS_NODE: "1", + HOST_DB_PATH: "/tmp/host.db", + HOST_MANIFEST_DIR: "/tmp/manifests", + HOST_MIGRATIONS_PATH: "/tmp/migrations", + HOST_SERVICE_VERSION: "1.2.3", + KEEP_ALIVE_AFTER_PARENT: "1", + CLOUD_API_URL: "https://api.example.com", + DESKTOP_VITE_PORT: "5173", + // Node/app keys + NODE_ENV: "development", + NODE_OPTIONS: "--max-old-space-size=4096", + NODE_PATH: "/some/path", + // Dev-runner and Electron runtime vars + npm_package_name: "superset", + npm_config_registry: "https://registry.npmjs.org", + npm_lifecycle_event: "dev", + ELECTRON_ENABLE_LOGGING: "1", + // Build-tool prefix keys + VITE_API_URL: "http://localhost:3000", + NEXT_PUBLIC_KEY: "pk_123", + TURBO_TEAM: "my-team", + // Legacy SUPERSET_* vars that should be stripped + SUPERSET_PANE_ID: "pane-1", + SUPERSET_TAB_ID: "tab-1", + SUPERSET_PORT: "51741", + SUPERSET_HOOK_VERSION: "2", + SUPERSET_WORKSPACE_NAME: "my-ws", + // Keys that SHOULD survive + HOME: "/Users/test", + PATH: "/usr/bin:/usr/local/bin", + SHELL: "/bin/zsh", + EDITOR: "vim", + SUPERSET_HOME_DIR: "/Users/test/.superset", + SUPERSET_AGENT_HOOK_PORT: "51741", + SUPERSET_AGENT_HOOK_VERSION: "2", + }; + + test("app/runtime secrets do not reach PTY env", () => { + const result = stripTerminalRuntimeEnv(secretsEnv); + expect(result.AUTH_TOKEN).toBeUndefined(); + expect(result.HOST_SERVICE_SECRET).toBeUndefined(); + expect(result.ORGANIZATION_ID).toBeUndefined(); + expect(result.DEVICE_CLIENT_ID).toBeUndefined(); + expect(result.ELECTRON_RUN_AS_NODE).toBeUndefined(); + expect(result.HOST_DB_PATH).toBeUndefined(); + expect(result.CLOUD_API_URL).toBeUndefined(); + expect(result.DESKTOP_VITE_PORT).toBeUndefined(); + }); + + test("host-service control vars do not reach PTY env", () => { + const result = stripTerminalRuntimeEnv(secretsEnv); + expect(result.HOST_MANIFEST_DIR).toBeUndefined(); + expect(result.HOST_MIGRATIONS_PATH).toBeUndefined(); + expect(result.HOST_SERVICE_VERSION).toBeUndefined(); + expect(result.KEEP_ALIVE_AFTER_PARENT).toBeUndefined(); + expect(result.DEVICE_NAME).toBeUndefined(); + }); + + test("Node/app keys are stripped", () => { + const result = stripTerminalRuntimeEnv(secretsEnv); + expect(result.NODE_ENV).toBeUndefined(); + expect(result.NODE_OPTIONS).toBeUndefined(); + expect(result.NODE_PATH).toBeUndefined(); + }); + + test("dev-runner and Electron runtime vars do not reach PTY env", () => { + const result = stripTerminalRuntimeEnv(secretsEnv); + expect(result.npm_package_name).toBeUndefined(); + expect(result.npm_config_registry).toBeUndefined(); + expect(result.npm_lifecycle_event).toBeUndefined(); + expect(result.ELECTRON_ENABLE_LOGGING).toBeUndefined(); + }); + + test("HOST_* prefix is stripped, DESKTOP_*/DEVICE_* are exact-key only", () => { + const env: Record = { + // HOST_* prefix: all stripped + HOST_DB_PATH: "/tmp/db", + HOST_MANIFEST_DIR: "/tmp/manifests", + HOST_SERVICE_SECRET: "secret", + // DESKTOP_* / DEVICE_*: only our exact keys stripped + DESKTOP_VITE_PORT: "5173", + DEVICE_CLIENT_ID: "abc", + DEVICE_NAME: "Mac", + // Legitimate Linux desktop vars: must survive + DESKTOP_SESSION: "gnome", + DESKTOP_STARTUP_ID: "startup-123", + HOME: "/Users/test", + }; + const result = stripTerminalRuntimeEnv(env); + expect(result.HOST_DB_PATH).toBeUndefined(); + expect(result.HOST_MANIFEST_DIR).toBeUndefined(); + expect(result.HOST_SERVICE_SECRET).toBeUndefined(); + expect(result.DESKTOP_VITE_PORT).toBeUndefined(); + expect(result.DEVICE_CLIENT_ID).toBeUndefined(); + expect(result.DEVICE_NAME).toBeUndefined(); + // Linux desktop vars preserved + expect(result.DESKTOP_SESSION).toBe("gnome"); + expect(result.DESKTOP_STARTUP_ID).toBe("startup-123"); + expect(result.HOME).toBe("/Users/test"); + }); + + test("build-tool prefix keys are stripped", () => { + const result = stripTerminalRuntimeEnv(secretsEnv); + expect(result.VITE_API_URL).toBeUndefined(); + expect(result.NEXT_PUBLIC_KEY).toBeUndefined(); + expect(result.TURBO_TEAM).toBeUndefined(); + }); + + test("removed legacy vars do not reach PTY env", () => { + const result = stripTerminalRuntimeEnv(secretsEnv); + expect(result.SUPERSET_PANE_ID).toBeUndefined(); + expect(result.SUPERSET_TAB_ID).toBeUndefined(); + expect(result.SUPERSET_PORT).toBeUndefined(); + expect(result.SUPERSET_HOOK_VERSION).toBeUndefined(); + expect(result.SUPERSET_WORKSPACE_NAME).toBeUndefined(); + }); + + test("user shell env vars survive stripping", () => { + const result = stripTerminalRuntimeEnv(secretsEnv); + expect(result.HOME).toBe("/Users/test"); + expect(result.PATH).toBe("/usr/bin:/usr/local/bin"); + expect(result.SHELL).toBe("/bin/zsh"); + expect(result.EDITOR).toBe("vim"); + }); + + test("explicit Superset support keys are kept", () => { + const result = stripTerminalRuntimeEnv(secretsEnv); + expect(result.SUPERSET_HOME_DIR).toBe("/Users/test/.superset"); + expect(result.SUPERSET_AGENT_HOOK_PORT).toBe("51741"); + expect(result.SUPERSET_AGENT_HOOK_VERSION).toBe("2"); + }); + + test("shell-derived env preserves user tooling vars", () => { + const shellEnv: Record = { + HOME: "/Users/dev", + PATH: "/opt/homebrew/bin:/usr/local/bin:/usr/bin", + SHELL: "/bin/zsh", + NVM_DIR: "/Users/dev/.nvm", + PYENV_ROOT: "/Users/dev/.pyenv", + GOPATH: "/Users/dev/go", + SSH_AUTH_SOCK: "/tmp/ssh-agent.sock", + }; + const result = stripTerminalRuntimeEnv(shellEnv); + expect(result.NVM_DIR).toBe("/Users/dev/.nvm"); + expect(result.PYENV_ROOT).toBe("/Users/dev/.pyenv"); + expect(result.GOPATH).toBe("/Users/dev/go"); + expect(result.SSH_AUTH_SOCK).toBe("/tmp/ssh-agent.sock"); + }); +}); + +// ── Shell launch behavior ──────────────────────────────────────────── + +describe("getShellLaunchArgs", () => { + const supersetHomeDir = "/tmp/test-superset"; + + test("zsh launches as login shell", () => { + expect(getShellLaunchArgs({ shell: "/bin/zsh", supersetHomeDir })).toEqual([ + "-l", + ]); + }); + + test("bash falls back to login shell when rcfile missing", () => { + const args = getShellLaunchArgs({ shell: "/bin/bash", supersetHomeDir }); + expect(args).toEqual(["-l"]); + }); + + test("fish uses init-command", () => { + const args = getShellLaunchArgs({ + shell: "/usr/bin/fish", + supersetHomeDir, + }); + expect(args[0]).toBe("-l"); + expect(args[1]).toBe("--init-command"); + expect(args[2]).toContain("_superset_bin"); + expect(args[2]).toContain("superset-shell-ready"); + }); + + test("sh launches as login shell", () => { + expect(getShellLaunchArgs({ shell: "/bin/sh", supersetHomeDir })).toEqual([ + "-l", + ]); + }); + + test("ksh launches as login shell", () => { + expect( + getShellLaunchArgs({ shell: "/usr/bin/ksh", supersetHomeDir }), + ).toEqual(["-l"]); + }); + + test("unsupported shells launch natively without bootstrap", () => { + expect( + getShellLaunchArgs({ shell: "/usr/bin/pwsh", supersetHomeDir }), + ).toEqual([]); + }); +}); + +describe("getShellBootstrapEnv", () => { + test("zsh bootstrap applies only when wrapper files exist", () => { + const result = getShellBootstrapEnv({ + shell: "/bin/zsh", + baseEnv: { HOME: "/Users/test" }, + supersetHomeDir: "/tmp/nonexistent-superset-dir", + }); + expect(result).toEqual({}); + }); + + test("bash returns no bootstrap env keys", () => { + const result = getShellBootstrapEnv({ + shell: "/bin/bash", + baseEnv: {}, + supersetHomeDir: "/tmp/test", + }); + expect(result).toEqual({}); + }); + + test("fish returns no bootstrap env keys", () => { + const result = getShellBootstrapEnv({ + shell: "/usr/bin/fish", + baseEnv: {}, + supersetHomeDir: "/tmp/test", + }); + expect(result).toEqual({}); + }); + + test("unsupported shells return no bootstrap env", () => { + const result = getShellBootstrapEnv({ + shell: "/usr/bin/pwsh", + baseEnv: {}, + supersetHomeDir: "/tmp/test", + }); + expect(result).toEqual({}); + }); +}); + +// ── Terminal base env preservation ─────────────────────────────────── + +describe("terminal base env preservation", () => { + test("getTerminalBaseEnv throws when not initialized", () => { + resetTerminalBaseEnvForTests(); + expect(() => getTerminalBaseEnv()).toThrow("not initialized"); + }); + + test("PTY env is built from preserved snapshot, not live process.env", () => { + resetTerminalBaseEnvForTests(); + + // Simulate host-service startup: process.env = shellSnapshot + runtime keys + const originalProcessEnv = { ...process.env }; + try { + // Set up process.env as if desktop spawned host-service + process.env.HOME = "/Users/test"; + process.env.PATH = "/usr/bin"; + process.env.SHELL = "/bin/zsh"; + process.env.NVM_DIR = "/Users/test/.nvm"; + // Runtime keys that should be stripped + process.env.HOST_SERVICE_SECRET = "secret-123"; + process.env.ORGANIZATION_ID = "org-abc"; + process.env.ELECTRON_RUN_AS_NODE = "1"; + + initTerminalBaseEnv(); + + const baseEnv = getTerminalBaseEnv(); + + // Shell vars preserved + expect(baseEnv.HOME).toBe("/Users/test"); + expect(baseEnv.PATH).toBe("/usr/bin"); + expect(baseEnv.SHELL).toBe("/bin/zsh"); + expect(baseEnv.NVM_DIR).toBe("/Users/test/.nvm"); + + // Runtime keys stripped + expect(baseEnv.HOST_SERVICE_SECRET).toBeUndefined(); + expect(baseEnv.ORGANIZATION_ID).toBeUndefined(); + expect(baseEnv.ELECTRON_RUN_AS_NODE).toBeUndefined(); + + // Modify process.env after init — preserved snapshot unaffected + process.env.INJECTED_LATER = "should-not-appear"; + const freshBaseEnv = getTerminalBaseEnv(); + expect(freshBaseEnv.INJECTED_LATER).toBeUndefined(); + } finally { + // Restore original process.env + for (const key of Object.keys(process.env)) { + if (!(key in originalProcessEnv)) { + delete process.env[key]; + } + } + for (const [key, value] of Object.entries(originalProcessEnv)) { + process.env[key] = value; + } + resetTerminalBaseEnvForTests(); + } + }); + + test("shell resolution failure means no terminal base env", () => { + resetTerminalBaseEnvForTests(); + // Without calling initTerminalBaseEnv(), getTerminalBaseEnv throws + expect(() => getTerminalBaseEnv()).toThrow(); + }); +}); + +// ── buildV2TerminalEnv ─────────────────────────────────────────────── + +describe("buildV2TerminalEnv", () => { + const baseParams = { + baseEnv: { + HOME: "/Users/test", + PATH: "/usr/bin", + SHELL: "/bin/zsh", + SUPERSET_HOME_DIR: "/Users/test/.superset", + }, + shell: "/bin/zsh", + supersetHomeDir: "/Users/test/.superset", + cwd: "/tmp/workspace", + terminalId: "term-1", + workspaceId: "ws-1", + workspacePath: "/tmp/workspace", + rootPath: "/tmp/repo", + hostServiceVersion: "2.0.0", + supersetEnv: "production" as const, + agentHookPort: "51741", + agentHookVersion: "2", + }; + + test("injects the public terminal contract and retained v2 metadata", () => { + const env = buildV2TerminalEnv(baseParams); + expect(env).toMatchObject({ + TERM: "xterm-256color", + TERM_PROGRAM: "Superset", + TERM_PROGRAM_VERSION: "2.0.0", + COLORTERM: "truecolor", + PWD: "/tmp/workspace", + SUPERSET_TERMINAL_ID: "term-1", + SUPERSET_WORKSPACE_ID: "ws-1", + SUPERSET_WORKSPACE_PATH: "/tmp/workspace", + SUPERSET_ROOT_PATH: "/tmp/repo", + SUPERSET_ENV: "production", + SUPERSET_AGENT_HOOK_PORT: "51741", + SUPERSET_AGENT_HOOK_VERSION: "2", + }); + expect(env.TERM_PROGRAM).toBe("Superset"); + expect(env.LANG).toContain("UTF-8"); + }); + + test("allows empty root path and alternate Superset env without breaking the contract", () => { + const env = buildV2TerminalEnv({ ...baseParams, rootPath: "" }); + expect(env.SUPERSET_ROOT_PATH).toBe(""); + + const devEnv = buildV2TerminalEnv({ + ...baseParams, + rootPath: "", + supersetEnv: "development", + }); + expect(devEnv.SUPERSET_ENV).toBe("development"); + expect(devEnv.SUPERSET_ROOT_PATH).toBe(""); + }); + + test("defaults COLORFGBG to dark mode", () => { + const env = buildV2TerminalEnv(baseParams); + expect(env.COLORFGBG).toBe("15;0"); + }); + + test("sets COLORFGBG to light mode when themeType is light", () => { + const env = buildV2TerminalEnv({ + ...baseParams, + themeType: "light", + }); + expect(env.COLORFGBG).toBe("0;15"); + }); + + test("drops removed v1 metadata while preserving user shell vars", () => { + const env = buildV2TerminalEnv({ + ...baseParams, + baseEnv: { + ...baseParams.baseEnv, + SUPERSET_PANE_ID: "pane-1", + SUPERSET_TAB_ID: "tab-1", + SUPERSET_PORT: "51741", + SUPERSET_HOOK_VERSION: "2", + SUPERSET_WORKSPACE_NAME: "my-workspace", + NVM_DIR: "/Users/test/.nvm", + SSH_AUTH_SOCK: "/tmp/ssh.sock", + }, + }); + expect(env.SUPERSET_PANE_ID).toBeUndefined(); + expect(env.SUPERSET_TAB_ID).toBeUndefined(); + expect(env.SUPERSET_PORT).toBeUndefined(); + expect(env.SUPERSET_HOOK_VERSION).toBeUndefined(); + expect(env.SUPERSET_WORKSPACE_NAME).toBeUndefined(); + expect(env.NVM_DIR).toBe("/Users/test/.nvm"); + expect(env.SSH_AUTH_SOCK).toBe("/tmp/ssh.sock"); + }); +}); + +// ── Integration: env never degenerates to process.env ──────────────── + +describe("v2 env contract boundary", () => { + test("runtime secrets in base env are stripped even when present", () => { + // Simulate a base env that somehow has runtime secrets + // (e.g. from shell snapshot contamination) + const env = buildV2TerminalEnv({ + baseEnv: { + HOME: "/Users/test", + PATH: "/usr/bin", + SHELL: "/bin/zsh", + HOST_SERVICE_SECRET: "top-secret", + AUTH_TOKEN: "bearer-xyz", + ORGANIZATION_ID: "org-abc", + NODE_ENV: "production", + VITE_SECRET: "vite-key", + npm_package_name: "superset", + ELECTRON_IS_DEV: "1", + }, + shell: "/bin/zsh", + supersetHomeDir: "/Users/test/.superset", + cwd: "/tmp/ws", + terminalId: "t-1", + workspaceId: "w-1", + workspacePath: "/tmp/ws", + rootPath: "", + hostServiceVersion: "2.0.0", + supersetEnv: "production", + agentHookPort: "51741", + agentHookVersion: "2", + }); + + // None of the runtime secrets should be present + expect(env.HOST_SERVICE_SECRET).toBeUndefined(); + expect(env.AUTH_TOKEN).toBeUndefined(); + expect(env.ORGANIZATION_ID).toBeUndefined(); + expect(env.NODE_ENV).toBeUndefined(); + expect(env.VITE_SECRET).toBeUndefined(); + expect(env.npm_package_name).toBeUndefined(); + expect(env.ELECTRON_IS_DEV).toBeUndefined(); + + // But user shell vars remain + expect(env.HOME).toBe("/Users/test"); + expect(env.PATH).toBe("/usr/bin"); + expect(env.SHELL).toBe("/bin/zsh"); + }); +}); diff --git a/packages/host-service/src/terminal/env.ts b/packages/host-service/src/terminal/env.ts new file mode 100644 index 00000000000..135594ba068 --- /dev/null +++ b/packages/host-service/src/terminal/env.ts @@ -0,0 +1,177 @@ +/** + * V2 terminal environment contract. + * + * PTY env is built from a preserved shell snapshot resolved by the host-service + * at startup — never from desktop main or the live host-service process.env. + */ + +export { stripTerminalRuntimeEnv } from "./env-strip"; +export type { ShellBootstrapParams, ShellLaunchParams } from "./shell-launch"; +export { + getShellBootstrapEnv, + getShellLaunchArgs, + getSupersetShellPaths, + resolveLaunchShell, +} from "./shell-launch"; + +import fs from "node:fs"; +import os from "node:os"; +import { + clearStrictShellEnvCache, + getStrictShellEnvironment, +} from "./clean-shell-env"; +import { stripTerminalRuntimeEnv } from "./env-strip"; +import { getShellBootstrapEnv } from "./shell-launch"; + +const MACOS_SYSTEM_CERT_FILE = "/etc/ssl/cert.pem"; +let cachedMacosSystemCertAvailable: boolean | null = null; + +function hasMacosSystemCertBundle(): boolean { + if (cachedMacosSystemCertAvailable !== null) { + return cachedMacosSystemCertAvailable; + } + cachedMacosSystemCertAvailable = fs.existsSync(MACOS_SYSTEM_CERT_FILE); + return cachedMacosSystemCertAvailable; +} + +// ── Shell snapshot preservation ────────────────────────────────────── + +let _terminalBaseEnv: Record | null = null; + +function snapshotStringEnv( + baseEnv: NodeJS.ProcessEnv | Record = process.env, +): Record { + const snapshot: Record = {}; + for (const [key, value] of Object.entries(baseEnv)) { + if (typeof value === "string") { + snapshot[key] = value; + } + } + return snapshot; +} + +/** + * Resolve the shell-derived terminal base env inside the host-service process. + * Desktop main should not construct or own this snapshot. + */ +export async function resolveTerminalBaseEnv(): Promise< + Record +> { + return getStrictShellEnvironment(); +} + +/** + * Capture the terminal base env at host-service startup. + * + * Accepts an explicit shell snapshot for the real startup path, but retains a + * process.env fallback for tests and local helpers. + */ +export function initTerminalBaseEnv(baseEnv?: Record): void { + _terminalBaseEnv = stripTerminalRuntimeEnv(snapshotStringEnv(baseEnv)); +} + +export function getTerminalBaseEnv(): Record { + if (!_terminalBaseEnv) { + throw new Error( + "Terminal base env not initialized. Call initTerminalBaseEnv() at host-service startup.", + ); + } + return { ..._terminalBaseEnv }; +} + +export function resetTerminalBaseEnvForTests(): void { + _terminalBaseEnv = null; + cachedMacosSystemCertAvailable = null; + clearStrictShellEnvCache(); +} + +// ── Locale ─────────────────────────────────────────────────────────── + +const UTF8_RE = /utf-?8/i; + +/** POSIX precedence: LC_ALL overrides LANG. Matches utf8/UTF-8/UTF8. */ +export function normalizeUtf8Locale(baseEnv: Record): string { + if (baseEnv.LC_ALL && UTF8_RE.test(baseEnv.LC_ALL)) return baseEnv.LC_ALL; + if (baseEnv.LANG && UTF8_RE.test(baseEnv.LANG)) return baseEnv.LANG; + return "en_US.UTF-8"; +} + +// ── V2 terminal env construction ───────────────────────────────────── + +interface BuildV2TerminalEnvParams { + baseEnv: Record; + shell: string; + supersetHomeDir: string; + themeType?: "dark" | "light"; + cwd: string; + terminalId: string; + workspaceId: string; + workspacePath: string; + rootPath: string; + hostServiceVersion: string; + supersetEnv: "development" | "production"; + agentHookPort: string; + agentHookVersion: string; +} + +/** + * Build the final v2 PTY environment. + * baseEnv must be the preserved shell snapshot from getTerminalBaseEnv(). + */ +export function buildV2TerminalEnv( + params: BuildV2TerminalEnvParams, +): Record { + const { + baseEnv, + shell, + supersetHomeDir, + themeType, + cwd, + terminalId, + workspaceId, + workspacePath, + rootPath, + hostServiceVersion, + supersetEnv, + agentHookPort, + agentHookVersion, + } = params; + + // Defense in depth — baseEnv is pre-stripped at init, but strip again + // to guarantee no runtime keys reach PTYs regardless of call site + const env = stripTerminalRuntimeEnv(baseEnv); + + Object.assign(env, getShellBootstrapEnv({ shell, baseEnv, supersetHomeDir })); + + env.TERM = "xterm-256color"; + env.TERM_PROGRAM = "Superset"; + env.TERM_PROGRAM_VERSION = hostServiceVersion; + env.COLORTERM = "truecolor"; + env.COLORFGBG = themeType === "light" ? "0;15" : "15;0"; + env.LANG = normalizeUtf8Locale(baseEnv); + env.PWD = cwd; + + env.SUPERSET_TERMINAL_ID = terminalId; + env.SUPERSET_WORKSPACE_ID = workspaceId; + env.SUPERSET_WORKSPACE_PATH = workspacePath; + env.SUPERSET_ROOT_PATH = rootPath; + env.SUPERSET_ENV = supersetEnv; + env.SUPERSET_AGENT_HOOK_PORT = agentHookPort; + env.SUPERSET_AGENT_HOOK_VERSION = agentHookVersion; + + if (supersetHomeDir) { + env.SUPERSET_HOME_DIR = supersetHomeDir; + } + + // Electron child processes can't access macOS Keychain for TLS cert verification, + // causing "x509: OSStatus -26276" in Go binaries like `gh`. File-based fallback. + if ( + os.platform() === "darwin" && + !env.SSL_CERT_FILE && + hasMacosSystemCertBundle() + ) { + env.SSL_CERT_FILE = MACOS_SYSTEM_CERT_FILE; + } + + return env; +} diff --git a/packages/host-service/src/terminal/shell-launch.ts b/packages/host-service/src/terminal/shell-launch.ts new file mode 100644 index 00000000000..950ee0cd256 --- /dev/null +++ b/packages/host-service/src/terminal/shell-launch.ts @@ -0,0 +1,116 @@ +/** + * Shell launch configuration for v2 terminals. + * + * Behavioral reference: apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts + * + * Upstream patterns: + * - VS Code: ZDOTDIR for zsh, --init-file for bash, --init-command for fish + * - Kitty: KITTY_ORIG_ZDOTDIR for zsh, ENV for bash, XDG_DATA_DIRS for fish + */ +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; + +/** Does not default to /bin/zsh — falls back to /bin/sh (POSIX-guaranteed). */ +export function resolveLaunchShell(baseEnv: Record): string { + if (process.platform === "win32") { + return baseEnv.COMSPEC || "cmd.exe"; + } + return baseEnv.SHELL || "/bin/sh"; +} + +export function getSupersetShellPaths(supersetHomeDir: string): { + BIN_DIR: string; + ZSH_DIR: string; + BASH_DIR: string; +} { + return { + BIN_DIR: path.join(supersetHomeDir, "bin"), + ZSH_DIR: path.join(supersetHomeDir, "zsh"), + BASH_DIR: path.join(supersetHomeDir, "bash"), + }; +} + +function getShellName(shell: string): string { + return path.basename(shell); +} + +/** Matches desktop shell-wrappers.ts fish init: idempotent PATH prepend + shell-ready OSC marker. */ +function buildFishInitCommand(binDir: string): string { + const escaped = binDir + .replaceAll("\\", "\\\\") + .replaceAll('"', '\\"') + .replaceAll("$", "\\$"); + return [ + `set -l _superset_bin "${escaped}"`, + `contains -- "$_superset_bin" $PATH`, + `or set -gx PATH "$_superset_bin" $PATH`, + `function _superset_shell_ready --on-event fish_prompt`, + `printf '\\033]777;superset-shell-ready\\007'`, + `functions -e _superset_shell_ready`, + `end`, + ].join("; "); +} + +export interface ShellBootstrapParams { + shell: string; + baseEnv: Record; + supersetHomeDir: string; +} + +/** + * Private bootstrap env for shell startup redirection. + * Only zsh needs env vars (ZDOTDIR). Bash/fish use args only. + */ +export function getShellBootstrapEnv( + params: ShellBootstrapParams, +): Record { + const { shell, baseEnv, supersetHomeDir } = params; + const shellName = getShellName(shell); + const paths = getSupersetShellPaths(supersetHomeDir); + + if (shellName === "zsh") { + const zshrc = path.join(paths.ZSH_DIR, ".zshrc"); + if (existsSync(zshrc)) { + return { + SUPERSET_ORIG_ZDOTDIR: baseEnv.ZDOTDIR || baseEnv.HOME || homedir(), + ZDOTDIR: paths.ZSH_DIR, + }; + } + } + + return {}; +} + +export interface ShellLaunchParams { + shell: string; + supersetHomeDir: string; +} + +export function getShellLaunchArgs(params: ShellLaunchParams): string[] { + const { shell, supersetHomeDir } = params; + const shellName = getShellName(shell); + const paths = getSupersetShellPaths(supersetHomeDir); + + if (shellName === "zsh") { + return ["-l"]; + } + + if (shellName === "bash") { + const rcfile = path.join(paths.BASH_DIR, "rcfile"); + if (existsSync(rcfile)) { + return ["--rcfile", rcfile]; + } + return ["-l"]; + } + + if (shellName === "fish") { + return ["-l", "--init-command", buildFishInitCommand(paths.BIN_DIR)]; + } + + if (shellName === "sh" || shellName === "ksh") { + return ["-l"]; + } + + return []; +} diff --git a/packages/host-service/src/terminal/terminal.ts b/packages/host-service/src/terminal/terminal.ts index bb52ab16c12..602186260a8 100644 --- a/packages/host-service/src/terminal/terminal.ts +++ b/packages/host-service/src/terminal/terminal.ts @@ -1,11 +1,16 @@ import { existsSync } from "node:fs"; -import { homedir } from "node:os"; import type { NodeWebSocket } from "@hono/node-ws"; import { eq } from "drizzle-orm"; import type { Hono } from "hono"; import { type IPty, spawn } from "node-pty"; import type { HostDb } from "../db"; -import { terminalSessions, workspaces } from "../db/schema"; +import { projects, terminalSessions, workspaces } from "../db/schema"; +import { + buildV2TerminalEnv, + getShellLaunchArgs, + getTerminalBaseEnv, + resolveLaunchShell, +} from "./env"; interface RegisterWorkspaceTerminalRouteOptions { app: Hono; @@ -13,6 +18,12 @@ interface RegisterWorkspaceTerminalRouteOptions { upgradeWebSocket: NodeWebSocket["upgradeWebSocket"]; } +function parseThemeType( + value: string | null | undefined, +): "dark" | "light" | undefined { + return value === "dark" || value === "light" ? value : undefined; +} + type TerminalClientMessage = | { type: "input"; data: string } | { type: "resize"; cols: number; rows: number } @@ -52,13 +63,6 @@ function sendMessage( socket.send(JSON.stringify(message)); } -function resolveShell(): string { - if (process.platform === "win32") { - return process.env.COMSPEC || "cmd.exe"; - } - return process.env.SHELL || "/bin/zsh"; -} - function bufferOutput(session: TerminalSession, data: string) { session.buffer.push(data); session.bufferBytes += data.length; @@ -102,12 +106,14 @@ function disposeSession(terminalId: string, db: HostDb) { interface CreateTerminalSessionOptions { terminalId: string; workspaceId: string; + themeType?: "dark" | "light"; db: HostDb; } function createTerminalSessionInternal({ terminalId, workspaceId, + themeType, db, }: CreateTerminalSessionOptions): TerminalSession | { error: string } { const existing = sessions.get(terminalId); @@ -123,22 +129,47 @@ function createTerminalSessionInternal({ return { error: "Workspace worktree not found" }; } + // Derive root path from the workspace's project + let rootPath = ""; + const project = db.query.projects + .findFirst({ where: eq(projects.id, workspace.projectId) }) + .sync(); + if (project?.repoPath) { + rootPath = project.repoPath; + } + const cwd = workspace.worktreePath; + // Use the preserved shell snapshot — never live process.env + const baseEnv = getTerminalBaseEnv(); + const supersetHomeDir = process.env.SUPERSET_HOME_DIR || ""; + const shell = resolveLaunchShell(baseEnv); + const shellArgs = getShellLaunchArgs({ shell, supersetHomeDir }); + const ptyEnv = buildV2TerminalEnv({ + baseEnv, + shell, + supersetHomeDir, + themeType, + cwd, + terminalId, + workspaceId, + workspacePath: workspace.worktreePath, + rootPath, + hostServiceVersion: process.env.HOST_SERVICE_VERSION || "unknown", + supersetEnv: + process.env.NODE_ENV === "development" ? "development" : "production", + agentHookPort: process.env.SUPERSET_AGENT_HOOK_PORT || "", + agentHookVersion: process.env.SUPERSET_AGENT_HOOK_VERSION || "", + }); + let pty: IPty; try { - pty = spawn(resolveShell(), [], { + pty = spawn(shell, shellArgs, { name: "xterm-256color", cwd, cols: 120, rows: 32, - env: { - ...process.env, - TERM: "xterm-256color", - COLORTERM: "truecolor", - HOME: process.env.HOME || homedir(), - PWD: cwd, - }, + env: ptyEnv, }); } catch (error) { return { @@ -210,6 +241,7 @@ export function registerWorkspaceTerminalRoute({ const body = await c.req.json<{ terminalId: string; workspaceId: string; + themeType?: string; }>(); if (!body.terminalId || !body.workspaceId) { @@ -219,6 +251,7 @@ export function registerWorkspaceTerminalRoute({ const result = createTerminalSessionInternal({ terminalId: body.terminalId, workspaceId: body.workspaceId, + themeType: parseThemeType(body.themeType), db, }); @@ -261,6 +294,7 @@ export function registerWorkspaceTerminalRoute({ upgradeWebSocket((c) => { const terminalId = c.req.param("terminalId") ?? ""; const workspaceId = c.req.query("workspaceId") ?? null; + const themeType = parseThemeType(c.req.query("themeType")); return { onOpen: (_event, ws) => { @@ -304,6 +338,7 @@ export function registerWorkspaceTerminalRoute({ const result = createTerminalSessionInternal({ terminalId, workspaceId, + themeType, db, }); diff --git a/packages/host-service/tsconfig.json b/packages/host-service/tsconfig.json index d6b95b87a3e..1e480221828 100644 --- a/packages/host-service/tsconfig.json +++ b/packages/host-service/tsconfig.json @@ -1,7 +1,8 @@ { "extends": "@superset/typescript/base.json", "compilerOptions": { - "jsx": "react-jsx" + "jsx": "react-jsx", + "types": ["bun-types"] }, "include": ["src"] } diff --git a/plans/v2-terminal-env-handoff.md b/plans/v2-terminal-env-handoff.md new file mode 100644 index 00000000000..500a1679c59 --- /dev/null +++ b/plans/v2-terminal-env-handoff.md @@ -0,0 +1,674 @@ +# V2 Terminal Env Handoff + +Last refined: 2026-04-05 + +## Goal + +Define and implement a v2 terminal env contract that: + +- matches common terminal patterns from GitHub sources +- preserves user-needed shell env for normal shell behavior +- includes explicit shell integration behavior for common shells +- uses only a shell-derived base env for PTYs +- avoids leaking desktop, Electron, and host-service runtime env into PTYs +- keeps the useful parts of the v1 Superset notification contract, but renames + the v2-specific keys to make the contract clearer + +This doc is meant to be handed to another agent to implement directly. + +## Current state + +Current checked-out v2 terminal flow: + +- renderer opens `/terminal/${terminalId}?workspaceId=${workspaceId}` +- host-service spawns a fresh PTY per websocket-backed session +- host-service resolves the shell from inherited process env +- host-service currently spreads raw `process.env` into the PTY + +Relevant code: + +- `apps/desktop/src/main/lib/host-service-manager.ts` +- `apps/desktop/src/lib/trpc/routers/workspaces/utils/shell-env.ts` +- `packages/host-service/src/terminal/terminal.ts` +- `apps/desktop/src/main/lib/terminal/env.ts` for the existing v1 contract + +Current PTY env in `packages/host-service/src/terminal/terminal.ts`: + +```ts +{ + ...process.env, + TERM: "xterm-256color", + COLORTERM: "truecolor", + HOME: process.env.HOME || homedir(), + PWD: workspace.worktreePath, +} +``` + +This is too loose in two places: + +1. host-service itself is spawned from desktop with an env built from desktop + `process.env` +2. PTYs then inherit host-service `process.env` wholesale + +That leaks whatever happens to be in the desktop and host-service runtime env +and does not define a stable contract for terminals. + +## Upstream patterns to follow + +GitHub sources: + +- VS Code terminal env injection: + https://github.com/microsoft/vscode/blob/main/src/vs/platform/terminal/node/terminalEnvironment.ts +- VS Code process env sanitization: + https://github.com/microsoft/vscode/blob/main/src/vs/base/common/processes.ts +- kitty shell integration: + https://github.com/kovidgoyal/kitty/blob/master/docs/shell-integration.rst +- WezTerm `TERM` docs: + https://github.com/wezterm/wezterm/blob/main/docs/config/lua/config/term.md +- WezTerm shell integration: + https://github.com/wezterm/wezterm/blob/main/docs/shell-integration.md +- Windows Terminal FAQ: + https://github.com/microsoft/terminal/wiki/Frequently-Asked-Questions-%28FAQ%29 + +What these tools converge on: + +- keep the public env surface small +- use shell-specific bootstrap vars only when loading shell integration +- sanitize app/runtime env before child processes and terminals instead of + inheriting it wholesale +- do not rely on env vars for dynamic session state +- keep `TERM` conservative unless terminfo is actually shipped +- do not treat env vars as the only reliable terminal identity signal + +Concrete VS Code pattern to follow: + +- VS Code uses a small set of private bootstrap vars for shell integration such + as `VSCODE_INJECTION`, `VSCODE_SHELL_ENV_REPORTING`, `VSCODE_PATH_PREFIX`, + `ZDOTDIR`, and `USER_ZDOTDIR` +- VS Code also sanitizes process env before crossing process boundaries by + stripping Electron and VS Code runtime keys like `ELECTRON_*` and most + `VSCODE_*` + +Superset v2 should follow the same shape: + +- shell-derived env is the base +- Superset adds a small explicit public contract +- Superset strips its own runtime env before PTY launch instead of inheriting it + by default + +## Refined v2 contract + +### 1. Env boundary + +The shell-derived env snapshot is the only valid PTY base env. + +For v2: + +- desktop should spawn host-service with the runtime env it needs +- host-service should resolve a shell-derived env snapshot for terminal use +- host-service should preserve that shell snapshot as a dedicated terminal + base env, separate from its own runtime `process.env` +- PTYs should be built from that dedicated shell snapshot plus explicit v2 + terminal vars + +Desktop `process.env` is not a valid PTY env source. + +Host-service `process.env` is not a valid PTY env source. + +Host-service runtime vars may exist in the host-service process env for the +service itself, but they are not part of the PTY base env and must never be +passed through to user terminals by default. + +### 2. Shell-derived base env + +Use a clean-shell resolver colocated with the host-service terminal code. + +But tighten the semantics: + +- normal path: use the resolved shell snapshot from a clean spawn +- failure path: fail closed for terminal env construction + +Important: + +- the existing `getShellEnvironment()` helper spawns a subshell that inherits + the full Electron `process.env`, which in dev includes all Vite `.env` + secrets — that contaminates the snapshot at the source +- the existing helper also falls back to `process.env` when shell env + resolution fails +- neither the contaminated snapshot nor the fallback are acceptable for v2 + terminal env construction + +For v2, shell snapshot resolution must: + +- spawn the user's login shell with a **minimal parent env** (HOME, USER, + SHELL, PATH, TERM, and a few OS-specific keys) so that Vite `.env` secrets + never enter the subshell +- let the shell's profile scripts populate the env with the user's actual + vars (version managers, proxy config, SSH agent, etc.) +- throw on failure instead of falling back to `process.env` + +This "clean spawn" approach means dev and production behave identically — the +snapshot only contains what the user's shell profile produces, never what +Electron or Vite loaded into the app process. + +For v2, PTY creation must never degenerate into `...process.env` or any other +desktop-runtime fallback. + +### 3. Public terminal env + +Inject this stable terminal surface by default: + +```sh +TERM=xterm-256color +TERM_PROGRAM=Superset +TERM_PROGRAM_VERSION= +COLORTERM=truecolor +LANG= +PWD= +``` + +Notes: + +- keep `TERM=xterm-256color` unless Superset ships and maintains terminfo +- `TERM_PROGRAM_VERSION` should come from the app/host-service version, not + `npm_package_version` +- `PWD` should reflect the resolved launch cwd +- for the current v2 path, launch cwd is the workspace worktree path +- `HOME`, `PATH`, `SHELL`, proxy vars, SSH agent vars, and version-manager vars + should come from the shell-derived base env rather than being redefined as + part of the public contract + +### 4. Superset-specific metadata retained in v2 + +We do want to keep a trimmed, explicit Superset contract for v2 notification +and integration flows. + +Keep these explicit vars in v2: + +```sh +SUPERSET_TERMINAL_ID= +SUPERSET_WORKSPACE_ID= +SUPERSET_WORKSPACE_PATH= +SUPERSET_ROOT_PATH= +SUPERSET_ENV= +SUPERSET_AGENT_HOOK_PORT= +SUPERSET_AGENT_HOOK_VERSION= +``` + +Rename the old v1 vars as follows: + +- `SUPERSET_PANE_ID` -> `SUPERSET_TERMINAL_ID` +- `SUPERSET_PORT` -> `SUPERSET_AGENT_HOOK_PORT` +- `SUPERSET_HOOK_VERSION` -> `SUPERSET_AGENT_HOOK_VERSION` + +Drop this key entirely in v2: + +- `SUPERSET_TAB_ID` + +Do not use a blanket `SUPERSET_*` passthrough rule in v2. + +The v2 Superset metadata surface should stay explicit and minimal. + +### 5. Shell behavior and integration + +V2 should support the user's shell out of the box, similar to VS Code. + +That means: + +- launch the user's configured or default shell +- preserve normal shell startup behavior users expect +- make PATH, version managers, aliases, and shell config work without manual + terminal setup + +Use a hard-coded fallback shell only as a last resort: + +- macOS/Linux: prefer inherited `SHELL`, then `/bin/sh` +- Windows: prefer inherited `COMSPEC`, then `cmd.exe` + +Do not default to `/bin/zsh` just because the current implementation does. + +Shell integration is in scope for v2. + +Follow the VS Code and kitty pattern: + +- use private bootstrap vars per shell only for startup +- examples: `ZDOTDIR`, `BASH_ENV`, `XDG_DATA_DIRS` +- clean them up after shell initialization when possible + +Do not expose those bootstrap vars as part of the public v2 terminal contract. + +Supported shells for the first v2 implementation: + +- `zsh` +- `bash` +- `fish` +- `sh` and `ksh` as reduced-functionality login-shell fallbacks + +Unsupported shells should still launch natively, but without Superset-specific +shell bootstrap beyond the base env contract. + +Per-shell integration design: + +- `zsh` + - use wrapper startup through `ZDOTDIR` + - set `SUPERSET_ORIG_ZDOTDIR` and temporary `ZDOTDIR` + - launch as a login shell +- `bash` + - use the generated Superset rcfile when available + - launch with `--rcfile ` +- `fish` + - use `-l --init-command ...` + - prepend Superset bin dir idempotently after fish config loads + - emit the shell-ready marker using fish-native event hooks +- `sh` and `ksh` + - launch as login shells + - no custom wrapper files in the first pass + +This means v2 should not reuse the v1 desktop terminal env builder as-is, but +it should reuse the proven shell integration behavior and path conventions. + +`apps/desktop/src/main/lib/terminal/env.ts` currently mixes together: + +- safe env filtering +- shell wrapper bootstrap +- theme hints like `COLORFGBG` +- legacy Superset notification metadata + +That builder should remain v1-oriented. + +Instead, v2 should have a separate shell launch config layer that produces: + +- `shell` +- `args` +- private bootstrap env + +from: + +- resolved shell path +- `SUPERSET_HOME_DIR` +- wrapper file availability + +### 7. Dynamic state + +Do not use env vars for: + +- cwd updates after launch +- prompt boundaries +- command start/end markers +- exit status + +If v2 needs those later, use shell integration and OSC sequences instead. + +## Current implementation constraints + +### Host-service launch env + +`apps/desktop/src/main/lib/host-service-manager.ts` should keep responsibility +for launching host-service with the runtime env it needs. + +Host-service itself must resolve and preserve the dedicated shell snapshot used +for PTY construction. PTYs must not be derived from desktop main or the live +host-service `process.env`. + +### PTY context available in host-service + +The host-service terminal session currently has first-class access to: + +- `terminalId` +- `workspaceId` +- workspace `worktreePath` + +Host-service can also derive: + +- repo root path by joining workspace -> project and reading `projects.repoPath` + +Host-service does not currently store a dedicated workspace display name in its +SQLite schema. + +Implication: + +- `SUPERSET_TERMINAL_ID`, `SUPERSET_WORKSPACE_ID`, and + `SUPERSET_WORKSPACE_PATH` are straightforward +- `SUPERSET_ROOT_PATH` is straightforward with a join +- `SUPERSET_WORKSPACE_NAME` should not be part of the first v2 PTY contract + +Do not invent a display name from `branch` or `id`. + +## Files to update + +Primary implementation targets: + +- `apps/desktop/src/main/lib/host-service-manager.ts` +- `apps/desktop/src/lib/trpc/routers/workspaces/utils/shell-env.ts` +- `packages/host-service/src/terminal/terminal.ts` +- new: `packages/host-service/src/terminal/env.ts` +- new: `packages/host-service/src/terminal/env-strip.ts` +- new: `packages/host-service/src/terminal/shell-launch.ts` +- `apps/desktop/src/main/host-service/index.ts` (desktop entry point for host-service) + +Secondary follow-up targets: + +- `apps/desktop/src/main/lib/terminal/env.ts` + only to clarify that it is the legacy v1 builder +- `apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh` +- `apps/desktop/src/main/lib/agent-setup/templates/gemini-hook.template.sh` +- `apps/desktop/src/main/lib/agent-setup/templates/copilot-hook.template.sh` +- `apps/desktop/src/main/lib/agent-setup/templates/cursor-hook.template.sh` +- `apps/desktop/src/lib/trpc/routers/terminal/terminal.ts` +- `apps/desktop/docs/EXTERNAL_FILES.md` + +## Implementation plan + +1. Tighten host-service spawn env in + `apps/desktop/src/main/lib/host-service-manager.ts`. + + Implement a strict helper: + + - `resolveTerminalShellSnapshot(): Promise>` + + Required behavior: + + - call `getStrictShellEnvironment()`, which spawns the user's login shell + with a minimal parent env (clean spawn) so Vite `.env` secrets never + contaminate the snapshot + - if shell resolution fails, throw — do not fall back to `process.env` or + any filtered variant + + This policy is final for v2: + + - shell resolution failure is terminal-blocking + - raw `process.env` passthrough is not allowed + - filtered desktop-runtime fallback is not allowed + +2. Build the final host-service process env explicitly in + `apps/desktop/src/main/lib/host-service-manager.ts`. + + Replace the current `buildHostServiceEnv()` implementation with: + + - `shellSnapshot` from `resolveTerminalShellSnapshot()` + - explicit runtime additions only + + The final host-service env must contain exactly: + + - all keys from `shellSnapshot` + - `ELECTRON_RUN_AS_NODE=1` + - `ORGANIZATION_ID` + - `DEVICE_CLIENT_ID` + - `DEVICE_NAME` + - `HOST_SERVICE_SECRET` + - `HOST_SERVICE_VERSION` + - `HOST_MANIFEST_DIR` + - `KEEP_ALIVE_AFTER_PARENT=1` + - `HOST_DB_PATH` + - `HOST_MIGRATIONS_PATH` + - `DESKTOP_VITE_PORT` + - `SUPERSET_HOME_DIR` + - `SUPERSET_AGENT_HOOK_PORT` + - `SUPERSET_AGENT_HOOK_VERSION` + - `AUTH_TOKEN` only when present + - `CLOUD_API_URL` only when present + + Source of each value: + + - `DESKTOP_VITE_PORT` comes from `shared/env.shared.ts` + - `SUPERSET_AGENT_HOOK_PORT` comes from + `shared/env.shared.ts` as `DESKTOP_NOTIFICATIONS_PORT` + - `SUPERSET_AGENT_HOOK_VERSION` comes from the existing + `HOOK_PROTOCOL_VERSION` constant for this change + - `SUPERSET_HOME_DIR` comes from the already-resolved desktop app env + + Do not start from `...(process.env as Record)`. + + Also persist the original `shellSnapshot` in host-service as the dedicated + PTY base env. PTY construction must use that preserved snapshot, not + host-service `process.env`. + +3. Add `packages/host-service/src/terminal/env.ts` as the single source of + truth for v2 PTY env construction. + + Required exports: + + - `resolveLaunchShell(baseEnv: Record): string` + - `normalizeUtf8Locale(baseEnv: Record): string` + - `getSupersetShellPaths(supersetHomeDir: string): { BIN_DIR: string; ZSH_DIR: string; BASH_DIR: string }` + - `getShellBootstrapEnv(params): Record` + - `getShellLaunchArgs(params): string[]` + - `stripTerminalRuntimeEnv(baseEnv: Record): Record` + - `buildV2TerminalEnv(params): Record` + +4. Make `resolveLaunchShell(...)` deterministic. + + Required behavior: + + - on Windows: `baseEnv.COMSPEC || "cmd.exe"` + - on non-Windows: `baseEnv.SHELL || "/bin/sh"` + + Do not default to `/bin/zsh`. + +5. Make shell integration deterministic in + `packages/host-service/src/terminal/env.ts`. + + Reuse the existing desktop shell behavior exactly: + + - `zsh` + - shell args: `["-l"]` + - private bootstrap env: + - `SUPERSET_ORIG_ZDOTDIR = baseEnv.ZDOTDIR || baseEnv.HOME || homedir()` + - `ZDOTDIR = /zsh` + - only apply this bootstrap when `/zsh/.zshrc` exists + - `bash` + - shell args: `["--rcfile", "/bash/rcfile"]` + - if the rcfile does not exist, fall back to `["-l"]` + - no bootstrap env keys + - `fish` + - shell args: + `["-l", "--init-command", ""]` + - no bootstrap env keys + - `sh` and `ksh` + - shell args: `["-l"]` + - no bootstrap env keys + - all other shells + - shell args: `[]` + - no bootstrap env keys + + Desktop remains responsible for creating: + + - `/bin` + - `/zsh` + - `/bash` + + Host-service is responsible for selecting shell args and bootstrap env. + +6. Make PTY env filtering deterministic in + `stripTerminalRuntimeEnv(...)`. + + Start from the dedicated terminal base env snapshot captured from the user's + shell, not from a snapshot of host-service `process.env`. + + Remove these exact runtime keys: + + - `AUTH_TOKEN` + - `CLOUD_API_URL` + - `DESKTOP_VITE_PORT` + - `DEVICE_CLIENT_ID` + - `DEVICE_NAME` + - `ELECTRON_RUN_AS_NODE` + - `HOST_DB_PATH` + - `HOST_MANIFEST_DIR` + - `HOST_MIGRATIONS_PATH` + - `HOST_SERVICE_SECRET` + - `HOST_SERVICE_VERSION` + - `KEEP_ALIVE_AFTER_PARENT` + - `ORGANIZATION_ID` + + Remove these exact Node and app keys: + + - `NODE_ENV` + - `NODE_OPTIONS` + - `NODE_PATH` + + Remove keys with these prefixes: + + - `npm_` + - `npm_config_` + - `ELECTRON_` + - `VITE_` + - `NEXT_PUBLIC_` + - `TURBO_` + + Treat these categories as internal runtime env, not terminal env: + + - `HOST_*` + - `DESKTOP_*` + - `DEVICE_*` + - non-kept `SUPERSET_*` + + Keep these explicit Superset support keys when present: + + - `SUPERSET_HOME_DIR` + - `SUPERSET_AGENT_HOOK_PORT` + - `SUPERSET_AGENT_HOOK_VERSION` + + Do not preserve any other `SUPERSET_*` keys by prefix rule. + +7. Make PTY env construction deterministic in `buildV2TerminalEnv(...)`. + + `buildV2TerminalEnv(...)` must: + + - start from `stripTerminalRuntimeEnv(baseEnv)` + - merge private shell bootstrap env from `getShellBootstrapEnv(...)` + - inject or override: + - `TERM=xterm-256color` + - `TERM_PROGRAM=Superset` + - `TERM_PROGRAM_VERSION=` + - `COLORTERM=truecolor` + - `LANG=` + - `PWD=` + - `SUPERSET_TERMINAL_ID=` + - `SUPERSET_WORKSPACE_ID=` + - `SUPERSET_WORKSPACE_PATH=` + - `SUPERSET_ROOT_PATH=` + - `SUPERSET_ENV=` + - `SUPERSET_AGENT_HOOK_PORT=` + - `SUPERSET_AGENT_HOOK_VERSION=` + + `SUPERSET_WORKSPACE_NAME` is not part of the v2 PTY env. + +8. Update `packages/host-service/src/terminal/terminal.ts`. + + `createTerminalSessionInternal(...)` must: + + - query the workspace as it does now + - query the related project to derive `rootPath` + - load the preserved shell snapshot for PTY env construction + - resolve `shell` via `resolveLaunchShell(shellSnapshot)` + - resolve `shellArgs` via `getShellLaunchArgs(...)` + - build `ptyEnv` via `buildV2TerminalEnv(...)` + - call `spawn(shell, shellArgs, { name: "xterm-256color", cwd, cols, rows, env: ptyEnv })` + + It must not read host-service `process.env` as the PTY base env. + + It must no longer call `spawn(resolveShell(), [], { env: { ...process.env, ... } })`. + +9. Keep v1 and v2 separate. + + - do not make v2 call `apps/desktop/src/main/lib/terminal/env.ts` + - do not make v2 reuse blanket `SUPERSET_*` passthrough + - do not change v1 desktop terminal behavior in this change + +## Acceptance criteria + +- v2 host-service no longer spawns PTYs from raw `process.env` +- v2 host-service no longer uses host-service `process.env` as the PTY base env +- v2 host-service launch env no longer starts from raw desktop `process.env` +- v2 terminal creation fails closed when a real shell snapshot cannot be + resolved +- user-needed shell env still works for normal tools and version managers +- zsh, bash, and fish launch with Superset shell integration behavior +- v2 PTY env includes `TERM_PROGRAM=Superset` +- v2 PTY env includes `SUPERSET_TERMINAL_ID` +- v2 PTY env includes `SUPERSET_WORKSPACE_ID` +- v2 PTY env includes `SUPERSET_WORKSPACE_PATH` +- v2 PTY env includes `SUPERSET_ROOT_PATH` when it is derivable +- v2 PTY env includes `SUPERSET_AGENT_HOOK_PORT` +- v2 PTY env includes `SUPERSET_AGENT_HOOK_VERSION` +- v2 PTY env does not include `SUPERSET_PANE_ID` +- v2 PTY env does not include `SUPERSET_TAB_ID` +- v2 PTY env does not include `SUPERSET_PORT` +- v2 PTY env does not include `SUPERSET_HOOK_VERSION` +- v2 PTY env does not require `SUPERSET_WORKSPACE_NAME` +- the v2 contract is defined in one place and documented + +## Tests + +Add or update tests around behavior regressions and boundary protection, not +around every field assignment. + +Required test coverage: + +- shell snapshot path + - when a shell-derived env contains user PATH/tooling vars that are missing + from app env, PTY env preserves them + - PTY env is built from the preserved shell snapshot, not live host-service + `process.env` + - when shell resolution fails, terminal creation fails explicitly instead of + falling back to desktop or host-service runtime env + +- leakage prevention + - app/runtime secrets do not reach PTY env + - host-service control vars do not reach PTY env + - dev-runner and Electron runtime vars do not reach PTY env: + `npm_*`, `npm_config_*`, `ELECTRON_*` + - removed legacy vars do not reach PTY env: + `SUPERSET_PANE_ID`, `SUPERSET_TAB_ID`, `SUPERSET_PORT`, + `SUPERSET_HOOK_VERSION` + +- retained contract behavior + - the minimal v2 Superset metadata needed by real consumers is present: + `SUPERSET_TERMINAL_ID`, `SUPERSET_WORKSPACE_ID`, + `SUPERSET_WORKSPACE_PATH`, `SUPERSET_AGENT_HOOK_PORT`, + `SUPERSET_AGENT_HOOK_VERSION` + - `TERM_PROGRAM=Superset` and a UTF-8 locale are present + +- shell launch behavior + - zsh launch config applies wrapper bootstrap only when wrapper files exist + and otherwise degrades safely + - bash launch config uses rcfile when present and login-shell fallback when + absent + - fish launch config uses the expected init-command path and does not crash + - unsupported shells launch natively without Superset-specific bootstrap + +- workspace-derived metadata + - `SUPERSET_ROOT_PATH` is populated when project data is available + - missing project/root metadata degrades to empty string rather than failure + +- one integration-level PTY spawn test + - host-service terminal session creation uses the preserved shell snapshot + plus built env rather than `spawn(..., [], { env: { ...process.env } })` + +Avoid low-value tests that only restate helper internals line-by-line or assert +every single key in isolation without covering a real regression risk. + +Recommended test location: + +- `packages/host-service/src/terminal/env.test.ts` +- targeted integration coverage near `packages/host-service/src/terminal/terminal.ts` + +## Non-goals + +- recreating the full v1 desktop hook contract unchanged +- using env vars for dynamic runtime session state + +## Notes for implementation + +- `apps/desktop/src/main/lib/terminal/env.ts` is not the right shared source + for v2 because it is coupled to v1 desktop shell wrappers and legacy + notification env names +- the pure shell launch logic in `apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts` + is the right behavioral reference for zsh, bash, and fish support +- `packages/host-service/src/terminal/terminal.ts` currently only has + `workspaceId` on websocket attach, so launch cwd remains the workspace + worktree path for this change +- `SUPERSET_WORKSPACE_NAME` is intentionally omitted from the first v2 PTY + contract because there is no clean host-service source for it and no concrete + v2 runtime consumer requiring it From fc986deb6f4954c0417ebae6b070525b9583f5d9 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sun, 5 Apr 2026 18:55:06 -0700 Subject: [PATCH 394/816] feat(desktop): pane context menus + binary tree layout (#3196) * feat(desktop): add pane context menus + rewrite layout to binary tree - Add data-driven context menu system to @superset/panes with ContextMenuActionConfig type and recursive rendering - Wire up context menus for all v2 pane types (terminal, chat, browser, file) with pane-specific actions (copy/paste/clear/scroll for terminal) - Add terminal hotkeys (CLEAR_TERMINAL, SCROLL_TO_BOTTOM) and scroll-to-bottom button to v2 terminal pane - Add movePaneToTab/movePaneToNewTab store actions for Move to Tab submenu - Rewrite layout model from N-ary splits (children[]/weights[]) to strict binary tree (first/second/splitPercentage) matching react-mosaic's model - Replace split node IDs with path-based addressing (SplitPath) - Equalize now uses leaf-count-weighted splitPercentage recursively - Pane removal uses sibling promotion (only neighbor affected) * fix: resolve lint warnings (non-null assertions, unused suppression) --- .../lib/terminal/terminal-runtime-registry.ts | 24 + .../useDefaultContextMenuActions/index.ts | 1 + .../useDefaultContextMenuActions.tsx | 160 +++++++ .../components/TerminalPane/TerminalPane.tsx | 30 +- .../hooks/usePaneRegistry/usePaneRegistry.tsx | 103 ++++- .../useWorkspaceHotkeys.ts | 4 +- .../v2-workspace/$workspaceId/page.tsx | 3 + packages/panes/src/core/store/store.test.ts | 434 +++++------------- packages/panes/src/core/store/store.ts | 248 +++++++--- packages/panes/src/core/store/utils/index.ts | 6 +- .../panes/src/core/store/utils/utils.test.ts | 260 ++++++----- packages/panes/src/core/store/utils/utils.ts | 149 +++--- packages/panes/src/index.ts | 3 + .../react/components/Workspace/Workspace.tsx | 2 + .../Workspace/components/Tab/Tab.tsx | 91 ++-- .../components/Tab/components/Pane/Pane.tsx | 100 ++-- .../PaneContextMenu/PaneContextMenu.tsx | 98 ++++ .../Pane/components/PaneContextMenu/index.ts | 1 + packages/panes/src/react/index.ts | 1 + packages/panes/src/react/types.ts | 24 + packages/panes/src/types.ts | 10 +- 21 files changed, 1090 insertions(+), 662 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDefaultContextMenuActions/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDefaultContextMenuActions/useDefaultContextMenuActions.tsx create mode 100644 packages/panes/src/react/components/Workspace/components/Tab/components/Pane/components/PaneContextMenu/PaneContextMenu.tsx create mode 100644 packages/panes/src/react/components/Workspace/components/Tab/components/Pane/components/PaneContextMenu/index.ts diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts index 2590f000f29..bdbca7d809e 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts @@ -98,6 +98,30 @@ class TerminalRuntimeRegistryImpl { this.entries.delete(terminalId); } + getSelection(terminalId: string): string { + const entry = this.entries.get(terminalId); + return entry?.runtime?.terminal.getSelection() ?? ""; + } + + clear(terminalId: string): void { + const entry = this.entries.get(terminalId); + entry?.runtime?.terminal.clear(); + } + + scrollToBottom(terminalId: string): void { + const entry = this.entries.get(terminalId); + entry?.runtime?.terminal.scrollToBottom(); + } + + paste(terminalId: string, text: string): void { + const entry = this.entries.get(terminalId); + entry?.runtime?.terminal.paste(text); + } + + getTerminal(terminalId: string) { + return this.entries.get(terminalId)?.runtime?.terminal ?? null; + } + getAllTerminalIds(): Set { return new Set(this.entries.keys()); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDefaultContextMenuActions/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDefaultContextMenuActions/index.ts new file mode 100644 index 00000000000..895864788f3 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDefaultContextMenuActions/index.ts @@ -0,0 +1 @@ +export { useDefaultContextMenuActions } from "./useDefaultContextMenuActions"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDefaultContextMenuActions/useDefaultContextMenuActions.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDefaultContextMenuActions/useDefaultContextMenuActions.tsx new file mode 100644 index 00000000000..01b09b6edca --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDefaultContextMenuActions/useDefaultContextMenuActions.tsx @@ -0,0 +1,160 @@ +import type { ContextMenuActionConfig, RendererContext } from "@superset/panes"; +import { useMemo } from "react"; +import { + LuColumns2, + LuEqual, + LuGlobe, + LuMessageSquare, + LuMoveRight, + LuPlus, + LuRows2, + LuX, +} from "react-icons/lu"; +import { useHotkeyDisplay } from "renderer/hotkeys"; +import type { + BrowserPaneData, + ChatPaneData, + PaneViewerData, + TerminalPaneData, +} from "../../types"; + +export function useDefaultContextMenuActions(): ContextMenuActionConfig[] { + const splitDownShortcut = useHotkeyDisplay("SPLIT_DOWN").text; + const splitRightShortcut = useHotkeyDisplay("SPLIT_RIGHT").text; + const splitWithChatShortcut = useHotkeyDisplay("SPLIT_WITH_CHAT").text; + const splitWithBrowserShortcut = useHotkeyDisplay("SPLIT_WITH_BROWSER").text; + const equalizePaneSplitsShortcut = useHotkeyDisplay( + "EQUALIZE_PANE_SPLITS", + ).text; + const closePaneShortcut = useHotkeyDisplay("CLOSE_TERMINAL").text; + + return useMemo[]>( + () => [ + { + key: "split-horizontal", + label: "Split Horizontally", + icon: , + shortcut: + splitDownShortcut !== "Unassigned" ? splitDownShortcut : undefined, + onSelect: (ctx) => { + ctx.actions.split("down", { + kind: "terminal", + data: { + terminalId: crypto.randomUUID(), + } as TerminalPaneData, + }); + }, + }, + { + key: "split-vertical", + label: "Split Vertically", + icon: , + shortcut: + splitRightShortcut !== "Unassigned" ? splitRightShortcut : undefined, + onSelect: (ctx) => { + ctx.actions.split("right", { + kind: "terminal", + data: { + terminalId: crypto.randomUUID(), + } as TerminalPaneData, + }); + }, + }, + { + key: "split-with-chat", + label: "Split with New Chat", + icon: , + shortcut: + splitWithChatShortcut !== "Unassigned" + ? splitWithChatShortcut + : undefined, + onSelect: (ctx) => { + ctx.actions.split("right", { + kind: "chat", + data: { sessionId: null } as ChatPaneData, + }); + }, + }, + { + key: "split-with-browser", + label: "Split with New Browser", + icon: , + shortcut: + splitWithBrowserShortcut !== "Unassigned" + ? splitWithBrowserShortcut + : undefined, + onSelect: (ctx) => { + ctx.actions.split("right", { + kind: "browser", + data: { + url: "http://localhost:3000", + mode: "preview", + } as BrowserPaneData, + }); + }, + }, + { + key: "equalize-splits", + label: "Equalize Pane Splits", + icon: , + shortcut: + equalizePaneSplitsShortcut !== "Unassigned" + ? equalizePaneSplitsShortcut + : undefined, + onSelect: (ctx) => { + ctx.store.getState().equalizeTab({ tabId: ctx.tab.id }); + }, + }, + { key: "sep-move", type: "separator" }, + { + key: "move-to-tab", + label: "Move to Tab", + icon: , + children: (ctx: RendererContext) => { + const tabs = ctx.store.getState().tabs; + const otherTabs = tabs.filter((t) => t.id !== ctx.tab.id); + const items: ContextMenuActionConfig[] = + otherTabs.map((tab) => ({ + key: `move-to-${tab.id}`, + label: tab.titleOverride ?? tab.id, + onSelect: () => { + ctx.store + .getState() + .movePaneToTab({ paneId: ctx.pane.id, targetTabId: tab.id }); + }, + })); + if (otherTabs.length > 0) { + items.push({ key: "sep-new-tab", type: "separator" }); + } + items.push({ + key: "move-to-new-tab", + label: "New Tab", + icon: , + onSelect: () => { + ctx.store.getState().movePaneToNewTab({ paneId: ctx.pane.id }); + }, + }); + return items; + }, + }, + { key: "sep-close", type: "separator" }, + { + key: "close-pane", + label: "Close Pane", + icon: , + variant: "destructive", + shortcut: + closePaneShortcut !== "Unassigned" ? closePaneShortcut : undefined, + onSelect: (ctx) => ctx.actions.close(), + }, + ], + [ + splitDownShortcut, + splitRightShortcut, + splitWithChatShortcut, + splitWithBrowserShortcut, + equalizePaneSplitsShortcut, + closePaneShortcut, + ], + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx index 866b9ca3e93..2917c95998d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx @@ -1,6 +1,7 @@ import type { RendererContext } from "@superset/panes"; import "@xterm/xterm/css/xterm.css"; import { useEffect, useMemo, useRef, useSyncExternalStore } from "react"; +import { useHotkey } from "renderer/hotkeys"; import { type ConnectionState, terminalRuntimeRegistry, @@ -10,6 +11,7 @@ import type { TerminalPaneData, } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; import { useWorkspaceWsUrl } from "renderer/routes/_authenticated/_dashboard/v2-workspace/providers/WorkspaceTrpcProvider/WorkspaceTrpcProvider"; +import { ScrollToBottomButton } from "renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/ScrollToBottomButton"; import { useTheme } from "renderer/stores/theme"; import { resolveTerminalThemeType } from "renderer/stores/theme/utils"; import { useTerminalAppearance } from "./hooks/useTerminalAppearance"; @@ -83,13 +85,31 @@ export function TerminalPane({ ctx, workspaceId }: TerminalPaneProps) { terminalRuntimeRegistry.updateAppearance(terminalId, appearance); }, [terminalId, appearance]); + useHotkey("CLEAR_TERMINAL", () => { + terminalRuntimeRegistry.clear(terminalId); + }); + + useHotkey("SCROLL_TO_BOTTOM", () => { + terminalRuntimeRegistry.scrollToBottom(terminalId); + }); + + // connectionState in deps ensures terminal ref re-derives after connect/disconnect + // biome-ignore lint/correctness/useExhaustiveDependencies: connectionState is intentionally included to trigger re-derive + const terminal = useMemo( + () => terminalRuntimeRegistry.getTerminal(terminalId), + [terminalId, connectionState], + ); + return (
-
+
+
+ +
{connectionState === "closed" && (
Disconnected diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx index b5e32b2afe5..cc43f3cb4fb 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx @@ -1,7 +1,19 @@ -import type { PaneRegistry, RendererContext } from "@superset/panes"; +import type { + ContextMenuActionConfig, + PaneRegistry, + RendererContext, +} from "@superset/panes"; import { alert } from "@superset/ui/atoms/Alert"; import { Circle, Globe, MessageSquare, TerminalSquare } from "lucide-react"; import { useMemo } from "react"; +import { + LuArrowDownToLine, + LuClipboard, + LuClipboardCopy, + LuEraser, +} from "react-icons/lu"; +import { useHotkeyDisplay } from "renderer/hotkeys"; +import { terminalRuntimeRegistry } from "renderer/lib/terminal/terminal-runtime-registry"; import { FileIcon } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils"; import type { BrowserPaneData, @@ -9,6 +21,7 @@ import type { DevtoolsPaneData, FilePaneData, PaneViewerData, + TerminalPaneData, } from "../../types"; import { ChatPane } from "./components/ChatPane"; import { FilePane } from "./components/FilePane"; @@ -18,9 +31,16 @@ function getFileName(filePath: string): string { return filePath.split("/").pop() ?? filePath; } +const MOD_KEY = navigator.platform.toLowerCase().includes("mac") + ? "⌘" + : "Ctrl+"; + export function usePaneRegistry( workspaceId: string, ): PaneRegistry { + const clearShortcut = useHotkeyDisplay("CLEAR_TERMINAL").text; + const scrollToBottomShortcut = useHotkeyDisplay("SCROLL_TO_BOTTOM").text; + return useMemo>( () => ({ file: { @@ -72,6 +92,10 @@ export function usePaneRegistry( }); }); }, + contextMenuActions: (_ctx, defaults) => + defaults.map((d) => + d.key === "close-pane" ? { ...d, label: "Close File" } : d, + ), }, terminal: { getIcon: () => , @@ -79,6 +103,73 @@ export function usePaneRegistry( renderPane: (ctx: RendererContext) => ( ), + contextMenuActions: (_ctx, defaults) => { + const terminalActions: ContextMenuActionConfig[] = [ + { + key: "copy", + label: "Copy", + icon: , + shortcut: `${MOD_KEY}C`, + disabled: (ctx) => { + const { terminalId } = ctx.pane.data as TerminalPaneData; + return !terminalRuntimeRegistry.getSelection(terminalId); + }, + onSelect: (ctx) => { + const { terminalId } = ctx.pane.data as TerminalPaneData; + const text = terminalRuntimeRegistry.getSelection(terminalId); + if (text) navigator.clipboard.writeText(text); + }, + }, + { + key: "paste", + label: "Paste", + icon: , + shortcut: `${MOD_KEY}V`, + onSelect: async (ctx) => { + const { terminalId } = ctx.pane.data as TerminalPaneData; + try { + const text = await navigator.clipboard.readText(); + if (text) terminalRuntimeRegistry.paste(terminalId, text); + } catch { + // Clipboard access denied + } + }, + }, + { key: "sep-terminal-clipboard", type: "separator" }, + { + key: "clear-terminal", + label: "Clear Terminal", + icon: , + shortcut: + clearShortcut !== "Unassigned" ? clearShortcut : undefined, + onSelect: (ctx) => { + const { terminalId } = ctx.pane.data as TerminalPaneData; + terminalRuntimeRegistry.clear(terminalId); + }, + }, + { + key: "scroll-to-bottom", + label: "Scroll to Bottom", + icon: , + shortcut: + scrollToBottomShortcut !== "Unassigned" + ? scrollToBottomShortcut + : undefined, + onSelect: (ctx) => { + const { terminalId } = ctx.pane.data as TerminalPaneData; + terminalRuntimeRegistry.scrollToBottom(terminalId); + }, + }, + { key: "sep-terminal-defaults", type: "separator" }, + ]; + + // Update close label + const modifiedDefaults = defaults.map((d) => + d.key === "close-pane" ? { ...d, label: "Close Terminal" } : d, + ); + + return [...terminalActions, ...modifiedDefaults]; + }, }, browser: { getIcon: () => , @@ -97,6 +188,10 @@ export function usePaneRegistry( /> ); }, + contextMenuActions: (_ctx, defaults) => + defaults.map((d) => + d.key === "close-pane" ? { ...d, label: "Close Browser" } : d, + ), }, chat: { getIcon: () => , @@ -115,6 +210,10 @@ export function usePaneRegistry( /> ); }, + contextMenuActions: (_ctx, defaults) => + defaults.map((d) => + d.key === "close-pane" ? { ...d, label: "Close Chat" } : d, + ), }, devtools: { getTitle: () => "DevTools", @@ -128,6 +227,6 @@ export function usePaneRegistry( }, }, }), - [workspaceId], + [workspaceId, clearShortcut, scrollToBottomShortcut], ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts index daec3accb52..cbf9ed57e6c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts @@ -236,8 +236,6 @@ export function useWorkspaceHotkeys({ const state = store.getState(); const tab = state.getActiveTab(); if (!tab) return; - if (tab.layout.type === "split") { - state.equalizeSplit({ tabId: tab.id, splitId: tab.layout.id }); - } + state.equalizeTab({ tabId: tab.id }); }); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index e7beebc7286..f56128fd3cf 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -25,6 +25,7 @@ import { AddTabMenu } from "./components/AddTabMenu"; import { WorkspaceEmptyState } from "./components/WorkspaceEmptyState"; import { WorkspaceNotFoundState } from "./components/WorkspaceNotFoundState"; import { WorkspaceSidebar } from "./components/WorkspaceSidebar"; +import { useDefaultContextMenuActions } from "./hooks/useDefaultContextMenuActions"; import { usePaneRegistry } from "./hooks/usePaneRegistry"; import { useV2WorkspacePaneLayout } from "./hooks/useV2WorkspacePaneLayout"; import { useWorkspaceHotkeys } from "./hooks/useWorkspaceHotkeys"; @@ -87,6 +88,7 @@ function WorkspaceContent({ workspaceId, }); const paneRegistry = usePaneRegistry(workspaceId); + const defaultContextMenuActions = useDefaultContextMenuActions(); const utils = electronTrpc.useUtils(); const { data: showPresetsBar, isLoading: isLoadingPresetsBar } = @@ -272,6 +274,7 @@ function WorkspaceContent({ registry={paneRegistry} paneActions={defaultPaneActions} + contextMenuActions={defaultContextMenuActions} renderAddTabMenu={() => ( { const store = makeStore(); store.getState().addTab({ id: "t1", panes: [tp("p1")] }); - store.getState().setPanePinned({ - paneId: "p1", - pinned: true, - }); + store.getState().setPanePinned({ paneId: "p1", pinned: true }); expect(store.getState().getPane("p1")?.pane.pinned).toBe(true); }); @@ -160,7 +157,6 @@ describe("pane operations", () => { }); const tab = store.getState().tabs[0]; - expect(tab).toBeDefined(); expect(tab?.panes.p1).toBeUndefined(); expect(tab?.panes.p2?.data.label).toBe("new"); expect(tab?.activePaneId).toBe("p2"); @@ -194,7 +190,7 @@ describe("pane operations", () => { }); describe("split operations", () => { - it("splits a single pane into a split with weights [1, 1]", () => { + it("splits a single pane into a binary split", () => { const store = makeStore(); store.getState().addTab({ id: "t1", panes: [tp("p1")] }); @@ -209,10 +205,9 @@ describe("split operations", () => { expect(layout?.type).toBe("split"); if (layout?.type === "split") { expect(layout.direction).toBe("horizontal"); - expect(layout.weights).toEqual([1, 1]); - expect(layout.children).toHaveLength(2); - expect(layout.children[0]).toEqual({ type: "pane", paneId: "p1" }); - expect(layout.children[1]).toEqual({ type: "pane", paneId: "p2" }); + expect(layout.splitPercentage).toBeUndefined(); + expect(layout.first).toEqual({ type: "pane", paneId: "p1" }); + expect(layout.second).toEqual({ type: "pane", paneId: "p2" }); } }); @@ -230,8 +225,8 @@ describe("split operations", () => { const layout = store.getState().tabs[0]?.layout; if (layout?.type === "split") { expect(layout.direction).toBe("horizontal"); - expect(layout.children[0]).toEqual({ type: "pane", paneId: "p2" }); - expect(layout.children[1]).toEqual({ type: "pane", paneId: "p1" }); + expect(layout.first).toEqual({ type: "pane", paneId: "p2" }); + expect(layout.second).toEqual({ type: "pane", paneId: "p1" }); } }); @@ -249,70 +244,36 @@ describe("split operations", () => { const layout = store.getState().tabs[0]?.layout; if (layout?.type === "split") { expect(layout.direction).toBe("vertical"); - expect(layout.children[0]).toEqual({ type: "pane", paneId: "p1" }); - expect(layout.children[1]).toEqual({ type: "pane", paneId: "p2" }); - } - }); - - it("split within same-direction split halves target weight and inserts adjacent", () => { - const store = makeStore({ - version: 1, - tabs: [ - { - id: "t1", - createdAt: Date.now(), - activePaneId: "p1", - layout: { - type: "split", - id: "s1", - direction: "horizontal", - children: [ - { type: "pane", paneId: "p1" }, - { type: "pane", paneId: "p2" }, - { type: "pane", paneId: "p3" }, - ], - weights: [3, 2, 1], - }, - panes: { - p1: { id: "p1", kind: "test", data: { label: "p1" } }, - p2: { id: "p2", kind: "test", data: { label: "p2" } }, - p3: { id: "p3", kind: "test", data: { label: "p3" } }, - }, - }, - ], - activeTabId: "t1", - }); - - store.getState().splitPane({ - tabId: "t1", - paneId: "p2", - position: "right", - newPane: tp("p4"), - }); - - const layout = store.getState().tabs[0]?.layout; - if (layout?.type === "split") { - expect(layout.weights).toEqual([3, 1, 1, 1]); - expect(layout.children).toHaveLength(4); - expect(layout.children[2]).toEqual({ type: "pane", paneId: "p4" }); + expect(layout.first).toEqual({ type: "pane", paneId: "p1" }); + expect(layout.second).toEqual({ type: "pane", paneId: "p2" }); } }); - it("split with custom weights", () => { + it("split creates nested binary split (no flattening)", () => { const store = makeStore(); store.getState().addTab({ id: "t1", panes: [tp("p1")] }); - store.getState().splitPane({ tabId: "t1", paneId: "p1", position: "right", newPane: tp("p2"), - weights: [3, 1], + }); + store.getState().splitPane({ + tabId: "t1", + paneId: "p2", + position: "right", + newPane: tp("p3"), }); const layout = store.getState().tabs[0]?.layout; if (layout?.type === "split") { - expect(layout.weights).toEqual([3, 1]); + expect(layout.first).toEqual({ type: "pane", paneId: "p1" }); + // p2 is now in a nested split with p3 + expect(layout.second.type).toBe("split"); + if (layout.second.type === "split") { + expect(layout.second.first).toEqual({ type: "pane", paneId: "p2" }); + expect(layout.second.second).toEqual({ type: "pane", paneId: "p3" }); + } } }); @@ -335,155 +296,107 @@ describe("split operations", () => { expect(store.getState().tabs[0]?.activePaneId).toBe("p1"); }); - it("resizes a split", () => { - const store = makeStore({ - version: 1, - tabs: [ - { - id: "t1", - createdAt: Date.now(), - activePaneId: "p1", - layout: { - type: "split", - id: "s1", - direction: "horizontal", - children: [ - { type: "pane", paneId: "p1" }, - { type: "pane", paneId: "p2" }, - ], - weights: [1, 1], - }, - panes: { - p1: { id: "p1", kind: "test", data: { label: "p1" } }, - p2: { id: "p2", kind: "test", data: { label: "p2" } }, - }, - }, - ], - activeTabId: "t1", + it("resizes a split via path", () => { + const store = makeStore(); + store.getState().addTab({ id: "t1", panes: [tp("p1")] }); + store.getState().splitPane({ + tabId: "t1", + paneId: "p1", + position: "right", + newPane: tp("p2"), }); store.getState().resizeSplit({ tabId: "t1", - splitId: "s1", - weights: [3, 7], + path: [], + splitPercentage: 30, }); const layout = store.getState().tabs[0]?.layout; if (layout?.type === "split") { - expect(layout.weights).toEqual([3, 7]); + expect(layout.splitPercentage).toBe(30); } }); - it("equalizes a split — all weights become 1", () => { - const store = makeStore({ - version: 1, - tabs: [ - { - id: "t1", - createdAt: Date.now(), - activePaneId: "p1", - layout: { - type: "split", - id: "s1", - direction: "horizontal", - children: [ - { type: "pane", paneId: "p1" }, - { type: "pane", paneId: "p2" }, - { type: "pane", paneId: "p3" }, - ], - weights: [10, 30, 60], - }, - panes: { - p1: { id: "p1", kind: "test", data: { label: "p1" } }, - p2: { id: "p2", kind: "test", data: { label: "p2" } }, - p3: { id: "p3", kind: "test", data: { label: "p3" } }, - }, - }, - ], - activeTabId: "t1", - }); - - store.getState().equalizeSplit({ tabId: "t1", splitId: "s1" }); + it("equalizes all splits in a tab by leaf count", () => { + const store = makeStore(); + store.getState().addTab({ id: "t1", panes: [tp("p1")] }); + store.getState().splitPane({ + tabId: "t1", + paneId: "p1", + position: "right", + newPane: tp("p2"), + }); + store.getState().splitPane({ + tabId: "t1", + paneId: "p2", + position: "bottom", + newPane: tp("p3"), + }); + + // Resize root to skewed + store.getState().resizeSplit({ + tabId: "t1", + path: [], + splitPercentage: 80, + }); + + store.getState().equalizeTab({ tabId: "t1" }); const layout = store.getState().tabs[0]?.layout; + // Root: [p1, [p2, p3]] → 1 leaf vs 2 leaves → 33.33% if (layout?.type === "split") { - expect(layout.weights).toEqual([1, 1, 1]); + expect(layout.splitPercentage).toBeCloseTo(33.33, 1); + if (layout.second.type === "split") { + expect(layout.second.splitPercentage).toBe(50); + } } }); }); describe("collapsing", () => { - it("close pane in 2-pane split collapses to remaining leaf", () => { - const store = makeStore({ - version: 1, - tabs: [ - { - id: "t1", - createdAt: Date.now(), - activePaneId: "p1", - layout: { - type: "split", - id: "s1", - direction: "horizontal", - children: [ - { type: "pane", paneId: "p1" }, - { type: "pane", paneId: "p2" }, - ], - weights: [1, 1], - }, - panes: { - p1: { id: "p1", kind: "test", data: { label: "p1" } }, - p2: { id: "p2", kind: "test", data: { label: "p2" } }, - }, - }, - ], - activeTabId: "t1", + it("close pane in 2-pane split — sibling promotion", () => { + const store = makeStore(); + store.getState().addTab({ id: "t1", panes: [tp("p1")] }); + store.getState().splitPane({ + tabId: "t1", + paneId: "p1", + position: "right", + newPane: tp("p2"), }); store.getState().closePane({ tabId: "t1", paneId: "p1" }); const tab = store.getState().tabs[0]; - expect(tab).toBeDefined(); expect(tab?.layout).toEqual({ type: "pane", paneId: "p2" }); expect(tab?.activePaneId).toBe("p2"); expect(tab?.panes.p1).toBeUndefined(); }); - it("close pane in 3-pane split removes child + weight", () => { - const store = makeStore({ - version: 1, - tabs: [ - { - id: "t1", - createdAt: Date.now(), - activePaneId: "p2", - layout: { - type: "split", - id: "s1", - direction: "horizontal", - children: [ - { type: "pane", paneId: "p1" }, - { type: "pane", paneId: "p2" }, - { type: "pane", paneId: "p3" }, - ], - weights: [3, 2, 1], - }, - panes: { - p1: { id: "p1", kind: "test", data: { label: "p1" } }, - p2: { id: "p2", kind: "test", data: { label: "p2" } }, - p3: { id: "p3", kind: "test", data: { label: "p3" } }, - }, - }, - ], - activeTabId: "t1", - }); - - store.getState().closePane({ tabId: "t1", paneId: "p2" }); + it("close pane in nested split — only sibling affected", () => { + const store = makeStore(); + store.getState().addTab({ id: "t1", panes: [tp("p1")] }); + store.getState().splitPane({ + tabId: "t1", + paneId: "p1", + position: "right", + newPane: tp("p2"), + }); + store.getState().splitPane({ + tabId: "t1", + paneId: "p2", + position: "bottom", + newPane: tp("p3"), + }); + + // Layout: [p1, [p2, p3]] + // Close p3 → [p1, p2] (sibling promotion of p2) + store.getState().closePane({ tabId: "t1", paneId: "p3" }); const layout = store.getState().tabs[0]?.layout; if (layout?.type === "split") { - expect(layout.children).toHaveLength(2); - expect(layout.weights).toEqual([3, 1]); + expect(layout.first).toEqual({ type: "pane", paneId: "p1" }); + expect(layout.second).toEqual({ type: "pane", paneId: "p2" }); } }); @@ -498,31 +411,16 @@ describe("collapsing", () => { }); it("activePaneId falls back to sibling after close", () => { - const store = makeStore({ - version: 1, - tabs: [ - { - id: "t1", - createdAt: Date.now(), - activePaneId: "p1", - layout: { - type: "split", - id: "s1", - direction: "horizontal", - children: [ - { type: "pane", paneId: "p1" }, - { type: "pane", paneId: "p2" }, - ], - weights: [1, 1], - }, - panes: { - p1: { id: "p1", kind: "test", data: { label: "p1" } }, - p2: { id: "p2", kind: "test", data: { label: "p2" } }, - }, - }, - ], - activeTabId: "t1", + const store = makeStore(); + store.getState().addTab({ id: "t1", panes: [tp("p1")] }); + store.getState().splitPane({ + tabId: "t1", + paneId: "p1", + position: "right", + newPane: tp("p2"), }); + // Make p1 active + store.getState().setActivePane({ tabId: "t1", paneId: "p1" }); store.getState().closePane({ tabId: "t1", paneId: "p1" }); expect(store.getState().tabs[0]?.activePaneId).toBe("p2"); @@ -570,38 +468,18 @@ describe("openPane", () => { const layout = store.getState().tabs[0]?.layout; expect(layout?.type).toBe("split"); - if (layout?.type === "split") { - expect(layout.children).toHaveLength(2); - } }); }); describe("movePaneToSplit", () => { it("moves a pane within the same tab", () => { - const store = makeStore({ - version: 1, - tabs: [ - { - id: "t1", - createdAt: Date.now(), - activePaneId: "p1", - layout: { - type: "split", - id: "s1", - direction: "horizontal", - children: [ - { type: "pane", paneId: "p1" }, - { type: "pane", paneId: "p2" }, - ], - weights: [1, 1], - }, - panes: { - p1: { id: "p1", kind: "test", data: { label: "p1" } }, - p2: { id: "p2", kind: "test", data: { label: "p2" } }, - }, - }, - ], - activeTabId: "t1", + const store = makeStore(); + store.getState().addTab({ id: "t1", panes: [tp("p1")] }); + store.getState().splitPane({ + tabId: "t1", + paneId: "p1", + position: "right", + newPane: tp("p2"), }); store.getState().movePaneToSplit({ @@ -611,48 +489,21 @@ describe("movePaneToSplit", () => { }); const tab = store.getState().tabs[0]; - expect(tab).toBeDefined(); - // p1 should now be split below p2 expect(tab?.panes.p1).toBeDefined(); expect(tab?.panes.p2).toBeDefined(); expect(tab?.activePaneId).toBe("p1"); }); it("moves a pane across tabs", () => { - const store = makeStore({ - version: 1, - tabs: [ - { - id: "t1", - createdAt: Date.now(), - activePaneId: "p1", - layout: { - type: "split", - id: "s1", - direction: "horizontal", - children: [ - { type: "pane", paneId: "p1" }, - { type: "pane", paneId: "p2" }, - ], - weights: [1, 1], - }, - panes: { - p1: { id: "p1", kind: "test", data: { label: "p1" } }, - p2: { id: "p2", kind: "test", data: { label: "p2" } }, - }, - }, - { - id: "t2", - createdAt: Date.now(), - activePaneId: "p3", - layout: { type: "pane", paneId: "p3" }, - panes: { - p3: { id: "p3", kind: "test", data: { label: "p3" } }, - }, - }, - ], - activeTabId: "t1", + const store = makeStore(); + store.getState().addTab({ id: "t1", panes: [tp("p1")] }); + store.getState().splitPane({ + tabId: "t1", + paneId: "p1", + position: "right", + newPane: tp("p2"), }); + store.getState().addTab({ id: "t2", panes: [tp("p3")] }); store.getState().movePaneToSplit({ sourcePaneId: "p1", @@ -673,31 +524,10 @@ describe("movePaneToSplit", () => { expect(store.getState().activeTabId).toBe("t2"); }); - it("removes source tab when last pane is moved out", () => { - const store = makeStore(); - store.getState().addTab({ id: "t1", panes: [tp("p1")] }); - store.getState().addTab({ id: "t2", panes: [tp("p2")] }); - - const tab1 = store.getState().tabs[0] as Tab; - const tab2 = store.getState().tabs[1] as Tab; - const p1Id = Object.keys(tab1.panes)[0] as string; - const p2Id = Object.keys(tab2.panes)[0] as string; - - store.getState().movePaneToSplit({ - sourcePaneId: p1Id, - targetPaneId: p2Id, - position: "right", - }); - - expect(store.getState().tabs).toHaveLength(1); - }); - it("is a no-op when dropping on self", () => { const store = makeStore(); store.getState().addTab({ id: "t1", panes: [tp("p1")] }); - const tab0 = store.getState().tabs[0] as Tab; - const p1Id = Object.keys(tab0.panes)[0] as string; const before = structuredClone({ version: store.getState().version, tabs: store.getState().tabs, @@ -705,8 +535,8 @@ describe("movePaneToSplit", () => { }); store.getState().movePaneToSplit({ - sourcePaneId: p1Id, - targetPaneId: p1Id, + sourcePaneId: "p1", + targetPaneId: "p1", position: "right", }); @@ -732,11 +562,6 @@ describe("edge cases", () => { store.getState().setActiveTab("missing"); store.getState().setActivePane({ tabId: "t1", paneId: "missing" }); store.getState().closePane({ tabId: "t1", paneId: "missing" }); - store.getState().resizeSplit({ - tabId: "t1", - splitId: "missing", - weights: [1], - }); expect({ version: store.getState().version, @@ -791,16 +616,6 @@ describe("reorderTab", () => { expect(store.getState().tabs.map((t) => t.id)).toEqual(before); }); - it("is a no-op for unknown tabId", () => { - const store = makeStore(); - store.getState().addTab({ id: "t1", panes: [tp("p1")] }); - - const before = store.getState().tabs.map((t) => t.id); - store.getState().reorderTab({ tabId: "missing", toIndex: 0 }); - - expect(store.getState().tabs.map((t) => t.id)).toEqual(before); - }); - it("clamps toIndex to valid range", () => { const store = makeStore(); store.getState().addTab({ id: "t1", panes: [tp("p1")] }); @@ -810,15 +625,4 @@ describe("reorderTab", () => { expect(store.getState().tabs.map((t) => t.id)).toEqual(["t2", "t1"]); }); - - it("preserves activeTabId", () => { - const store = makeStore(); - store.getState().addTab({ id: "t1", panes: [tp("p1")] }); - store.getState().addTab({ id: "t2", panes: [tp("p2")] }); - store.getState().setActiveTab("t2"); - - store.getState().reorderTab({ tabId: "t1", toIndex: 1 }); - - expect(store.getState().activeTabId).toBe("t2"); - }); }); diff --git a/packages/panes/src/core/store/store.ts b/packages/panes/src/core/store/store.ts index 8bfeff72a86..5cfe035e550 100644 --- a/packages/panes/src/core/store/store.ts +++ b/packages/panes/src/core/store/store.ts @@ -2,18 +2,21 @@ import { createStore, type StoreApi } from "zustand/vanilla"; import type { LayoutNode, Pane, + SplitPath, SplitPosition, Tab, WorkspaceState, } from "../../types"; import { + equalizeAllSplits, findFirstPaneId, findPaneInLayout, generateId, + positionToDirection, removePaneFromLayout, replacePaneIdInLayout, splitPaneInLayout, - updateSplitInLayout, + updateAtPath, } from "./utils"; function buildPane(args: CreatePaneInput): Pane { @@ -26,6 +29,26 @@ function buildPane(args: CreatePaneInput): Pane { }; } +function buildBalancedTree( + panes: LayoutNode[], + direction: "horizontal" | "vertical" = "vertical", +): LayoutNode { + if (panes.length === 1) { + const [single] = panes as [LayoutNode]; + return single; + } + + const mid = Math.ceil(panes.length / 2); + const nextDirection = direction === "vertical" ? "horizontal" : "vertical"; + + return { + type: "split", + direction, + first: buildBalancedTree(panes.slice(0, mid), nextDirection), + second: buildBalancedTree(panes.slice(mid), nextDirection), + }; +} + function buildTab(args: { id?: string; titleOverride?: string; @@ -33,26 +56,11 @@ function buildTab(args: { activePaneId?: string; }): Tab { const panesMap: Record> = {}; - let layout: LayoutNode; - - if (args.panes.length === 1) { - panesMap[args.panes[0].id] = args.panes[0]; - layout = { type: "pane", paneId: args.panes[0].id }; - } else { - const children: LayoutNode[] = []; - const weights: number[] = []; - for (const pane of args.panes) { - panesMap[pane.id] = pane; - children.push({ type: "pane", paneId: pane.id }); - weights.push(1); - } - layout = { - type: "split", - id: generateId("split"), - direction: "horizontal", - children, - weights, - }; + const leaves: LayoutNode[] = []; + + for (const pane of args.panes) { + panesMap[pane.id] = pane; + leaves.push({ type: "pane", paneId: pane.id }); } return { @@ -60,7 +68,7 @@ function buildTab(args: { titleOverride: args.titleOverride, createdAt: Date.now(), activePaneId: args.activePaneId ?? args.panes[0].id, - layout, + layout: buildBalancedTree(leaves), panes: panesMap, }; } @@ -119,7 +127,6 @@ export interface WorkspaceStore extends WorkspaceState { paneId: string; position: SplitPosition; newPane: CreatePaneInput; - weights?: number[]; selectNewPane?: boolean; }) => void; addPane: (args: { @@ -130,10 +137,11 @@ export interface WorkspaceStore extends WorkspaceState { }) => void; resizeSplit: (args: { tabId: string; - splitId: string; - weights: number[]; + path: SplitPath; + splitPercentage: number; }) => void; - equalizeSplit: (args: { tabId: string; splitId: string }) => void; + equalizeSplit: (args: { tabId: string; path: SplitPath }) => void; + equalizeTab: (args: { tabId: string }) => void; movePaneToSplit: (args: { sourcePaneId: string; @@ -141,6 +149,9 @@ export interface WorkspaceStore extends WorkspaceState { position: SplitPosition; }) => void; + movePaneToTab: (args: { paneId: string; targetTabId: string }) => void; + movePaneToNewTab: (args: { paneId: string }) => void; + reorderTab: (args: { tabId: string; toIndex: number }) => void; replaceState: ( @@ -458,7 +469,6 @@ export function createWorkspaceStore( args.paneId, newPane.id, args.position, - args.weights, ), panes: { ...tab.panes, @@ -531,22 +541,17 @@ export function createWorkspaceStore( }; } + const direction = positionToDirection(position); const newPaneLeaf: LayoutNode = { type: "pane", paneId: newPane.id, }; + const isFirst = position === "left" || position === "top"; const edgeLayout: LayoutNode = { type: "split", - id: generateId("split"), - direction: - position === "left" || position === "right" - ? "horizontal" - : "vertical", - children: - position === "left" || position === "top" - ? [newPaneLeaf, layout] - : [layout, newPaneLeaf], - weights: [1, 1], + direction, + first: isFirst ? newPaneLeaf : layout, + second: isFirst ? layout : newPaneLeaf, }; return { @@ -572,20 +577,18 @@ export function createWorkspaceStore( const tab = s.tabs.find((t) => t.id === args.tabId); if (!tab || !tab.layout) return s; - const { layout } = tab; - return { tabs: s.tabs.map((t) => t.id === args.tabId ? { ...t, - layout: updateSplitInLayout( - layout, - args.splitId, - (split) => ({ - ...split, - weights: args.weights, - }), + layout: updateAtPath(tab.layout, args.path, (node) => + node.type === "split" + ? { + ...node, + splitPercentage: args.splitPercentage, + } + : node, ), } : t, @@ -599,20 +602,15 @@ export function createWorkspaceStore( const tab = s.tabs.find((t) => t.id === args.tabId); if (!tab || !tab.layout) return s; - const { layout } = tab; - return { tabs: s.tabs.map((t) => t.id === args.tabId ? { ...t, - layout: updateSplitInLayout( - layout, - args.splitId, - (split) => ({ - ...split, - weights: split.children.map(() => 1), - }), + layout: updateAtPath( + tab.layout, + args.path, + equalizeAllSplits, ), } : t, @@ -621,9 +619,23 @@ export function createWorkspaceStore( }); }, + equalizeTab: (args) => { + set((s) => { + const tab = s.tabs.find((t) => t.id === args.tabId); + if (!tab?.layout) return s; + + return { + tabs: s.tabs.map((t) => + t.id === args.tabId + ? { ...t, layout: equalizeAllSplits(tab.layout) } + : t, + ), + }; + }); + }, + movePaneToSplit: (args) => { set((s) => { - // Find source and target tabs by pane ID let sourceTab: Tab | undefined; let sourcePane: Pane | undefined; let targetTab: Tab | undefined; @@ -639,20 +651,15 @@ export function createWorkspaceStore( if (!sourceTab || !sourcePane) return s; if (!targetTab || !targetTab.layout) return s; if (!findPaneInLayout(targetTab.layout, args.targetPaneId)) return s; - - // Don't drop on self if (args.sourcePaneId === args.targetPaneId) return s; - // Remove from source layout const nextSourceLayout = removePaneFromLayout( sourceTab.layout, args.sourcePaneId, ); const { [args.sourcePaneId]: _, ...nextSourcePanes } = sourceTab.panes; - // Insert into target layout const nextTargetLayout = splitPaneInLayout( - // If same tab, use the already-modified layout sourceTab.id === targetTab.id && nextSourceLayout ? nextSourceLayout : targetTab.layout, @@ -664,8 +671,7 @@ export function createWorkspaceStore( const nextTabs = s.tabs .map((t) => { if (sourceTab.id === targetTab.id && t.id === sourceTab.id) { - // Same-tab move - if (!nextSourceLayout) return null; // shouldn't happen since we check targetPaneId != sourcePaneId + if (!nextSourceLayout) return null; return { ...t, layout: nextTargetLayout, @@ -674,8 +680,7 @@ export function createWorkspaceStore( }; } if (t.id === sourceTab.id) { - // Source tab — pane removed - if (!nextSourceLayout) return null; // last pane removed, tab will be filtered + if (!nextSourceLayout) return null; return { ...t, layout: nextSourceLayout, @@ -687,7 +692,6 @@ export function createWorkspaceStore( }; } if (t.id === targetTab.id) { - // Target tab — pane added return { ...t, layout: nextTargetLayout, @@ -699,10 +703,116 @@ export function createWorkspaceStore( }) .filter((t): t is Tab => t !== null); - return { - tabs: nextTabs, - activeTabId: targetTab.id, + return { tabs: nextTabs, activeTabId: targetTab.id }; + }); + }, + + movePaneToTab: (args) => { + set((s) => { + let sourceTab: Tab | undefined; + let pane: Pane | undefined; + for (const t of s.tabs) { + if (t.panes[args.paneId]) { + sourceTab = t; + pane = t.panes[args.paneId]; + break; + } + } + if (!sourceTab || !pane || !sourceTab.layout) return s; + + const targetTab = s.tabs.find((t) => t.id === args.targetTabId); + if (!targetTab || !targetTab.layout) return s; + if (sourceTab.id === targetTab.id) return s; + + const nextSourceLayout = removePaneFromLayout( + sourceTab.layout, + args.paneId, + ); + const { [args.paneId]: _, ...nextSourcePanes } = sourceTab.panes; + + const paneLeaf: LayoutNode = { type: "pane", paneId: pane.id }; + const nextTargetLayout: LayoutNode = { + type: "split", + direction: "horizontal", + first: targetTab.layout, + second: paneLeaf, }; + + const nextTabs = s.tabs + .map((t) => { + if (t.id === sourceTab.id) { + if (!nextSourceLayout) return null; + return { + ...t, + layout: nextSourceLayout, + panes: nextSourcePanes, + activePaneId: + t.activePaneId === args.paneId + ? findFirstPaneId(nextSourceLayout) + : t.activePaneId, + }; + } + if (t.id === targetTab.id) { + return { + ...t, + layout: nextTargetLayout, + panes: { ...t.panes, [pane.id]: pane }, + activePaneId: pane.id, + }; + } + return t; + }) + .filter((t): t is Tab => t !== null); + + return { tabs: nextTabs, activeTabId: targetTab.id }; + }); + }, + + movePaneToNewTab: (args) => { + set((s) => { + let sourceTab: Tab | undefined; + let pane: Pane | undefined; + for (const t of s.tabs) { + if (t.panes[args.paneId]) { + sourceTab = t; + pane = t.panes[args.paneId]; + break; + } + } + if (!sourceTab || !pane || !sourceTab.layout) return s; + + const nextSourceLayout = removePaneFromLayout( + sourceTab.layout, + args.paneId, + ); + const { [args.paneId]: _, ...nextSourcePanes } = sourceTab.panes; + + const newTab = buildTab({ + panes: [pane], + activePaneId: pane.id, + }); + + const nextTabs = s.tabs + .map((t) => { + if (t.id === sourceTab.id) { + if (!nextSourceLayout) return null; + return { + ...t, + layout: nextSourceLayout, + panes: nextSourcePanes, + activePaneId: + t.activePaneId === args.paneId + ? findFirstPaneId(nextSourceLayout) + : t.activePaneId, + }; + } + return t; + }) + .filter((t): t is Tab => t !== null); + + nextTabs.push(newTab); + + return { tabs: nextTabs, activeTabId: newTab.id }; }); }, diff --git a/packages/panes/src/core/store/utils/index.ts b/packages/panes/src/core/store/utils/index.ts index e4f85fd557f..cca41893ed2 100644 --- a/packages/panes/src/core/store/utils/index.ts +++ b/packages/panes/src/core/store/utils/index.ts @@ -1,9 +1,13 @@ export { + equalizeAllSplits, findFirstPaneId, findPaneInLayout, generateId, + getNodeAtPath, + getOtherBranch, + positionToDirection, removePaneFromLayout, replacePaneIdInLayout, splitPaneInLayout, - updateSplitInLayout, + updateAtPath, } from "./utils"; diff --git a/packages/panes/src/core/store/utils/utils.test.ts b/packages/panes/src/core/store/utils/utils.test.ts index 7803559dde2..22fc3c0f2f4 100644 --- a/packages/panes/src/core/store/utils/utils.test.ts +++ b/packages/panes/src/core/store/utils/utils.test.ts @@ -1,58 +1,55 @@ import { describe, expect, it } from "bun:test"; import type { LayoutNode } from "../../../types"; import { + equalizeAllSplits, findFirstPaneId, findPaneInLayout, + getNodeAtPath, + getOtherBranch, positionToDirection, removePaneFromLayout, replacePaneIdInLayout, splitPaneInLayout, - updateSplitInLayout, + updateAtPath, } from "./utils"; const SINGLE: LayoutNode = { type: "pane", paneId: "a" }; const TWO_SPLIT: LayoutNode = { type: "split", - id: "s1", direction: "horizontal", - children: [ - { type: "pane", paneId: "a" }, - { type: "pane", paneId: "b" }, - ], - weights: [1, 1], + first: { type: "pane", paneId: "a" }, + second: { type: "pane", paneId: "b" }, }; -const THREE_SPLIT: LayoutNode = { +const NESTED: LayoutNode = { type: "split", - id: "s1", direction: "horizontal", - children: [ - { type: "pane", paneId: "a" }, - { type: "pane", paneId: "b" }, - { type: "pane", paneId: "c" }, - ], - weights: [3, 2, 1], + first: { type: "pane", paneId: "a" }, + second: { + type: "split", + direction: "vertical", + first: { type: "pane", paneId: "b" }, + second: { type: "pane", paneId: "c" }, + }, }; -const NESTED: LayoutNode = { +const DEEP: LayoutNode = { type: "split", - id: "s1", - direction: "horizontal", - children: [ - { type: "pane", paneId: "a" }, - { + direction: "vertical", + first: { type: "pane", paneId: "a" }, + second: { + type: "split", + direction: "vertical", + first: { type: "pane", paneId: "b" }, + second: { type: "split", - id: "s2", direction: "vertical", - children: [ - { type: "pane", paneId: "b" }, - { type: "pane", paneId: "c" }, - ], - weights: [1, 1], + first: { type: "pane", paneId: "c" }, + second: { type: "pane", paneId: "d" }, }, - ], - weights: [1, 1], + }, + splitPercentage: 30, }; describe("findPaneInLayout", () => { @@ -92,34 +89,39 @@ describe("removePaneFromLayout", () => { expect(removePaneFromLayout(SINGLE, "a")).toBeNull(); }); - it("returns the remaining pane when removing from a 2-pane split", () => { + it("promotes sibling when removing from a 2-pane split", () => { const result = removePaneFromLayout(TWO_SPLIT, "a"); expect(result).toEqual({ type: "pane", paneId: "b" }); }); - it("preserves weights when removing from a 3-pane split", () => { - const result = removePaneFromLayout(THREE_SPLIT, "b"); + it("promotes sibling (other direction)", () => { + const result = removePaneFromLayout(TWO_SPLIT, "b"); + expect(result).toEqual({ type: "pane", paneId: "a" }); + }); + + it("collapses nested split — sibling promotion preserves parent", () => { + // NESTED: { h: [a, { v: [b, c] }] } — remove b → { h: [a, c] } + const result = removePaneFromLayout(NESTED, "b"); expect(result).toMatchObject({ type: "split", - weights: [3, 1], - children: [ - { type: "pane", paneId: "a" }, - { type: "pane", paneId: "c" }, - ], + direction: "horizontal", + first: { type: "pane", paneId: "a" }, + second: { type: "pane", paneId: "c" }, }); }); - it("collapses nested split when child is removed", () => { - const result = removePaneFromLayout(NESTED, "b"); - // s2 had [b, c], removing b leaves just c — s2 collapses - // s1 now has [a, c] + it("preserves parent splitPercentage when descendant is removed", () => { + // DEEP: { v(30%): [a, { v: [b, { v: [c, d] }] }] } — remove c + const result = removePaneFromLayout(DEEP, "c"); expect(result).toMatchObject({ type: "split", - id: "s1", - children: [ - { type: "pane", paneId: "a" }, - { type: "pane", paneId: "c" }, - ], + splitPercentage: 30, + first: { type: "pane", paneId: "a" }, + second: { + type: "split", + first: { type: "pane", paneId: "b" }, + second: { type: "pane", paneId: "d" }, + }, }); }); @@ -139,17 +141,14 @@ describe("replacePaneIdInLayout", () => { it("replaces a pane id inside a split", () => { const result = replacePaneIdInLayout(TWO_SPLIT, "b", "x"); if (result.type === "split") { - expect(result.children[1]).toEqual({ type: "pane", paneId: "x" }); + expect(result.second).toEqual({ type: "pane", paneId: "x" }); } }); it("replaces in nested splits", () => { const result = replacePaneIdInLayout(NESTED, "c", "x"); - if (result.type === "split" && result.children[1]?.type === "split") { - expect(result.children[1].children[1]).toEqual({ - type: "pane", - paneId: "x", - }); + if (result.type === "split" && result.second.type === "split") { + expect(result.second.second).toEqual({ type: "pane", paneId: "x" }); } }); @@ -161,20 +160,23 @@ describe("replacePaneIdInLayout", () => { describe("splitPaneInLayout", () => { it("wraps a leaf in a new split", () => { const result = splitPaneInLayout(SINGLE, "a", "b", "right"); - expect(result.type).toBe("split"); + expect(result).toMatchObject({ + type: "split", + direction: "horizontal", + first: { type: "pane", paneId: "a" }, + second: { type: "pane", paneId: "b" }, + }); + // splitPercentage should be absent (defaults to 50) if (result.type === "split") { - expect(result.direction).toBe("horizontal"); - expect(result.weights).toEqual([1, 1]); - expect(result.children[0]).toEqual({ type: "pane", paneId: "a" }); - expect(result.children[1]).toEqual({ type: "pane", paneId: "b" }); + expect(result.splitPercentage).toBeUndefined(); } }); it("left/top puts new pane first", () => { const result = splitPaneInLayout(SINGLE, "a", "b", "left"); if (result.type === "split") { - expect(result.children[0]).toEqual({ type: "pane", paneId: "b" }); - expect(result.children[1]).toEqual({ type: "pane", paneId: "a" }); + expect(result.first).toEqual({ type: "pane", paneId: "b" }); + expect(result.second).toEqual({ type: "pane", paneId: "a" }); } }); @@ -185,73 +187,125 @@ describe("splitPaneInLayout", () => { } }); - it("inserts into existing same-direction split and halves weight", () => { - const result = splitPaneInLayout(THREE_SPLIT, "b", "d", "right"); - if (result.type === "split") { - expect(result.children).toHaveLength(4); - expect(result.weights).toEqual([3, 1, 1, 1]); - expect(result.children[1]).toEqual({ type: "pane", paneId: "b" }); - expect(result.children[2]).toEqual({ type: "pane", paneId: "d" }); - } - }); - - it("inserts left into existing same-direction split", () => { - const result = splitPaneInLayout(THREE_SPLIT, "b", "d", "left"); + it("always creates nested binary split (no flattening)", () => { + const result = splitPaneInLayout(TWO_SPLIT, "b", "c", "right"); if (result.type === "split") { - expect(result.children).toHaveLength(4); - expect(result.children[1]).toEqual({ type: "pane", paneId: "d" }); - expect(result.children[2]).toEqual({ type: "pane", paneId: "b" }); + expect(result.first).toEqual({ type: "pane", paneId: "a" }); + // b is now wrapped in a nested split with c + expect(result.second.type).toBe("split"); + if (result.second.type === "split") { + expect(result.second.first).toEqual({ type: "pane", paneId: "b" }); + expect(result.second.second).toEqual({ type: "pane", paneId: "c" }); + } } }); - it("creates nested split for cross-direction split", () => { + it("creates cross-direction nested split", () => { const result = splitPaneInLayout(TWO_SPLIT, "b", "c", "bottom"); if (result.type === "split") { - expect(result.children).toHaveLength(2); - expect(result.children[0]).toEqual({ type: "pane", paneId: "a" }); - const nested = result.children[1]; - expect(nested?.type).toBe("split"); - if (nested?.type === "split") { + expect(result.direction).toBe("horizontal"); // parent unchanged + const nested = result.second; + expect(nested.type).toBe("split"); + if (nested.type === "split") { expect(nested.direction).toBe("vertical"); - expect(nested.children[0]).toEqual({ type: "pane", paneId: "b" }); - expect(nested.children[1]).toEqual({ type: "pane", paneId: "c" }); + expect(nested.first).toEqual({ type: "pane", paneId: "b" }); + expect(nested.second).toEqual({ type: "pane", paneId: "c" }); } } }); +}); + +describe("getNodeAtPath", () => { + it("returns root for empty path", () => { + expect(getNodeAtPath(TWO_SPLIT, [])).toEqual(TWO_SPLIT); + }); - it("uses custom weights", () => { - const result = splitPaneInLayout(SINGLE, "a", "b", "right", [3, 1]); + it("returns first child", () => { + expect(getNodeAtPath(TWO_SPLIT, ["first"])).toEqual({ + type: "pane", + paneId: "a", + }); + }); + + it("returns nested node", () => { + expect(getNodeAtPath(NESTED, ["second", "first"])).toEqual({ + type: "pane", + paneId: "b", + }); + }); + + it("returns null for invalid path", () => { + expect(getNodeAtPath(SINGLE, ["first"])).toBeNull(); + }); +}); + +describe("updateAtPath", () => { + it("updates root", () => { + const result = updateAtPath(TWO_SPLIT, [], (node) => + node.type === "split" ? { ...node, splitPercentage: 75 } : node, + ); if (result.type === "split") { - expect(result.weights).toEqual([3, 1]); + expect(result.splitPercentage).toBe(75); + } + }); + + it("updates nested node", () => { + const result = updateAtPath(NESTED, ["second"], (node) => + node.type === "split" ? { ...node, splitPercentage: 30 } : node, + ); + if (result.type === "split" && result.second.type === "split") { + expect(result.second.splitPercentage).toBe(30); } }); }); -describe("updateSplitInLayout", () => { - it("updates a split by id", () => { - const result = updateSplitInLayout(TWO_SPLIT, "s1", (split) => ({ - ...split, - weights: [3, 7], - })); +describe("getOtherBranch", () => { + it("returns second for first", () => { + expect(getOtherBranch("first")).toBe("second"); + }); + + it("returns first for second", () => { + expect(getOtherBranch("second")).toBe("first"); + }); +}); + +describe("equalizeAllSplits", () => { + it("returns pane unchanged", () => { + expect(equalizeAllSplits(SINGLE)).toEqual(SINGLE); + }); + + it("sets splitPercentage to 50 for equal leaves", () => { + const result = equalizeAllSplits(TWO_SPLIT); if (result.type === "split") { - expect(result.weights).toEqual([3, 7]); + expect(result.splitPercentage).toBe(50); } }); - it("updates a nested split", () => { - const result = updateSplitInLayout(NESTED, "s2", (split) => ({ - ...split, - weights: [3, 1], - })); - if (result.type === "split" && result.children[1]?.type === "split") { - expect(result.children[1].weights).toEqual([3, 1]); + it("sets splitPercentage by leaf count ratio", () => { + // NESTED: [a, [b, c]] → first has 1 leaf, second has 2 → 33.33% + const result = equalizeAllSplits(NESTED); + if (result.type === "split") { + expect(result.splitPercentage).toBeCloseTo(33.33, 1); + // Nested split should be 50/50 + if (result.second.type === "split") { + expect(result.second.splitPercentage).toBe(50); + } } }); - it("returns unchanged layout for missing id", () => { - expect(updateSplitInLayout(TWO_SPLIT, "missing", (s) => s)).toEqual( - TWO_SPLIT, - ); + it("equalizes deep tree so all panes get equal space", () => { + // DEEP: [a, [b, [c, d]]] → 4 panes + // Root: 1/4 = 25%, second: 1/3 = 33.33%, innermost: 1/2 = 50% + const result = equalizeAllSplits(DEEP); + if (result.type === "split") { + expect(result.splitPercentage).toBe(25); + if (result.second.type === "split") { + expect(result.second.splitPercentage).toBeCloseTo(33.33, 1); + if (result.second.second.type === "split") { + expect(result.second.second.splitPercentage).toBe(50); + } + } + } }); }); diff --git a/packages/panes/src/core/store/utils/utils.ts b/packages/panes/src/core/store/utils/utils.ts index ce39f3620e9..7f3a24553ad 100644 --- a/packages/panes/src/core/store/utils/utils.ts +++ b/packages/panes/src/core/store/utils/utils.ts @@ -1,21 +1,26 @@ -import type { LayoutNode, SplitDirection, SplitPosition } from "../../../types"; +import type { + LayoutNode, + SplitBranch, + SplitDirection, + SplitPath, + SplitPosition, +} from "../../../types"; export function findPaneInLayout(node: LayoutNode, paneId: string): boolean { if (node.type === "pane") { return node.paneId === paneId; } - return node.children.some((child) => findPaneInLayout(child, paneId)); + return ( + findPaneInLayout(node.first, paneId) || + findPaneInLayout(node.second, paneId) + ); } export function findFirstPaneId(node: LayoutNode): string | null { if (node.type === "pane") { return node.paneId; } - for (const child of node.children) { - const id = findFirstPaneId(child); - if (id) return id; - } - return null; + return findFirstPaneId(node.first) ?? findFirstPaneId(node.second); } export function removePaneFromLayout( @@ -26,33 +31,16 @@ export function removePaneFromLayout( return node.paneId === paneId ? null : node; } - const nextChildren: LayoutNode[] = []; - const nextWeights: number[] = []; - - for (let i = 0; i < node.children.length; i++) { - const child = node.children[i]; - if (!child) continue; + const newFirst = removePaneFromLayout(node.first, paneId); + const newSecond = removePaneFromLayout(node.second, paneId); - const result = removePaneFromLayout(child, paneId); - if (result) { - nextChildren.push(result); - nextWeights.push(node.weights[i] ?? 1); - } - } - - if (nextChildren.length === 0) { - return null; - } + // Both removed (shouldn't happen in practice) + if (!newFirst && !newSecond) return null; + // Sibling promotion — one child removed, promote the other + if (!newFirst) return newSecond; + if (!newSecond) return newFirst; - if (nextChildren.length === 1) { - return nextChildren[0] ?? null; - } - - return { - ...node, - children: nextChildren, - weights: nextWeights, - }; + return { ...node, first: newFirst, second: newSecond }; } export function replacePaneIdInLayout( @@ -68,9 +56,8 @@ export function replacePaneIdInLayout( return { ...node, - children: node.children.map((child) => - replacePaneIdInLayout(child, oldPaneId, newPaneId), - ), + first: replacePaneIdInLayout(node.first, oldPaneId, newPaneId), + second: replacePaneIdInLayout(node.second, oldPaneId, newPaneId), }; } @@ -79,7 +66,6 @@ export function splitPaneInLayout( targetPaneId: string, newPaneId: string, position: SplitPosition, - weights?: number[], ): LayoutNode { if (node.type === "pane") { if (node.paneId !== targetPaneId) return node; @@ -90,76 +76,65 @@ export function splitPaneInLayout( return { type: "split", - id: generateId("split"), direction, - children: isFirst ? [newPaneNode, node] : [node, newPaneNode], - weights: weights ?? [1, 1], + first: isFirst ? newPaneNode : node, + second: isFirst ? node : newPaneNode, }; } - const parentInfo = findDirectChild(node, targetPaneId); - - if (parentInfo && node.direction === positionToDirection(position)) { - const { childIndex } = parentInfo; - const currentWeight = node.weights[childIndex] ?? 1; - const halfWeight = currentWeight / 2; - const newPaneNode: LayoutNode = { type: "pane", paneId: newPaneId }; - const isFirst = position === "left" || position === "top"; - - const nextChildren = [...node.children]; - const nextWeights = [...node.weights]; + return { + ...node, + first: splitPaneInLayout(node.first, targetPaneId, newPaneId, position), + second: splitPaneInLayout(node.second, targetPaneId, newPaneId, position), + }; +} - nextWeights[childIndex] = halfWeight; +export function getNodeAtPath( + node: LayoutNode, + path: SplitPath, +): LayoutNode | null { + if (path.length === 0) return node; + if (node.type === "pane") return null; - if (isFirst) { - nextChildren.splice(childIndex, 0, newPaneNode); - nextWeights.splice(childIndex, 0, halfWeight); - } else { - nextChildren.splice(childIndex + 1, 0, newPaneNode); - nextWeights.splice(childIndex + 1, 0, halfWeight); - } + const [branch, ...rest] = path as [SplitBranch, ...SplitBranch[]]; + return getNodeAtPath(node[branch], rest); +} - return { - ...node, - children: nextChildren, - weights: nextWeights, - }; - } +export function updateAtPath( + node: LayoutNode, + path: SplitPath, + updater: (node: LayoutNode) => LayoutNode, +): LayoutNode { + if (path.length === 0) return updater(node); + if (node.type === "pane") return node; + const [branch, ...rest] = path as [SplitBranch, ...SplitBranch[]]; return { ...node, - children: node.children.map((child) => - splitPaneInLayout(child, targetPaneId, newPaneId, position, weights), - ), + [branch]: updateAtPath(node[branch], rest, updater), }; } -function findDirectChild( - split: LayoutNode & { type: "split" }, - paneId: string, -): { childIndex: number } | null { - for (let i = 0; i < split.children.length; i++) { - const child = split.children[i]; - if (child?.type === "pane" && child.paneId === paneId) { - return { childIndex: i }; - } - } - return null; +export function getOtherBranch(branch: SplitBranch): SplitBranch { + return branch === "first" ? "second" : "first"; } -export function updateSplitInLayout( - node: LayoutNode, - splitId: string, - updater: (split: LayoutNode & { type: "split" }) => LayoutNode, -): LayoutNode { +function countLeaves(node: LayoutNode): number { + if (node.type === "pane") return 1; + return countLeaves(node.first) + countLeaves(node.second); +} + +export function equalizeAllSplits(node: LayoutNode): LayoutNode { if (node.type === "pane") return node; - if (node.id === splitId) return updater(node); + + const firstLeaves = countLeaves(node.first); + const secondLeaves = countLeaves(node.second); return { ...node, - children: node.children.map((child) => - updateSplitInLayout(child, splitId, updater), - ), + splitPercentage: (firstLeaves / (firstLeaves + secondLeaves)) * 100, + first: equalizeAllSplits(node.first), + second: equalizeAllSplits(node.second), }; } diff --git a/packages/panes/src/index.ts b/packages/panes/src/index.ts index f582526cc03..8f1844c72cb 100644 --- a/packages/panes/src/index.ts +++ b/packages/panes/src/index.ts @@ -6,6 +6,7 @@ export type { } from "./core/store"; export { createWorkspaceStore } from "./core/store"; export type { + ContextMenuActionConfig, PaneActionConfig, PaneContext, PaneDefinition, @@ -18,7 +19,9 @@ export { Workspace } from "./react"; export type { LayoutNode, Pane, + SplitBranch, SplitDirection, + SplitPath, SplitPosition, Tab, WorkspaceState, diff --git a/packages/panes/src/react/components/Workspace/Workspace.tsx b/packages/panes/src/react/components/Workspace/Workspace.tsx index 9d35efeaf41..70b712ca8aa 100644 --- a/packages/panes/src/react/components/Workspace/Workspace.tsx +++ b/packages/panes/src/react/components/Workspace/Workspace.tsx @@ -13,6 +13,7 @@ export function Workspace({ renderAddTabMenu, onBeforeCloseTab, paneActions, + contextMenuActions, }: WorkspaceProps) { const tabs = useStore(store, (s) => s.tabs); const activeTabId = useStore(store, (s) => s.activeTabId); @@ -66,6 +67,7 @@ export function Workspace({ tab={activeTab} registry={registry} paneActions={paneActions} + contextMenuActions={contextMenuActions} /> ) : (
diff --git a/packages/panes/src/react/components/Workspace/components/Tab/Tab.tsx b/packages/panes/src/react/components/Workspace/components/Tab/Tab.tsx index 1daf3b983fc..6e8ebd77c1c 100644 --- a/packages/panes/src/react/components/Workspace/components/Tab/Tab.tsx +++ b/packages/panes/src/react/components/Workspace/components/Tab/Tab.tsx @@ -6,8 +6,13 @@ import { import { useRef } from "react"; import type { StoreApi } from "zustand/vanilla"; import type { WorkspaceStore } from "../../../../../core/store"; -import type { LayoutNode, Tab as TabType } from "../../../../../types"; import type { + LayoutNode, + SplitPath, + Tab as TabType, +} from "../../../../../types"; +import type { + ContextMenuActionConfig, PaneActionConfig, PaneRegistry, RendererContext, @@ -21,65 +26,75 @@ interface TabProps { paneActions?: | PaneActionConfig[] | ((context: RendererContext) => PaneActionConfig[]); -} - -function weightsToPercentages(weights: number[]): number[] { - const total = weights.reduce((sum, w) => sum + w, 0); - if (total === 0) return weights.map(() => 100 / weights.length); - return weights.map((w) => (w / total) * 100); + contextMenuActions?: + | ContextMenuActionConfig[] + | ((context: RendererContext) => ContextMenuActionConfig[]); } function SplitView({ store, tab, node, + path, registry, paneActions, + contextMenuActions, }: { store: StoreApi>; tab: TabType; node: Extract; + path: SplitPath; registry: PaneRegistry; paneActions?: TabProps["paneActions"]; + contextMenuActions?: TabProps["contextMenuActions"]; }) { const groupRef = useRef>(null); - const percentages = weightsToPercentages(node.weights); + const firstSize = node.splitPercentage ?? 50; + const secondSize = 100 - firstSize; return ( { - store.getState().resizeSplit({ - tabId: tab.id, - splitId: node.id, - weights: sizes, - }); + if (sizes[0] != null) { + store.getState().resizeSplit({ + tabId: tab.id, + path, + splitPercentage: sizes[0], + }); + } }} onDoubleClick={(e) => { e.stopPropagation(); - const equal = node.children.map(() => 100 / node.children.length); - groupRef.current?.setLayout(equal); + groupRef.current?.setLayout([50, 50]); }} > - {node.children.map((child, index) => { - const key = child.type === "pane" ? child.paneId : child.id; - return ( - <> - {index > 0 && } - - - - - ); - })} + + + + + + + ); } @@ -88,15 +103,19 @@ function LayoutNodeView({ store, tab, node, + path, registry, paneActions, + contextMenuActions, parentDirection = null, }: { store: StoreApi>; tab: TabType; node: LayoutNode; + path: SplitPath; registry: PaneRegistry; paneActions?: TabProps["paneActions"]; + contextMenuActions?: TabProps["contextMenuActions"]; parentDirection?: "horizontal" | "vertical" | null; }) { if (node.type === "pane") { @@ -111,6 +130,7 @@ function LayoutNodeView({ isActive={tab.activePaneId === pane.id} registry={registry} paneActions={paneActions} + contextMenuActions={contextMenuActions} parentDirection={parentDirection} /> ); @@ -121,8 +141,10 @@ function LayoutNodeView({ store={store} tab={tab} node={node} + path={path} registry={registry} paneActions={paneActions} + contextMenuActions={contextMenuActions} /> ); } @@ -132,6 +154,7 @@ export function Tab({ tab, registry, paneActions, + contextMenuActions, }: TabProps) { if (!tab.layout) { return ( @@ -147,8 +170,10 @@ export function Tab({ store={store} tab={tab} node={tab.layout} + path={[]} registry={registry} paneActions={paneActions} + contextMenuActions={contextMenuActions} />
); diff --git a/packages/panes/src/react/components/Workspace/components/Tab/components/Pane/Pane.tsx b/packages/panes/src/react/components/Workspace/components/Tab/components/Pane/Pane.tsx index d9dde1bc58b..1ac744d2c51 100644 --- a/packages/panes/src/react/components/Workspace/components/Tab/components/Pane/Pane.tsx +++ b/packages/panes/src/react/components/Workspace/components/Tab/components/Pane/Pane.tsx @@ -8,6 +8,7 @@ import type { Tab, } from "../../../../../../../types"; import type { + ContextMenuActionConfig, PaneActionConfig, PaneRegistry, RendererContext, @@ -15,6 +16,7 @@ import type { import { PaneHeaderActions } from "../../../../../PaneHeaderActions"; import { DropZoneOverlay } from "./components/DropZoneOverlay"; import { PaneContent } from "./components/PaneContent"; +import { PaneContextMenu } from "./components/PaneContextMenu"; import { PANE_DRAG_TYPE, PaneHeader } from "./components/PaneHeader"; interface PaneComponentProps { @@ -27,19 +29,19 @@ interface PaneComponentProps { paneActions?: | PaneActionConfig[] | ((context: RendererContext) => PaneActionConfig[]); + contextMenuActions?: + | ContextMenuActionConfig[] + | ((context: RendererContext) => ContextMenuActionConfig[]); } -function resolveActions( +function resolveActions( config: - | PaneActionConfig[] - | (( - context: RendererContext, - defaults: PaneActionConfig[], - ) => PaneActionConfig[]) + | TAction[] + | ((context: RendererContext, defaults: TAction[]) => TAction[]) | undefined, context: RendererContext, - defaults: PaneActionConfig[], -): PaneActionConfig[] { + defaults: TAction[], +): TAction[] { if (!config) return defaults; if (typeof config === "function") return config(context, defaults); return config; @@ -68,6 +70,7 @@ export function Pane({ registry, parentDirection = null, paneActions, + contextMenuActions, }: PaneComponentProps) { const definition = registry[pane.kind]; @@ -143,6 +146,19 @@ export function Pane({ tabPosition, ]); + const resolvedContextMenuActions = useMemo(() => { + const workspaceResolved = + typeof contextMenuActions === "function" + ? contextMenuActions(context) + : (contextMenuActions ?? []); + + return resolveActions( + definition?.contextMenuActions, + context, + workspaceResolved, + ); + }, [context, contextMenuActions, definition]); + const dropPositionRef = useRef(null); const [dropPosition, setDropPosition] = useState(null); const dropRef = useRef(null); @@ -205,38 +221,40 @@ export function Pane({ const isDropTarget = isOver && canDrop; return ( - // biome-ignore lint/a11y/noStaticElementInteractions: clicking anywhere in a pane focuses it (standard IDE behavior) -
- } - paneId={pane.id} - onClick={ - definition?.onHeaderClick - ? () => definition.onHeaderClick?.(context) - : context.actions.pin - } - onMiddleClick={context.actions.close} - /> - - {definition ? ( - definition.renderPane(context) - ) : ( -
- Unknown pane kind: {pane.kind} -
- )} -
- {isDropTarget && } -
+ + {/* biome-ignore lint/a11y/noStaticElementInteractions: clicking anywhere in a pane focuses it (standard IDE behavior) */} +
+ } + paneId={pane.id} + onClick={ + definition?.onHeaderClick + ? () => definition.onHeaderClick?.(context) + : context.actions.pin + } + onMiddleClick={context.actions.close} + /> + + {definition ? ( + definition.renderPane(context) + ) : ( +
+ Unknown pane kind: {pane.kind} +
+ )} +
+ {isDropTarget && } +
+
); } diff --git a/packages/panes/src/react/components/Workspace/components/Tab/components/Pane/components/PaneContextMenu/PaneContextMenu.tsx b/packages/panes/src/react/components/Workspace/components/Tab/components/Pane/components/PaneContextMenu/PaneContextMenu.tsx new file mode 100644 index 00000000000..af97bcf2eb2 --- /dev/null +++ b/packages/panes/src/react/components/Workspace/components/Tab/components/Pane/components/PaneContextMenu/PaneContextMenu.tsx @@ -0,0 +1,98 @@ +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuTrigger, +} from "@superset/ui/context-menu"; +import type { ReactNode } from "react"; +import type { + ContextMenuActionConfig, + RendererContext, +} from "../../../../../../../../types"; + +interface PaneContextMenuProps { + children: ReactNode; + actions: ContextMenuActionConfig[]; + context: RendererContext; +} + +function ContextMenuItems({ + actions, + context, +}: { + actions: ContextMenuActionConfig[]; + context: RendererContext; +}) { + return ( + <> + {actions.map((action) => { + if (action.type === "separator") { + return ; + } + + if (action.children) { + const childActions = + typeof action.children === "function" + ? action.children(context) + : action.children; + + return ( + + + {action.icon} + {action.label} + + + + + + ); + } + + const disabled = + typeof action.disabled === "function" + ? action.disabled(context) + : action.disabled; + + const shortcut = action.shortcut ?? action.hotkeyId; + + return ( + action.onSelect?.(context)} + > + {action.icon} + {action.label} + {shortcut && {shortcut}} + + ); + })} + + ); +} + +export function PaneContextMenu({ + children, + actions, + context, +}: PaneContextMenuProps) { + if (actions.length === 0) { + return <>{children}; + } + + return ( + + {children} + + + + + ); +} diff --git a/packages/panes/src/react/components/Workspace/components/Tab/components/Pane/components/PaneContextMenu/index.ts b/packages/panes/src/react/components/Workspace/components/Tab/components/Pane/components/PaneContextMenu/index.ts new file mode 100644 index 00000000000..246aa9d59a4 --- /dev/null +++ b/packages/panes/src/react/components/Workspace/components/Tab/components/Pane/components/PaneContextMenu/index.ts @@ -0,0 +1 @@ +export { PaneContextMenu } from "./PaneContextMenu"; diff --git a/packages/panes/src/react/index.ts b/packages/panes/src/react/index.ts index 3ac9a4a111f..c54e0d47c8f 100644 --- a/packages/panes/src/react/index.ts +++ b/packages/panes/src/react/index.ts @@ -1,5 +1,6 @@ export { Workspace } from "./components/Workspace"; export type { + ContextMenuActionConfig, PaneActionConfig, PaneContext, PaneDefinition, diff --git a/packages/panes/src/react/types.ts b/packages/panes/src/react/types.ts index 293f1f0f9b0..6393a6d598e 100644 --- a/packages/panes/src/react/types.ts +++ b/packages/panes/src/react/types.ts @@ -10,6 +10,21 @@ export interface PaneActionConfig { onClick: (context: RendererContext) => void; } +export interface ContextMenuActionConfig { + key: string; + label?: string; + icon?: ReactNode; + hotkeyId?: string; + shortcut?: string; + onSelect?: (context: RendererContext) => void; + disabled?: boolean | ((context: RendererContext) => boolean); + variant?: "destructive"; + type?: "item" | "separator"; + children?: + | ContextMenuActionConfig[] + | ((context: RendererContext) => ContextMenuActionConfig[]); +} + export interface PaneContext extends Pane { parentDirection: "horizontal" | "vertical" | null; } @@ -56,6 +71,12 @@ export interface PaneDefinition { context: RendererContext, defaults: PaneActionConfig[], ) => PaneActionConfig[]); + contextMenuActions?: + | ContextMenuActionConfig[] + | (( + context: RendererContext, + defaults: ContextMenuActionConfig[], + ) => ContextMenuActionConfig[]); } export type PaneRegistry = Record>; @@ -75,4 +96,7 @@ export interface WorkspaceProps { paneActions?: | PaneActionConfig[] | ((context: RendererContext) => PaneActionConfig[]); + contextMenuActions?: + | ContextMenuActionConfig[] + | ((context: RendererContext) => ContextMenuActionConfig[]); } diff --git a/packages/panes/src/types.ts b/packages/panes/src/types.ts index 423429bbb92..b35784deb45 100644 --- a/packages/panes/src/types.ts +++ b/packages/panes/src/types.ts @@ -2,14 +2,18 @@ export type SplitDirection = "horizontal" | "vertical"; export type SplitPosition = "top" | "right" | "bottom" | "left"; +export type SplitBranch = "first" | "second"; + +export type SplitPath = SplitBranch[]; + export type LayoutNode = | { type: "pane"; paneId: string } | { type: "split"; - id: string; direction: SplitDirection; - children: LayoutNode[]; - weights: number[]; + first: LayoutNode; + second: LayoutNode; + splitPercentage?: number; }; export interface Pane { From d0d7df49649bd80d83a66c6d44920f01ce888141 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sun, 5 Apr 2026 19:29:59 -0700 Subject: [PATCH 395/816] feat(panes): prefer sibling pane when closing active pane (#3198) After closing the active pane, activate its peer in the layout tree instead of always falling back to the first (top-left) pane. Falls back to findFirstPaneId only when no sibling is found. --- packages/panes/src/core/store/store.ts | 13 ++++++++---- packages/panes/src/core/store/utils/index.ts | 1 + packages/panes/src/core/store/utils/utils.ts | 22 ++++++++++++++++++++ 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/packages/panes/src/core/store/store.ts b/packages/panes/src/core/store/store.ts index 5cfe035e550..fa98a56ba53 100644 --- a/packages/panes/src/core/store/store.ts +++ b/packages/panes/src/core/store/store.ts @@ -11,6 +11,7 @@ import { equalizeAllSplits, findFirstPaneId, findPaneInLayout, + findSiblingPaneId, generateId, positionToDirection, removePaneFromLayout, @@ -281,7 +282,8 @@ export function createWorkspaceStore( panes: nextPanes, activePaneId: tab.activePaneId === args.paneId - ? findFirstPaneId(nextLayout) + ? (findSiblingPaneId(tab.layout, args.paneId) ?? + findFirstPaneId(nextLayout)) : tab.activePaneId, } : t, @@ -687,7 +689,8 @@ export function createWorkspaceStore( panes: nextSourcePanes, activePaneId: t.activePaneId === args.sourcePaneId - ? findFirstPaneId(nextSourceLayout) + ? (findSiblingPaneId(sourceTab.layout, args.sourcePaneId) ?? + findFirstPaneId(nextSourceLayout)) : t.activePaneId, }; } @@ -748,7 +751,8 @@ export function createWorkspaceStore( panes: nextSourcePanes, activePaneId: t.activePaneId === args.paneId - ? findFirstPaneId(nextSourceLayout) + ? (findSiblingPaneId(sourceTab.layout, args.paneId) ?? + findFirstPaneId(nextSourceLayout)) : t.activePaneId, }; } @@ -802,7 +806,8 @@ export function createWorkspaceStore( panes: nextSourcePanes, activePaneId: t.activePaneId === args.paneId - ? findFirstPaneId(nextSourceLayout) + ? (findSiblingPaneId(sourceTab.layout, args.paneId) ?? + findFirstPaneId(nextSourceLayout)) : t.activePaneId, }; } diff --git a/packages/panes/src/core/store/utils/index.ts b/packages/panes/src/core/store/utils/index.ts index cca41893ed2..3b4da77a2bd 100644 --- a/packages/panes/src/core/store/utils/index.ts +++ b/packages/panes/src/core/store/utils/index.ts @@ -2,6 +2,7 @@ export { equalizeAllSplits, findFirstPaneId, findPaneInLayout, + findSiblingPaneId, generateId, getNodeAtPath, getOtherBranch, diff --git a/packages/panes/src/core/store/utils/utils.ts b/packages/panes/src/core/store/utils/utils.ts index 7f3a24553ad..6bc4df9a64f 100644 --- a/packages/panes/src/core/store/utils/utils.ts +++ b/packages/panes/src/core/store/utils/utils.ts @@ -23,6 +23,28 @@ export function findFirstPaneId(node: LayoutNode): string | null { return findFirstPaneId(node.first) ?? findFirstPaneId(node.second); } +export function findSiblingPaneId( + node: LayoutNode, + paneId: string, +): string | null { + if (node.type === "pane") return null; + + const inFirst = findPaneInLayout(node.first, paneId); + const inSecond = findPaneInLayout(node.second, paneId); + + if (inFirst && !inSecond) { + // Target is in the first branch — sibling is the nearest pane in second + const deeper = findSiblingPaneId(node.first, paneId); + return deeper ?? findFirstPaneId(node.second); + } + if (inSecond && !inFirst) { + const deeper = findSiblingPaneId(node.second, paneId); + return deeper ?? findFirstPaneId(node.first); + } + + return null; +} + export function removePaneFromLayout( node: LayoutNode, paneId: string, From 453d95621210fda80361d44973f741547a344b91 Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Tue, 7 Apr 2026 05:09:34 +0900 Subject: [PATCH 396/816] fix(fork): restore BROWSER_HARD_RELOAD and SEARCH_IN_FILES in v2 workspace - Pass action param to addBrowserShortcutListener to distinguish hard reload - Add hardReloadToken to BrowserPaneData for cache-busting on hard reload - Wire up SEARCH_IN_FILES hotkey to open CommandPalette in v2 --- .../hooks/usePaneRegistry/usePaneRegistry.tsx | 6 +++++- .../_dashboard/v2-workspace/$workspaceId/page.tsx | 8 +++++++- .../_dashboard/v2-workspace/$workspaceId/types.ts | 2 ++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx index cc43f3cb4fb..dfaaffd4f32 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx @@ -179,11 +179,15 @@ export function usePaneRegistry( }, renderPane: (ctx: RendererContext) => { const data = ctx.pane.data as BrowserPaneData; + // FORK NOTE: hard reload appends cache-bust param to force full re-fetch + const src = data.hardReloadToken + ? `${data.url}${data.url.includes("?") ? "&" : "?"}__cb=${data.hardReloadToken}` + : data.url; return (