From a7816ee34eca00afdb85953522410a63c56c72bc Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Sat, 28 Mar 2026 02:09:32 +0900 Subject: [PATCH 01/67] 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 02/67] 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 03/67] 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 04/67] 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 05/67] 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 06/67] 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 07/67] 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 08/67] 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 09/67] 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 10/67] 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 11/67] 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 12/67] 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 13/67] 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 14/67] 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 15/67] 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 (
-