diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 14d0e5021e0..42f6bcef4fb 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -55,7 +55,6 @@ "dnd-core": "^16.0.1", "dotenv": "^17.2.3", "electron-router-dom": "^2.1.0", - "electron-store": "^11.0.2", "electron-updater": "6", "execa": "^9.6.0", "express": "^5.1.0", diff --git a/apps/desktop/src/lib/trpc/routers/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts index 342d11f1bde..4571dbbda4b 100644 --- a/apps/desktop/src/lib/trpc/routers/index.ts +++ b/apps/desktop/src/lib/trpc/routers/index.ts @@ -7,6 +7,7 @@ import { createNotificationsRouter } from "./notifications"; import { createProjectsRouter } from "./projects"; import { createSettingsRouter } from "./settings"; import { createTerminalRouter } from "./terminal"; +import { createUiStateRouter } from "./ui-state"; import { createWindowRouter } from "./window"; import { createWorkspacesRouter } from "./workspaces"; @@ -28,6 +29,7 @@ export const createAppRouter = (getWindow: () => BrowserWindow | null) => { external: createExternalRouter(), settings: createSettingsRouter(), config: createConfigRouter(), + uiState: createUiStateRouter(), }); }; diff --git a/apps/desktop/src/lib/trpc/routers/notifications.ts b/apps/desktop/src/lib/trpc/routers/notifications.ts index bae8e0691d6..928b1de61a9 100644 --- a/apps/desktop/src/lib/trpc/routers/notifications.ts +++ b/apps/desktop/src/lib/trpc/routers/notifications.ts @@ -7,7 +7,10 @@ import { publicProcedure, router } from ".."; type NotificationEvent = | { type: "agent-complete"; data: AgentCompleteEvent } - | { type: "focus-tab"; data: { tabId: string; workspaceId: string } }; + | { + type: "focus-tab"; + data: { paneId: string; tabId: string; workspaceId: string }; + }; export const createNotificationsRouter = () => { return router({ @@ -20,7 +23,11 @@ export const createNotificationsRouter = () => { emit.next({ type: "agent-complete", data: event }); }; - const onFocusTab = (data: { tabId: string; workspaceId: string }) => { + const onFocusTab = (data: { + paneId: string; + tabId: string; + workspaceId: string; + }) => { emit.next({ type: "focus-tab", data }); }; diff --git a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts index 477f31aff06..eccfd3d6684 100644 --- a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts +++ b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts @@ -8,27 +8,23 @@ import { resolveCwd } from "./utils"; /** * Terminal router using TerminalManager with node-pty - * Sessions are keyed by tabId and linked to workspaces for cwd resolution + * Sessions are keyed by paneId and linked to workspaces for cwd resolution * - * IMPORTANT: When creating terminals, ensure these env vars are passed: - * - PATH: Prepend ~/.superset/bin (use getSupersetBinDir() from agent-setup) - * - SUPERSET_TAB_ID: The tab's ID - * - SUPERSET_TAB_TITLE: The tab's display title - * - SUPERSET_WORKSPACE_NAME: The workspace name - * - SUPERSET_PORT: The hooks server port (use getHooksServerPort()) - * - * PATH prepending ensures our wrapper scripts (~/.superset/bin/claude, codex) - * are used instead of system binaries. These wrappers inject hook settings - * that notify the app when agents complete their tasks. + * Environment variables set for terminal sessions: + * - PATH: Prepends ~/.superset/bin so wrapper scripts intercept agent commands + * - SUPERSET_PANE_ID: The pane ID (used by notification hooks, session key) + * - SUPERSET_TAB_ID: The tab ID (parent of pane, used by notification hooks) + * - SUPERSET_WORKSPACE_ID: The workspace ID (used by notification hooks) + * - SUPERSET_PORT: The hooks server port for agent completion notifications */ export const createTerminalRouter = () => { return router({ createOrAttach: publicProcedure .input( z.object({ + paneId: z.string(), tabId: z.string(), workspaceId: z.string(), - tabTitle: z.string(), cols: z.number().optional(), rows: z.number().optional(), cwd: z.string().optional(), @@ -37,41 +33,26 @@ export const createTerminalRouter = () => { ) .mutation(async ({ input }) => { const { + paneId, tabId, workspaceId, - tabTitle, cols, rows, cwd: cwdOverride, initialCommands, } = input; - // Get workspace to determine cwd and workspace name - const workspace = db.data.workspaces.find((w) => w.id === workspaceId); - const worktree = workspace - ? db.data.worktrees.find((wt) => wt.id === workspace.worktreeId) - : undefined; - const workspaceName = - workspace?.name || worktree?.branch || "Workspace"; - // Resolve cwd: absolute paths stay as-is, relative paths resolve against worktree + const workspace = db.data.workspaces.find((w) => w.id === workspaceId); const worktreePath = workspace ? getWorktreePath(workspace.worktreeId) : undefined; const cwd = resolveCwd(cwdOverride, worktreePath); - // Get project to get root path for setup scripts - const project = workspace - ? db.data.projects.find((p) => p.id === workspace.projectId) - : undefined; - const rootPath = project?.mainRepoPath; - const result = await terminalManager.createOrAttach({ + paneId, tabId, workspaceId, - tabTitle, - workspaceName, - rootPath, cwd, cols, rows, @@ -79,7 +60,7 @@ export const createTerminalRouter = () => { }); return { - tabId, + paneId, isNew: result.isNew, scrollback: result.scrollback, wasRecovered: result.wasRecovered, @@ -89,7 +70,7 @@ export const createTerminalRouter = () => { write: publicProcedure .input( z.object({ - tabId: z.string(), + paneId: z.string(), data: z.string(), }), ) @@ -100,7 +81,7 @@ export const createTerminalRouter = () => { resize: publicProcedure .input( z.object({ - tabId: z.string(), + paneId: z.string(), cols: z.number(), rows: z.number(), seq: z.number().optional(), @@ -113,7 +94,7 @@ export const createTerminalRouter = () => { signal: publicProcedure .input( z.object({ - tabId: z.string(), + paneId: z.string(), signal: z.string().optional(), }), ) @@ -124,7 +105,7 @@ export const createTerminalRouter = () => { kill: publicProcedure .input( z.object({ - tabId: z.string(), + paneId: z.string(), deleteHistory: z.boolean().optional(), }), ) @@ -138,7 +119,7 @@ export const createTerminalRouter = () => { detach: publicProcedure .input( z.object({ - tabId: z.string(), + paneId: z.string(), }), ) .mutation(async ({ input }) => { @@ -152,7 +133,7 @@ export const createTerminalRouter = () => { clearScrollback: publicProcedure .input( z.object({ - tabId: z.string(), + paneId: z.string(), }), ) .mutation(async ({ input }) => { @@ -161,8 +142,8 @@ export const createTerminalRouter = () => { getSession: publicProcedure .input(z.string()) - .query(async ({ input: tabId }) => { - return terminalManager.getSession(tabId); + .query(async ({ input: paneId }) => { + return terminalManager.getSession(paneId); }), /** @@ -185,7 +166,7 @@ export const createTerminalRouter = () => { stream: publicProcedure .input(z.string()) - .subscription(({ input: tabId }) => { + .subscription(({ input: paneId }) => { return observable< | { type: "data"; data: string } | { type: "exit"; exitCode: number; signal?: number } @@ -199,13 +180,13 @@ export const createTerminalRouter = () => { emit.complete(); }; - terminalManager.on(`data:${tabId}`, onData); - terminalManager.on(`exit:${tabId}`, onExit); + terminalManager.on(`data:${paneId}`, onData); + terminalManager.on(`exit:${paneId}`, onExit); // Cleanup on unsubscribe return () => { - terminalManager.off(`data:${tabId}`, onData); - terminalManager.off(`exit:${tabId}`, onExit); + terminalManager.off(`data:${paneId}`, onData); + terminalManager.off(`exit:${paneId}`, onExit); }; }); }), diff --git a/apps/desktop/src/lib/trpc/routers/ui-state/index.ts b/apps/desktop/src/lib/trpc/routers/ui-state/index.ts new file mode 100644 index 00000000000..d1dda4cfcf5 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/ui-state/index.ts @@ -0,0 +1,194 @@ +import { appState } from "main/lib/app-state"; +import type { TabsState, ThemeState } from "main/lib/app-state/schemas"; +import { z } from "zod"; +import { publicProcedure, router } from "../.."; + +/** + * Zod schema for Pane + */ +const paneSchema = z.object({ + id: z.string(), + tabId: z.string(), + type: z.enum(["terminal", "webview"]), + name: z.string(), + isNew: z.boolean().optional(), + needsAttention: z.boolean().optional(), + initialCommands: z.array(z.string()).optional(), + initialCwd: z.string().optional(), + url: z.string().optional(), +}); + +/** + * Zod schema for MosaicNode (recursive tree structure for pane layouts) + */ +type MosaicNode = + | string + | { + direction: "row" | "column"; + first: MosaicNode; + second: MosaicNode; + splitPercentage?: number; + }; +const mosaicNodeSchema: z.ZodType = z.lazy(() => + z.union([ + z.string(), // Leaf node (paneId) + z.object({ + direction: z.enum(["row", "column"]), + first: mosaicNodeSchema, + second: mosaicNodeSchema, + splitPercentage: z.number().optional(), + }), + ]), +); + +/** + * Zod schema for Tab (extends BaseTab with layout) + */ +const tabSchema = z.object({ + id: z.string(), + name: z.string(), + userTitle: z.string().optional(), + workspaceId: z.string(), + createdAt: z.number(), + layout: mosaicNodeSchema, +}); + +/** + * Zod schema for TabsState + */ +const tabsStateSchema = z.object({ + tabs: z.array(tabSchema), + panes: z.record(z.string(), paneSchema), + activeTabIds: z.record(z.string(), z.string().nullable()), + focusedPaneIds: z.record(z.string(), z.string()), + tabHistoryStacks: z.record(z.string(), z.array(z.string())), +}); + +/** + * Zod schema for UI colors + */ +const uiColorsSchema = z.object({ + background: z.string(), + foreground: z.string(), + card: z.string(), + cardForeground: z.string(), + popover: z.string(), + popoverForeground: z.string(), + primary: z.string(), + primaryForeground: z.string(), + secondary: z.string(), + secondaryForeground: z.string(), + muted: z.string(), + mutedForeground: z.string(), + accent: z.string(), + accentForeground: z.string(), + tertiary: z.string(), + tertiaryActive: z.string(), + destructive: z.string(), + destructiveForeground: z.string(), + border: z.string(), + input: z.string(), + ring: z.string(), + sidebar: z.string(), + sidebarForeground: z.string(), + sidebarPrimary: z.string(), + sidebarPrimaryForeground: z.string(), + sidebarAccent: z.string(), + sidebarAccentForeground: z.string(), + sidebarBorder: z.string(), + sidebarRing: z.string(), + chart1: z.string(), + chart2: z.string(), + chart3: z.string(), + chart4: z.string(), + chart5: z.string(), +}); + +/** + * Zod schema for terminal colors + */ +const terminalColorsSchema = z.object({ + background: z.string(), + foreground: z.string(), + cursor: z.string(), + cursorAccent: z.string().optional(), + selectionBackground: z.string().optional(), + selectionForeground: z.string().optional(), + black: z.string(), + red: z.string(), + green: z.string(), + yellow: z.string(), + blue: z.string(), + magenta: z.string(), + cyan: z.string(), + white: z.string(), + brightBlack: z.string(), + brightRed: z.string(), + brightGreen: z.string(), + brightYellow: z.string(), + brightBlue: z.string(), + brightMagenta: z.string(), + brightCyan: z.string(), + brightWhite: z.string(), +}); + +/** + * Zod schema for Theme + */ +const themeSchema = z.object({ + id: z.string(), + name: z.string(), + author: z.string().optional(), + version: z.string().optional(), + description: z.string().optional(), + type: z.enum(["dark", "light"]), + ui: uiColorsSchema, + terminal: terminalColorsSchema, + isBuiltIn: z.boolean().optional(), + isCustom: z.boolean().optional(), +}); + +/** + * Zod schema for ThemeState + */ +const themeStateSchema = z.object({ + activeThemeId: z.string(), + customThemes: z.array(themeSchema), +}); + +/** + * UI State router - manages tabs and theme persistence via lowdb + */ +export const createUiStateRouter = () => { + return router({ + // Tabs state procedures + tabs: router({ + get: publicProcedure.query((): TabsState => { + return appState.data.tabsState; + }), + + set: publicProcedure + .input(tabsStateSchema) + .mutation(async ({ input }) => { + appState.data.tabsState = input; + await appState.write(); + return { success: true }; + }), + }), + + // Theme state procedures + theme: router({ + get: publicProcedure.query((): ThemeState => { + return appState.data.themeState; + }), + + set: publicProcedure + .input(themeStateSchema) + .mutation(async ({ input }) => { + appState.data.themeState = input; + await appState.write(); + return { success: true }; + }), + }), + }); +}; diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index e51743ad0ab..889f8122214 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -2,9 +2,9 @@ import path from "node:path"; import { app } from "electron"; import { makeAppSetup } from "lib/electron-app/factories/app/setup"; import { setupAgentHooks } from "./lib/agent-setup"; +import { initAppState } from "./lib/app-state"; import { setupAutoUpdater } from "./lib/auto-updater"; import { initDb } from "./lib/db"; -import { registerStorageHandlers } from "./lib/storage-ipcs"; import { terminalManager } from "./lib/terminal-manager"; import { MainWindow } from "./windows/main"; @@ -28,13 +28,12 @@ app.on("open-url", (event, _url) => { event.preventDefault(); }); -registerStorageHandlers(); - // Allow multiple instances - removed single instance lock (async () => { await app.whenReady(); await initDb(); + await initAppState(); try { setupAgentHooks(); diff --git a/apps/desktop/src/main/lib/agent-setup.ts b/apps/desktop/src/main/lib/agent-setup.ts index e4b5b131c65..d565ffb0c0e 100644 --- a/apps/desktop/src/main/lib/agent-setup.ts +++ b/apps/desktop/src/main/lib/agent-setup.ts @@ -2,7 +2,7 @@ import { execSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { PORTS, SUPERSET_DIR_NAME } from "shared/constants"; +import { PORTS, SUPERSET_DIR_NAME, SUPERSET_DIR_NAMES } from "shared/constants"; import { SUPERSET_HOME_DIR } from "./app-environment"; const BIN_DIR = path.join(SUPERSET_HOME_DIR, "bin"); @@ -11,18 +11,25 @@ const ZSH_DIR = path.join(SUPERSET_HOME_DIR, "zsh"); const BASH_DIR = path.join(SUPERSET_HOME_DIR, "bash"); /** - * Finds the real path of a binary, skipping our wrapper scripts + * Finds the real path of a binary, skipping our wrapper scripts. + * Filters out both dev and prod superset bin directories + * to avoid wrapper scripts calling each other. */ function findRealBinary(name: string): string | null { try { - // Get all paths, filter out our bin dir + // Get all paths, filter out both dev and prod superset bin dirs const result = execSync(`which -a ${name} 2>/dev/null || true`, { encoding: "utf-8", }); + const homedir = os.homedir(); + const supersetBinDirs = [ + path.join(homedir, SUPERSET_DIR_NAMES.PROD, "bin"), + path.join(homedir, SUPERSET_DIR_NAMES.DEV, "bin"), + ]; const paths = result .trim() .split("\n") - .filter((p) => p && !p.startsWith(BIN_DIR)); + .filter((p) => p && !supersetBinDirs.some((dir) => p.startsWith(dir))); return paths[0] || null; } catch { return null; @@ -62,9 +69,8 @@ fi [ -z "$EVENT_TYPE" ] && EVENT_TYPE="Stop" curl -sG "http://127.0.0.1:\${SUPERSET_PORT:-${PORTS.NOTIFICATIONS}}/hook/complete" \\ + --data-urlencode "paneId=$SUPERSET_PANE_ID" \\ --data-urlencode "tabId=$SUPERSET_TAB_ID" \\ - --data-urlencode "tabTitle=$SUPERSET_TAB_TITLE" \\ - --data-urlencode "workspaceName=$SUPERSET_WORKSPACE_NAME" \\ --data-urlencode "workspaceId=$SUPERSET_WORKSPACE_ID" \\ --data-urlencode "eventType=$EVENT_TYPE" \\ > /dev/null 2>&1 diff --git a/apps/desktop/src/main/lib/app-environment.ts b/apps/desktop/src/main/lib/app-environment.ts index fd490dbdc0b..e510d810fbd 100644 --- a/apps/desktop/src/main/lib/app-environment.ts +++ b/apps/desktop/src/main/lib/app-environment.ts @@ -9,3 +9,4 @@ export const SUPERSET_HOME_DIR = join(homedir(), SUPERSET_DIR_NAME); // For lowdb - use our own path instead of app.getPath("userData") export const DB_PATH = join(SUPERSET_HOME_DIR, "db.json"); +export const APP_STATE_PATH = join(SUPERSET_HOME_DIR, "app-state.json"); diff --git a/apps/desktop/src/main/lib/app-state/index.ts b/apps/desktop/src/main/lib/app-state/index.ts new file mode 100644 index 00000000000..e736a541d23 --- /dev/null +++ b/apps/desktop/src/main/lib/app-state/index.ts @@ -0,0 +1,46 @@ +import { JSONFilePreset } from "lowdb/node"; +import { APP_STATE_PATH } from "../app-environment"; +import type { AppState } from "./schemas"; +import { defaultAppState } from "./schemas"; + +type AppStateDB = Awaited>>; + +let _appState: AppStateDB | null = null; + +/** + * Ensures loaded data has the correct shape by merging with defaults. + * Handles legacy app-state.json files that may have a different structure + * (e.g., from old electron-store format with keys like "tabs-storage"). + */ +function ensureValidShape(data: Partial): AppState { + return { + tabsState: { + ...defaultAppState.tabsState, + ...(data.tabsState ?? {}), + }, + themeState: { + ...defaultAppState.themeState, + ...(data.themeState ?? {}), + }, + }; +} + +export async function initAppState(): Promise { + if (_appState) return; + + _appState = await JSONFilePreset(APP_STATE_PATH, defaultAppState); + + // Reshape data to ensure it has the correct structure (handles legacy formats) + _appState.data = ensureValidShape(_appState.data); + + console.log(`App state initialized at: ${APP_STATE_PATH}`); +} + +export const appState = new Proxy({} as AppStateDB, { + get(_target, prop) { + if (!_appState) { + throw new Error("App state not initialized. Call initAppState() first."); + } + return _appState[prop as keyof AppStateDB]; + }, +}); diff --git a/apps/desktop/src/main/lib/app-state/schemas.ts b/apps/desktop/src/main/lib/app-state/schemas.ts new file mode 100644 index 00000000000..90c3e7138b4 --- /dev/null +++ b/apps/desktop/src/main/lib/app-state/schemas.ts @@ -0,0 +1,32 @@ +/** + * UI state schemas (persisted from renderer zustand stores) + */ +import type { BaseTabsState } from "shared/tabs-types"; +import type { Theme } from "shared/themes"; + +// Re-export for convenience +export type { BaseTabsState as TabsState, Pane } from "shared/tabs-types"; + +export interface ThemeState { + activeThemeId: string; + customThemes: Theme[]; +} + +export interface AppState { + tabsState: BaseTabsState; + themeState: ThemeState; +} + +export const defaultAppState: AppState = { + tabsState: { + tabs: [], + panes: {}, + activeTabIds: {}, + focusedPaneIds: {}, + tabHistoryStacks: {}, + }, + themeState: { + activeThemeId: "dark", + customThemes: [], + }, +}; diff --git a/apps/desktop/src/main/lib/notifications/server.ts b/apps/desktop/src/main/lib/notifications/server.ts index 8cacbc4b507..a0ffd099835 100644 --- a/apps/desktop/src/main/lib/notifications/server.ts +++ b/apps/desktop/src/main/lib/notifications/server.ts @@ -2,10 +2,9 @@ import { EventEmitter } from "node:events"; import express from "express"; export interface AgentCompleteEvent { - tabId: string; - tabTitle: string; - workspaceName: string; - workspaceId: string; + paneId?: string; + tabId?: string; + workspaceId?: string; eventType: "Stop" | "PermissionRequest"; } @@ -25,23 +24,18 @@ app.use((req, res, next) => { // Agent completion hook app.get("/hook/complete", (req, res) => { - const { tabId, tabTitle, workspaceName, workspaceId, eventType } = req.query; - - if (!tabId || typeof tabId !== "string") { - return res.status(400).json({ error: "Missing tabId parameter" }); - } + const { paneId, tabId, workspaceId, eventType } = req.query; const event: AgentCompleteEvent = { - tabId, - tabTitle: (tabTitle as string) || "Terminal", - workspaceName: (workspaceName as string) || "Workspace", - workspaceId: (workspaceId as string) || "", + paneId: paneId as string | undefined, + tabId: tabId as string | undefined, + workspaceId: workspaceId as string | undefined, eventType: eventType === "PermissionRequest" ? "PermissionRequest" : "Stop", }; notificationsEmitter.emit("agent-complete", event); - res.json({ success: true, tabId }); + res.json({ success: true, paneId, tabId }); }); // Health check diff --git a/apps/desktop/src/main/lib/storage-ipcs.ts b/apps/desktop/src/main/lib/storage-ipcs.ts deleted file mode 100644 index 749985abd0a..00000000000 --- a/apps/desktop/src/main/lib/storage-ipcs.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ipcMain } from "electron"; -import { store } from "./storage-manager"; - -/** - * Register storage IPC handlers - * These handlers provide access to electron-store from the renderer process - */ -export function registerStorageHandlers() { - ipcMain.handle("storage:get", async (_event, input: { key: string }) => { - return store.get(input.key); - }); - - ipcMain.handle( - "storage:set", - async (_event, input: { key: string; value: unknown }) => { - store.set(input.key, input.value); - }, - ); - - ipcMain.handle("storage:delete", async (_event, input: { key: string }) => { - store.delete(input.key); - }); -} diff --git a/apps/desktop/src/main/lib/storage-manager.ts b/apps/desktop/src/main/lib/storage-manager.ts deleted file mode 100644 index 750d898da57..00000000000 --- a/apps/desktop/src/main/lib/storage-manager.ts +++ /dev/null @@ -1,11 +0,0 @@ -import Store from "electron-store"; -import { SUPERSET_HOME_DIR } from "./app-environment"; - -/** - * Electron store instance for persisting application state - * Stores data in ~/.superset/app-state.json (prod) or ~/.superset-dev/app-state.json (dev) - */ -export const store = new Store({ - cwd: SUPERSET_HOME_DIR, - name: "app-state", -}); diff --git a/apps/desktop/src/main/lib/terminal-history.ts b/apps/desktop/src/main/lib/terminal-history.ts index ad06976b603..292a7aea7b6 100644 --- a/apps/desktop/src/main/lib/terminal-history.ts +++ b/apps/desktop/src/main/lib/terminal-history.ts @@ -12,19 +12,19 @@ export interface SessionMetadata { exitCode?: number; } -export function getHistoryDir(workspaceId: string, tabId: string): string { +export function getHistoryDir(workspaceId: string, paneId: string): string { const baseDir = IS_TEST ? join(tmpdir(), "superset-test", ".superset") : SUPERSET_HOME_DIR; - return join(baseDir, "terminal-history", workspaceId, tabId); + return join(baseDir, "terminal-history", workspaceId, paneId); } -function getHistoryFilePath(workspaceId: string, tabId: string): string { - return join(getHistoryDir(workspaceId, tabId), "scrollback.bin"); +function getHistoryFilePath(workspaceId: string, paneId: string): string { + return join(getHistoryDir(workspaceId, paneId), "scrollback.bin"); } -function getMetadataPath(workspaceId: string, tabId: string): string { - return join(getHistoryDir(workspaceId, tabId), "meta.json"); +function getMetadataPath(workspaceId: string, paneId: string): string { + return join(getHistoryDir(workspaceId, paneId), "meta.json"); } export class HistoryWriter { @@ -36,13 +36,13 @@ export class HistoryWriter { constructor( private workspaceId: string, - private tabId: string, + private paneId: string, cwd: string, cols: number, rows: number, ) { - this.filePath = getHistoryFilePath(workspaceId, tabId); - this.metaPath = getMetadataPath(workspaceId, tabId); + this.filePath = getHistoryFilePath(workspaceId, paneId); + this.metaPath = getMetadataPath(workspaceId, paneId); this.metadata = { cwd, cols, @@ -52,7 +52,7 @@ export class HistoryWriter { } async init(initialScrollback?: string): Promise { - const dir = getHistoryDir(this.workspaceId, this.tabId); + const dir = getHistoryDir(this.workspaceId, this.paneId); await fs.mkdir(dir, { recursive: true }); // Write initial scrollback (recovered from previous session) or truncate @@ -107,19 +107,19 @@ export class HistoryWriter { export class HistoryReader { constructor( private workspaceId: string, - private tabId: string, + private paneId: string, ) {} async read(): Promise<{ scrollback: string; metadata?: SessionMetadata }> { try { - const filePath = getHistoryFilePath(this.workspaceId, this.tabId); + const filePath = getHistoryFilePath(this.workspaceId, this.paneId); // Read as UTF-8 to match how node-pty produces terminal output // The file is stored as raw bytes from UTF-8 encoded strings const scrollback = await fs.readFile(filePath, "utf8"); let metadata: SessionMetadata | undefined; try { - const metaPath = getMetadataPath(this.workspaceId, this.tabId); + const metaPath = getMetadataPath(this.workspaceId, this.paneId); const metaContent = await fs.readFile(metaPath, "utf-8"); metadata = JSON.parse(metaContent); } catch { @@ -134,7 +134,7 @@ export class HistoryReader { async cleanup(): Promise { try { - const dir = getHistoryDir(this.workspaceId, this.tabId); + const dir = getHistoryDir(this.workspaceId, this.paneId); await fs.rm(dir, { recursive: true, force: true }); } catch (error) { console.error("Failed to cleanup history:", error); diff --git a/apps/desktop/src/main/lib/terminal-manager.test.ts b/apps/desktop/src/main/lib/terminal-manager.test.ts index 94e1d4e45c1..9bec1e72c90 100644 --- a/apps/desktop/src/main/lib/terminal-manager.test.ts +++ b/apps/desktop/src/main/lib/terminal-manager.test.ts @@ -79,10 +79,9 @@ describe("TerminalManager", () => { describe("createOrAttach", () => { it("should create a new terminal session", async () => { const result = await manager.createOrAttach({ + paneId: "pane-1", tabId: "tab-1", workspaceId: "workspace-1", - tabTitle: "Test Tab", - workspaceName: "Test Workspace", cwd: "/test/path", cols: 80, rows: 24, @@ -104,10 +103,9 @@ describe("TerminalManager", () => { it("should reuse existing terminal session", async () => { await manager.createOrAttach({ + paneId: "pane-1", tabId: "tab-1", workspaceId: "workspace-1", - tabTitle: "Test Tab", - workspaceName: "Test Workspace", cwd: "/test/path", }); @@ -115,10 +113,9 @@ describe("TerminalManager", () => { .length; const result = await manager.createOrAttach({ + paneId: "pane-1", tabId: "tab-1", workspaceId: "workspace-1", - tabTitle: "Test Tab", - workspaceName: "Test Workspace", }); expect(result.isNew).toBe(false); @@ -130,19 +127,17 @@ describe("TerminalManager", () => { it("should update size when reattaching with new dimensions", async () => { await manager.createOrAttach({ + paneId: "pane-1", tabId: "tab-1", workspaceId: "workspace-1", - tabTitle: "Test Tab", - workspaceName: "Test Workspace", cols: 80, rows: 24, }); await manager.createOrAttach({ + paneId: "pane-1", tabId: "tab-1", workspaceId: "workspace-1", - tabTitle: "Test Tab", - workspaceName: "Test Workspace", cols: 100, rows: 30, }); @@ -152,18 +147,17 @@ describe("TerminalManager", () => { it("should filter recovered scrollback from history", async () => { const workspaceId = "workspace-1"; - const tabId = "tab-recover"; - const historyDir = getHistoryDir(workspaceId, tabId); + const paneId = "pane-recover"; + const historyDir = getHistoryDir(workspaceId, paneId); await fs.mkdir(historyDir, { recursive: true }); const ESC = "\x1b"; const rawScrollback = `before${ESC}[1;1Rafter${ESC}[?1;0c`; await fs.writeFile(join(historyDir, "scrollback.bin"), rawScrollback); const result = await manager.createOrAttach({ - tabId, + paneId, + tabId: "tab-recover", workspaceId, - tabTitle: "Test Tab", - workspaceName: "Test Workspace", }); expect(result.wasRecovered).toBe(true); @@ -174,14 +168,13 @@ describe("TerminalManager", () => { describe("write", () => { it("should write data to terminal", async () => { await manager.createOrAttach({ + paneId: "pane-1", tabId: "tab-1", workspaceId: "workspace-1", - tabTitle: "Test Tab", - workspaceName: "Test Workspace", }); manager.write({ - tabId: "tab-1", + paneId: "pane-1", data: "ls -la\n", }); @@ -191,7 +184,7 @@ describe("TerminalManager", () => { it("should throw error for non-existent session", () => { expect(() => { manager.write({ - tabId: "non-existent", + paneId: "non-existent", data: "test", }); }).toThrow("Terminal session non-existent not found or not alive"); @@ -201,14 +194,13 @@ describe("TerminalManager", () => { describe("resize", () => { it("should resize terminal", async () => { await manager.createOrAttach({ + paneId: "pane-1", tabId: "tab-1", workspaceId: "workspace-1", - tabTitle: "Test Tab", - workspaceName: "Test Workspace", }); manager.resize({ - tabId: "tab-1", + paneId: "pane-1", cols: 120, rows: 40, }); @@ -225,7 +217,7 @@ describe("TerminalManager", () => { // Should not throw expect(() => { manager.resize({ - tabId: "non-existent", + paneId: "non-existent", cols: 80, rows: 24, }); @@ -243,14 +235,13 @@ describe("TerminalManager", () => { describe("signal", () => { it("should send signal to terminal", async () => { await manager.createOrAttach({ + paneId: "pane-1", tabId: "tab-1", workspaceId: "workspace-1", - tabTitle: "Test Tab", - workspaceName: "Test Workspace", }); manager.signal({ - tabId: "tab-1", + paneId: "pane-1", signal: "SIGINT", }); @@ -259,14 +250,13 @@ describe("TerminalManager", () => { it("should use SIGTERM by default", async () => { await manager.createOrAttach({ + paneId: "pane-1", tabId: "tab-1", workspaceId: "workspace-1", - tabTitle: "Test Tab", - workspaceName: "Test Workspace", }); manager.signal({ - tabId: "tab-1", + paneId: "pane-1", }); expect(mockPty.kill).toHaveBeenCalledWith("SIGTERM"); @@ -276,10 +266,9 @@ describe("TerminalManager", () => { describe("kill", () => { it("should kill and preserve history by default", async () => { await manager.createOrAttach({ + paneId: "pane-1", tabId: "tab-1", workspaceId: "workspace-1", - tabTitle: "Test Tab", - workspaceName: "Test Workspace", }); // Trigger some data to create history @@ -290,10 +279,10 @@ describe("TerminalManager", () => { } const exitPromise = new Promise((resolve) => { - manager.once("exit:tab-1", () => resolve()); + manager.once("exit:pane-1", () => resolve()); }); - await manager.kill({ tabId: "tab-1" }); + await manager.kill({ paneId: "pane-1" }); expect(mockPty.kill).toHaveBeenCalled(); @@ -308,7 +297,7 @@ describe("TerminalManager", () => { // Verify history directory still exists (preserved) const historyDir = join( testTmpDir, - ".superset/terminal-history/workspace-1/tab-1", + ".superset/terminal-history/workspace-1/pane-1", ); const stats = await fs.stat(historyDir); expect(stats.isDirectory()).toBe(true); @@ -316,10 +305,9 @@ describe("TerminalManager", () => { it("should delete history when deleteHistory flag is true", async () => { await manager.createOrAttach({ + paneId: "pane-delete-history", tabId: "tab-delete-history", workspaceId: "workspace-1", - tabTitle: "Test Tab", - workspaceName: "Test Workspace", }); // Trigger some data to create history @@ -330,10 +318,13 @@ describe("TerminalManager", () => { } const exitPromise = new Promise((resolve) => { - manager.once("exit:tab-delete-history", () => resolve()); + manager.once("exit:pane-delete-history", () => resolve()); }); - await manager.kill({ tabId: "tab-delete-history", deleteHistory: true }); + await manager.kill({ + paneId: "pane-delete-history", + deleteHistory: true, + }); expect(mockPty.kill).toHaveBeenCalled(); @@ -348,7 +339,7 @@ describe("TerminalManager", () => { // Verify history directory was deleted const historyDir = join( testTmpDir, - ".superset/terminal-history/workspace-1/tab-delete-history", + ".superset/terminal-history/workspace-1/pane-delete-history", ); const exists = await fs .stat(historyDir) @@ -360,10 +351,9 @@ describe("TerminalManager", () => { it("should preserve history for recovery after kill without deleteHistory", async () => { // Create and write some data await manager.createOrAttach({ + paneId: "pane-preserve", tabId: "tab-preserve", workspaceId: "workspace-1", - tabTitle: "Test Tab", - workspaceName: "Test Workspace", }); const onDataCallback = @@ -373,10 +363,10 @@ describe("TerminalManager", () => { } const exitPromise = new Promise((resolve) => { - manager.once("exit:tab-preserve", () => resolve()); + manager.once("exit:pane-preserve", () => resolve()); }); - await manager.kill({ tabId: "tab-preserve" }); + await manager.kill({ paneId: "pane-preserve" }); const onExitCallback = mockPty.onExit.mock.calls[mockPty.onExit.mock.calls.length - 1]?.[0]; @@ -388,10 +378,9 @@ describe("TerminalManager", () => { // Recreate session - should recover history from filesystem const result = await manager.createOrAttach({ + paneId: "pane-preserve", tabId: "tab-preserve", workspaceId: "workspace-1", - tabTitle: "Test Tab", - workspaceName: "Test Workspace", }); expect(result.wasRecovered).toBe(true); @@ -402,15 +391,14 @@ describe("TerminalManager", () => { describe("detach", () => { it("should keep session alive after detach", async () => { await manager.createOrAttach({ + paneId: "pane-1", tabId: "tab-1", workspaceId: "workspace-1", - tabTitle: "Test Tab", - workspaceName: "Test Workspace", }); - manager.detach({ tabId: "tab-1" }); + manager.detach({ paneId: "pane-1" }); - const session = manager.getSession("tab-1"); + const session = manager.getSession("pane-1"); expect(session).not.toBeNull(); expect(session?.isAlive).toBe(true); }); @@ -419,14 +407,13 @@ describe("TerminalManager", () => { describe("getSession", () => { it("should return session metadata", async () => { await manager.createOrAttach({ + paneId: "pane-1", tabId: "tab-1", workspaceId: "workspace-1", - tabTitle: "Test Tab", - workspaceName: "Test Workspace", cwd: "/test/path", }); - const session = manager.getSession("tab-1"); + const session = manager.getSession("pane-1"); expect(session).toMatchObject({ isAlive: true, @@ -444,17 +431,15 @@ describe("TerminalManager", () => { describe("cleanup", () => { it("should kill all sessions and wait for exit handlers", async () => { await manager.createOrAttach({ + paneId: "pane-1", tabId: "tab-1", workspaceId: "workspace-1", - tabTitle: "Test Tab 1", - workspaceName: "Test Workspace", }); await manager.createOrAttach({ + paneId: "pane-2", tabId: "tab-2", workspaceId: "workspace-1", - tabTitle: "Test Tab 2", - workspaceName: "Test Workspace", }); const cleanupPromise = manager.cleanup(); @@ -476,10 +461,9 @@ describe("TerminalManager", () => { it("should preserve history during cleanup", async () => { await manager.createOrAttach({ + paneId: "pane-cleanup", tabId: "tab-cleanup", workspaceId: "workspace-1", - tabTitle: "Test Tab", - workspaceName: "Test Workspace", }); const onDataCallback = @@ -501,7 +485,7 @@ describe("TerminalManager", () => { // Verify history was preserved (directory still exists) const historyDir = join( testTmpDir, - ".superset/terminal-history/workspace-1/tab-cleanup", + ".superset/terminal-history/workspace-1/pane-cleanup", ); const stats = await fs.stat(historyDir); expect(stats.isDirectory()).toBe(true); @@ -513,13 +497,12 @@ describe("TerminalManager", () => { const dataHandler = mock(() => {}); await manager.createOrAttach({ + paneId: "pane-1", tabId: "tab-1", workspaceId: "workspace-1", - tabTitle: "Test Tab", - workspaceName: "Test Workspace", }); - manager.on("data:tab-1", dataHandler); + manager.on("data:pane-1", dataHandler); const onDataCallback = mockPty.onData.mock.results[0]?.value; if (onDataCallback) { @@ -533,13 +516,12 @@ describe("TerminalManager", () => { const dataHandler = mock(() => {}); await manager.createOrAttach({ + paneId: "pane-raw", tabId: "tab-raw", workspaceId: "workspace-1", - tabTitle: "Test Tab", - workspaceName: "Test Workspace", }); - manager.on("data:tab-raw", dataHandler); + manager.on("data:pane-raw", dataHandler); const onDataCallback = mockPty.onData.mock.results[0]?.value; const dataWithEscapes = @@ -556,18 +538,17 @@ describe("TerminalManager", () => { const exitHandler = mock(() => {}); await manager.createOrAttach({ + paneId: "pane-1", tabId: "tab-1", workspaceId: "workspace-1", - tabTitle: "Test Tab", - workspaceName: "Test Workspace", }); // Listen for exit event const exitPromise = new Promise((resolve) => { - manager.once("exit:tab-1", () => resolve()); + manager.once("exit:pane-1", () => resolve()); }); - manager.on("exit:tab-1", exitHandler); + manager.on("exit:pane-1", exitHandler); const onExitCallback = mockPty.onExit.mock.results[0]?.value; if (onExitCallback) { @@ -583,10 +564,9 @@ describe("TerminalManager", () => { describe("killByWorkspaceId", () => { it("should kill session for a workspace and return count", async () => { await manager.createOrAttach({ + paneId: "pane-kill-single", tabId: "tab-kill-single", workspaceId: "workspace-kill-single", - tabTitle: "Test Tab", - workspaceName: "Test Workspace", }); const result = await manager.killByWorkspaceId("workspace-kill-single"); @@ -597,17 +577,16 @@ describe("TerminalManager", () => { it("should not kill sessions from other workspaces", async () => { await manager.createOrAttach({ + paneId: "pane-other", tabId: "tab-other", workspaceId: "workspace-other", - tabTitle: "Test Tab", - workspaceName: "Other Workspace", }); await manager.killByWorkspaceId("workspace-different"); // Session should still exist - expect(manager.getSession("tab-other")).not.toBeNull(); - expect(manager.getSession("tab-other")?.isAlive).toBe(true); + expect(manager.getSession("pane-other")).not.toBeNull(); + expect(manager.getSession("pane-other")?.isAlive).toBe(true); }); it("should return zero counts for non-existent workspace", async () => { @@ -619,10 +598,9 @@ describe("TerminalManager", () => { it("should delete history for killed sessions", async () => { await manager.createOrAttach({ + paneId: "pane-kill-history", tabId: "tab-kill-history", workspaceId: "workspace-kill", - tabTitle: "Test Tab", - workspaceName: "Test Workspace", }); // Trigger some data to create history @@ -640,7 +618,7 @@ describe("TerminalManager", () => { // Verify history directory was deleted const historyDir = join( testTmpDir, - ".superset/terminal-history/workspace-kill/tab-kill-history", + ".superset/terminal-history/workspace-kill/pane-kill-history", ); const exists = await fs .stat(historyDir) @@ -651,10 +629,9 @@ describe("TerminalManager", () => { it("should clean up already-dead sessions", async () => { await manager.createOrAttach({ + paneId: "pane-dead", tabId: "tab-dead", workspaceId: "workspace-dead", - tabTitle: "Test Tab", - workspaceName: "Test Workspace", }); // Simulate the session dying naturally @@ -677,24 +654,21 @@ describe("TerminalManager", () => { describe("getSessionCountByWorkspaceId", () => { it("should return count of active sessions for workspace", async () => { await manager.createOrAttach({ + paneId: "pane-1", tabId: "tab-1", workspaceId: "workspace-count", - tabTitle: "Test Tab 1", - workspaceName: "Test Workspace", }); await manager.createOrAttach({ + paneId: "pane-2", tabId: "tab-2", workspaceId: "workspace-count", - tabTitle: "Test Tab 2", - workspaceName: "Test Workspace", }); await manager.createOrAttach({ + paneId: "pane-3", tabId: "tab-3", workspaceId: "other-workspace", - tabTitle: "Test Tab 3", - workspaceName: "Other Workspace", }); expect(manager.getSessionCountByWorkspaceId("workspace-count")).toBe(2); @@ -707,17 +681,15 @@ describe("TerminalManager", () => { it("should not count dead sessions", async () => { await manager.createOrAttach({ + paneId: "pane-alive", tabId: "tab-alive", workspaceId: "workspace-mixed", - tabTitle: "Test Tab Alive", - workspaceName: "Test Workspace", }); await manager.createOrAttach({ + paneId: "pane-dead", tabId: "tab-dead", workspaceId: "workspace-mixed", - tabTitle: "Test Tab Dead", - workspaceName: "Test Workspace", }); // Simulate the second session dying @@ -737,10 +709,9 @@ describe("TerminalManager", () => { describe("clearScrollback", () => { it("should clear in-memory scrollback", async () => { await manager.createOrAttach({ + paneId: "pane-clear", tabId: "tab-clear", workspaceId: "workspace-1", - tabTitle: "Test Tab", - workspaceName: "Test Workspace", }); const onDataCallback = @@ -749,13 +720,12 @@ describe("TerminalManager", () => { onDataCallback("some output\n"); } - await manager.clearScrollback({ tabId: "tab-clear" }); + await manager.clearScrollback({ paneId: "pane-clear" }); const result = await manager.createOrAttach({ + paneId: "pane-clear", tabId: "tab-clear", workspaceId: "workspace-1", - tabTitle: "Test Tab", - workspaceName: "Test Workspace", }); expect(result.scrollback).toBe(""); @@ -763,10 +733,9 @@ describe("TerminalManager", () => { it("should reinitialize history file", async () => { await manager.createOrAttach({ + paneId: "pane-clear-history", tabId: "tab-clear-history", workspaceId: "workspace-clear", - tabTitle: "Test Tab", - workspaceName: "Test Workspace", }); const onDataCallback = @@ -775,7 +744,7 @@ describe("TerminalManager", () => { onDataCallback("output before clear\n"); } - await manager.clearScrollback({ tabId: "tab-clear-history" }); + await manager.clearScrollback({ paneId: "pane-clear-history" }); const onExitCallback = mockPty.onExit.mock.calls[mockPty.onExit.mock.calls.length - 1]?.[0]; @@ -786,10 +755,9 @@ describe("TerminalManager", () => { await manager.cleanup(); const result = await manager.createOrAttach({ + paneId: "pane-clear-history", tabId: "tab-clear-history", workspaceId: "workspace-clear", - tabTitle: "Test Tab", - workspaceName: "Test Workspace", }); expect(result.scrollback).toBe(""); @@ -802,7 +770,7 @@ describe("TerminalManager", () => { console.warn = warnSpy; await expect( - manager.clearScrollback({ tabId: "non-existent" }), + manager.clearScrollback({ paneId: "non-existent" }), ).resolves.toBeUndefined(); expect(warnSpy).toHaveBeenCalledWith( @@ -814,10 +782,9 @@ describe("TerminalManager", () => { it("should clear scrollback when shell sends clear sequence", async () => { await manager.createOrAttach({ + paneId: "pane-shell-clear", tabId: "tab-shell-clear", workspaceId: "workspace-1", - tabTitle: "Test Tab", - workspaceName: "Test Workspace", }); const onDataCallback = @@ -829,10 +796,9 @@ describe("TerminalManager", () => { } const result = await manager.createOrAttach({ + paneId: "pane-shell-clear", tabId: "tab-shell-clear", workspaceId: "workspace-1", - tabTitle: "Test Tab", - workspaceName: "Test Workspace", }); // Only content after the clear sequence should remain @@ -845,10 +811,9 @@ describe("TerminalManager", () => { it("should persist history across multiple sessions", async () => { // Session 1: Create and write data const result1 = await manager.createOrAttach({ + paneId: "pane-multi", tabId: "tab-multi", workspaceId: "workspace-1", - tabTitle: "Test Tab", - workspaceName: "Test Workspace", }); expect(result1.isNew).toBe(true); @@ -861,7 +826,7 @@ describe("TerminalManager", () => { } const exitPromise1 = new Promise((resolve) => { - manager.once("exit:tab-multi", () => resolve()); + manager.once("exit:pane-multi", () => resolve()); }); const onExitCallback1 = @@ -875,10 +840,9 @@ describe("TerminalManager", () => { // Session 2: Should recover Session 1 data const result2 = await manager.createOrAttach({ + paneId: "pane-multi", tabId: "tab-multi", workspaceId: "workspace-1", - tabTitle: "Test Tab", - workspaceName: "Test Workspace", }); expect(result2.isNew).toBe(true); @@ -892,7 +856,7 @@ describe("TerminalManager", () => { } const exitPromise2 = new Promise((resolve) => { - manager.once("exit:tab-multi", () => resolve()); + manager.once("exit:pane-multi", () => resolve()); }); const onExitCallback2 = @@ -906,10 +870,9 @@ describe("TerminalManager", () => { // Session 3: Should recover both Session 1 and Session 2 data const result3 = await manager.createOrAttach({ + paneId: "pane-multi", tabId: "tab-multi", workspaceId: "workspace-1", - tabTitle: "Test Tab", - workspaceName: "Test Workspace", }); expect(result3.isNew).toBe(true); diff --git a/apps/desktop/src/main/lib/terminal-manager.ts b/apps/desktop/src/main/lib/terminal-manager.ts index 6a64e7c0346..f432d746278 100644 --- a/apps/desktop/src/main/lib/terminal-manager.ts +++ b/apps/desktop/src/main/lib/terminal-manager.ts @@ -11,7 +11,7 @@ import { HistoryReader, HistoryWriter } from "./terminal-history"; interface TerminalSession { pty: pty.IPty; - tabId: string; + paneId: string; workspaceId: string; cwd: string; cols: number; @@ -48,11 +48,9 @@ export class TerminalManager extends EventEmitter { private readonly DEFAULT_ROWS = 24; async createOrAttach(params: { + paneId: string; tabId: string; workspaceId: string; - tabTitle: string; - workspaceName: string; - rootPath?: string; cwd?: string; cols?: number; rows?: number; @@ -62,29 +60,20 @@ export class TerminalManager extends EventEmitter { scrollback: string; wasRecovered: boolean; }> { - const { - tabId, - workspaceId, - tabTitle, - workspaceName, - rootPath, - cwd, - cols, - rows, - initialCommands, - } = params; + const { paneId, tabId, workspaceId, cwd, cols, rows, initialCommands } = + params; - // Deduplicate concurrent calls for the same tabId (prevents race in React Strict Mode) - const pending = this.pendingSessions.get(tabId); + // Deduplicate concurrent calls for the same paneId (prevents race in React Strict Mode) + const pending = this.pendingSessions.get(paneId); if (pending) { return pending; } - const existing = this.sessions.get(tabId); + const existing = this.sessions.get(paneId); if (existing?.isAlive) { existing.lastActive = Date.now(); if (cols !== undefined && rows !== undefined) { - this.resize({ tabId, cols, rows }); + this.resize({ paneId, cols, rows }); } return { isNew: false, @@ -95,32 +84,28 @@ export class TerminalManager extends EventEmitter { // Track this creation to prevent duplicate sessions from concurrent calls const creationPromise = this.doCreateSession({ + paneId, tabId, workspaceId, - tabTitle, - workspaceName, - rootPath, cwd, cols, rows, initialCommands, existingScrollback: existing?.scrollback || null, }); - this.pendingSessions.set(tabId, creationPromise); + this.pendingSessions.set(paneId, creationPromise); try { return await creationPromise; } finally { - this.pendingSessions.delete(tabId); + this.pendingSessions.delete(paneId); } } private async doCreateSession(params: { + paneId: string; tabId: string; workspaceId: string; - tabTitle: string; - workspaceName: string; - rootPath?: string; cwd?: string; cols?: number; rows?: number; @@ -132,11 +117,9 @@ export class TerminalManager extends EventEmitter { wasRecovered: boolean; }> { const { + paneId, tabId, workspaceId, - tabTitle, - workspaceName, - rootPath, cwd, cols, rows, @@ -154,12 +137,9 @@ export class TerminalManager extends EventEmitter { const env = { ...baseEnv, ...shellEnv, + SUPERSET_PANE_ID: paneId, SUPERSET_TAB_ID: tabId, - SUPERSET_TAB_TITLE: tabTitle, - SUPERSET_WORKSPACE_NAME: workspaceName, SUPERSET_WORKSPACE_ID: workspaceId, - SUPERSET_WORKSPACE_PATH: workingDir, - SUPERSET_ROOT_PATH: rootPath || "", SUPERSET_PORT: String(PORTS.NOTIFICATIONS), }; @@ -170,7 +150,7 @@ export class TerminalManager extends EventEmitter { recoveredScrollback = existingScrollback; wasRecovered = true; } else { - const historyReader = new HistoryReader(workspaceId, tabId); + const historyReader = new HistoryReader(workspaceId, paneId); const history = await historyReader.read(); if (history.scrollback) { recoveredScrollback = history.scrollback; @@ -198,7 +178,7 @@ export class TerminalManager extends EventEmitter { // Initialize history writer with recovered scrollback const historyWriter = new HistoryWriter( workspaceId, - tabId, + paneId, workingDir, terminalCols, terminalRows, @@ -207,7 +187,7 @@ export class TerminalManager extends EventEmitter { const session: TerminalSession = { pty: ptyProcess, - tabId, + paneId, workspaceId, cwd: workingDir, cols: terminalCols, @@ -236,7 +216,8 @@ export class TerminalManager extends EventEmitter { const filteredData = session.escapeFilter.filter(data); session.scrollback += filteredData; session.historyWriter?.write(filteredData); - this.emit(`data:${tabId}`, data); + // Emit ORIGINAL data to xterm - it needs to process query responses + this.emit(`data:${paneId}`, data); if (shouldRunCommands && !commandsSent) { commandsSent = true; @@ -260,15 +241,15 @@ export class TerminalManager extends EventEmitter { } await this.closeHistory(session, exitCode); - this.emit(`exit:${tabId}`, exitCode, signal); + this.emit(`exit:${paneId}`, exitCode, signal); const timeout = setTimeout(() => { - this.sessions.delete(tabId); + this.sessions.delete(paneId); }, 5000); timeout.unref(); }); - this.sessions.set(tabId, session); + this.sessions.set(paneId, session); return { isNew: true, @@ -277,12 +258,12 @@ export class TerminalManager extends EventEmitter { }; } - write(params: { tabId: string; data: string }): void { - const { tabId, data } = params; - const session = this.sessions.get(tabId); + write(params: { paneId: string; data: string }): void { + const { paneId, data } = params; + const session = this.sessions.get(paneId); if (!session || !session.isAlive) { - throw new Error(`Terminal session ${tabId} not found or not alive`); + throw new Error(`Terminal session ${paneId} not found or not alive`); } session.pty.write(data); @@ -290,17 +271,17 @@ export class TerminalManager extends EventEmitter { } resize(params: { - tabId: string; + paneId: string; cols: number; rows: number; seq?: number; }): void { - const { tabId, cols, rows } = params; - const session = this.sessions.get(tabId); + const { paneId, cols, rows } = params; + const session = this.sessions.get(paneId); if (!session || !session.isAlive) { console.warn( - `Cannot resize terminal ${tabId}: session not found or not alive`, + `Cannot resize terminal ${paneId}: session not found or not alive`, ); return; } @@ -311,13 +292,13 @@ export class TerminalManager extends EventEmitter { session.lastActive = Date.now(); } - signal(params: { tabId: string; signal?: string }): void { - const { tabId, signal = "SIGTERM" } = params; - const session = this.sessions.get(tabId); + signal(params: { paneId: string; signal?: string }): void { + const { paneId, signal = "SIGTERM" } = params; + const session = this.sessions.get(paneId); if (!session || !session.isAlive) { console.warn( - `Cannot signal terminal ${tabId}: session not found or not alive`, + `Cannot signal terminal ${paneId}: session not found or not alive`, ); return; } @@ -327,14 +308,14 @@ export class TerminalManager extends EventEmitter { } async kill(params: { - tabId: string; + paneId: string; deleteHistory?: boolean; }): Promise { - const { tabId, deleteHistory = false } = params; - const session = this.sessions.get(tabId); + const { paneId, deleteHistory = false } = params; + const session = this.sessions.get(paneId); if (!session) { - console.warn(`Cannot kill terminal ${tabId}: session not found`); + console.warn(`Cannot kill terminal ${paneId}: session not found`); return; } @@ -346,29 +327,29 @@ export class TerminalManager extends EventEmitter { session.pty.kill(); } else { await this.closeHistory(session); - this.sessions.delete(tabId); + this.sessions.delete(paneId); } } - detach(params: { tabId: string }): void { - const { tabId } = params; - const session = this.sessions.get(tabId); + detach(params: { paneId: string }): void { + const { paneId } = params; + const session = this.sessions.get(paneId); if (!session) { - console.warn(`Cannot detach terminal ${tabId}: session not found`); + console.warn(`Cannot detach terminal ${paneId}: session not found`); return; } session.lastActive = Date.now(); } - async clearScrollback(params: { tabId: string }): Promise { - const { tabId } = params; - const session = this.sessions.get(tabId); + async clearScrollback(params: { paneId: string }): Promise { + const { paneId } = params; + const session = this.sessions.get(paneId); if (!session) { console.warn( - `Cannot clear scrollback for terminal ${tabId}: session not found`, + `Cannot clear scrollback for terminal ${paneId}: session not found`, ); return; } @@ -384,7 +365,7 @@ export class TerminalManager extends EventEmitter { await session.historyWriter.close(); session.historyWriter = new HistoryWriter( session.workspaceId, - session.tabId, + session.paneId, session.cwd, session.cols, session.rows, @@ -393,12 +374,12 @@ export class TerminalManager extends EventEmitter { } } - getSession(tabId: string): { + getSession(paneId: string): { isAlive: boolean; cwd: string; lastActive: number; } | null { - const session = this.sessions.get(tabId); + const session = this.sessions.get(paneId); if (!session) { return null; } @@ -423,7 +404,7 @@ export class TerminalManager extends EventEmitter { const results: Promise[] = []; - for (const [tabId, session] of sessionsToKill) { + for (const [paneId, session] of sessionsToKill) { if (session.isAlive) { session.deleteHistoryOnExit = true; @@ -435,14 +416,14 @@ export class TerminalManager extends EventEmitter { const cleanup = (success: boolean) => { if (resolved) return; resolved = true; - this.off(`exit:${tabId}`, exitHandler); + this.off(`exit:${paneId}`, exitHandler); if (sigtermTimeout) clearTimeout(sigtermTimeout); if (sigkillTimeout) clearTimeout(sigkillTimeout); resolve(success); }; const exitHandler = () => cleanup(true); - this.once(`exit:${tabId}`, exitHandler); + this.once(`exit:${paneId}`, exitHandler); // First timeout: SIGTERM didn't work, try SIGKILL sigtermTimeout = setTimeout(() => { @@ -452,7 +433,7 @@ export class TerminalManager extends EventEmitter { session.pty.kill("SIGKILL"); } catch (error) { console.error( - `Failed to send SIGKILL to terminal ${tabId}:`, + `Failed to send SIGKILL to terminal ${paneId}:`, error, ); } @@ -463,11 +444,11 @@ export class TerminalManager extends EventEmitter { if (session.isAlive) { console.error( - `Terminal ${tabId} did not exit after SIGKILL, forcing cleanup. Process may still be running.`, + `Terminal ${paneId} did not exit after SIGKILL, forcing cleanup. Process may still be running.`, ); // Clean up session state before resolving session.isAlive = false; - this.sessions.delete(tabId); + this.sessions.delete(paneId); this.closeHistory(session).catch(() => {}); } cleanup(false); @@ -481,12 +462,12 @@ export class TerminalManager extends EventEmitter { session.pty.kill(); } catch (error) { console.error( - `Failed to send SIGTERM to terminal ${tabId}:`, + `Failed to send SIGTERM to terminal ${paneId}:`, error, ); // Mark as failed immediately since we can't even signal it session.isAlive = false; - this.sessions.delete(tabId); + this.sessions.delete(paneId); this.closeHistory(session).catch(() => {}); cleanup(false); } @@ -497,7 +478,7 @@ export class TerminalManager extends EventEmitter { // Clean up history for already-dead sessions session.deleteHistoryOnExit = true; await this.closeHistory(session); - this.sessions.delete(tabId); + this.sessions.delete(paneId); results.push(Promise.resolve(true)); } } @@ -539,20 +520,20 @@ export class TerminalManager extends EventEmitter { async cleanup(): Promise { const exitPromises: Promise[] = []; - for (const [tabId, session] of this.sessions.entries()) { + for (const [paneId, session] of this.sessions.entries()) { if (session.isAlive) { const exitPromise = new Promise((resolve) => { const exitHandler = () => { - this.off(`exit:${tabId}`, exitHandler); + this.off(`exit:${paneId}`, exitHandler); if (timeoutId) { clearTimeout(timeoutId); } resolve(); }; - this.once(`exit:${tabId}`, exitHandler); + this.once(`exit:${paneId}`, exitHandler); const timeoutId = setTimeout(() => { - this.off(`exit:${tabId}`, exitHandler); + this.off(`exit:${paneId}`, exitHandler); resolve(); }, 2000); timeoutId.unref(); @@ -583,7 +564,7 @@ export class TerminalManager extends EventEmitter { } const historyReader = new HistoryReader( session.workspaceId, - session.tabId, + session.paneId, ); await historyReader.cleanup(); return; diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index f8de0dd7ae4..84a8b69b4f1 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -6,7 +6,9 @@ import { createAppRouter } from "lib/trpc/routers"; import { PORTS } from "shared/constants"; import { createIPCHandler } from "trpc-electron/main"; import { productName } from "~/package.json"; +import { appState } from "../lib/app-state"; import { setMainWindow } from "../lib/auto-updater"; +import { db } from "../lib/db"; import { createApplicationMenu } from "../lib/menu"; import { playNotificationSound } from "../lib/notification-sound"; import { @@ -80,13 +82,50 @@ export async function MainWindow() { if (Notification.isSupported()) { const isPermissionRequest = event.eventType === "PermissionRequest"; + // Derive workspace name from workspaceId with safe fallbacks + let workspaceName = "Workspace"; + try { + const workspaces = db.data?.workspaces; + const worktrees = db.data?.worktrees; + if (Array.isArray(workspaces) && Array.isArray(worktrees)) { + const workspace = workspaces.find((w) => w.id === event.workspaceId); + const worktree = workspace + ? worktrees.find((wt) => wt.id === workspace.worktreeId) + : undefined; + workspaceName = workspace?.name || worktree?.branch || "Workspace"; + } + } catch (error) { + console.error( + "[notifications] Failed to access db for workspace name:", + error, + ); + } + + // Derive title from tab name, falling back to pane name + // Priority: tab.userTitle (user-set name) > tab.name (auto-generated) > pane.name > "Terminal" + let title = "Terminal"; + try { + const { paneId, tabId } = event; + const tabsState = appState.data?.tabsState; + const pane = paneId ? tabsState?.panes?.[paneId] : undefined; + const tab = tabId + ? tabsState?.tabs?.find((t) => t.id === tabId) + : undefined; + title = tab?.userTitle?.trim() || tab?.name || pane?.name || "Terminal"; + } catch (error) { + console.error( + "[notifications] Failed to access appState for tab title:", + error, + ); + } + const notification = new Notification({ title: isPermissionRequest - ? `Input Needed — ${event.workspaceName}` - : `Agent Complete — ${event.workspaceName}`, + ? `Input Needed — ${workspaceName}` + : `Agent Complete — ${workspaceName}`, body: isPermissionRequest - ? `"${event.tabTitle}" needs your attention` - : `"${event.tabTitle}" has finished its task`, + ? `"${title}" needs your attention` + : `"${title}" has finished its task`, silent: true, }); @@ -95,8 +134,9 @@ export async function MainWindow() { notification.on("click", () => { window.show(); window.focus(); - // Request focus on the specific tab + // Request focus on the specific pane notificationsEmitter.emit("focus-tab", { + paneId: event.paneId, tabId: event.tabId, workspaceId: event.workspaceId, }); diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 24d60a47952..2245510b485 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -10,11 +10,6 @@ declare global { interface Window { App: typeof API; ipcRenderer: typeof ipcRendererAPI; - electronStore: { - get: (key: string) => unknown; - set: (key: string, value: unknown) => void; - delete: (key: string) => void; - }; webUtils: { getPathForFile: (file: File) => string; }; @@ -81,17 +76,8 @@ const ipcRendererAPI = { // Expose electron-trpc IPC channel FIRST (must be before contextBridge calls) exposeElectronTRPC(); -// Expose electron-store API via IPC -const electronStoreAPI = { - get: (key: string) => ipcRenderer.invoke("storage:get", { key }), - set: (key: string, value: unknown) => - ipcRenderer.invoke("storage:set", { key, value }), - delete: (key: string) => ipcRenderer.invoke("storage:delete", { key }), -}; - contextBridge.exposeInMainWorld("App", API); contextBridge.exposeInMainWorld("ipcRenderer", ipcRendererAPI); -contextBridge.exposeInMainWorld("electronStore", electronStoreAPI); contextBridge.exposeInMainWorld("webUtils", { getPathForFile: (file: File) => webUtils.getPathForFile(file), }); diff --git a/apps/desktop/src/renderer/lib/electron-storage.ts b/apps/desktop/src/renderer/lib/electron-storage.ts deleted file mode 100644 index 455c7e7c5b9..00000000000 --- a/apps/desktop/src/renderer/lib/electron-storage.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { createJSONStorage } from "zustand/middleware"; - -/** - * Custom Zustand storage adapter that uses electron-store for persistence via IPC - * Stores state in ~/.superset/app-state.json - */ -const electronStorageAdapter = { - getItem: async (name: string): Promise => { - const value = await window.electronStore.get(name); - return value as string | null; - }, - setItem: async (name: string, value: string): Promise => { - await window.electronStore.set(name, value); - }, - removeItem: async (name: string): Promise => { - await window.electronStore.delete(name); - }, -}; - -export const electronStorage = createJSONStorage(() => electronStorageAdapter); diff --git a/apps/desktop/src/renderer/lib/trpc-storage.ts b/apps/desktop/src/renderer/lib/trpc-storage.ts new file mode 100644 index 00000000000..d91e47be3c7 --- /dev/null +++ b/apps/desktop/src/renderer/lib/trpc-storage.ts @@ -0,0 +1,62 @@ +import { createJSONStorage, type StateStorage } from "zustand/middleware"; +import { trpcClient } from "./trpc-client"; + +/** + * Creates a Zustand storage adapter that uses tRPC for persistence. + * This ensures all state is persisted through the centralized appState lowdb instance. + */ + +interface TrpcStorageConfig { + get: () => Promise; + set: (input: unknown) => Promise; +} + +function createTrpcStorageAdapter(config: TrpcStorageConfig): StateStorage { + return { + getItem: async (_name: string): Promise => { + try { + const state = await config.get(); + if (!state) return null; + // Wrap in zustand persist format + return JSON.stringify({ state, version: 0 }); + } catch (error) { + console.error("[trpc-storage] Failed to get state:", error); + return null; + } + }, + setItem: async (_name: string, value: string): Promise => { + try { + const parsed = JSON.parse(value) as { state: unknown; version: number }; + await config.set(parsed.state); + } catch (error) { + console.error("[trpc-storage] Failed to set state:", error); + } + }, + removeItem: async (_name: string): Promise => { + // Reset to empty/default state is handled by the store itself + // No-op here as we don't want to delete persisted state + }, + }; +} + +/** + * Zustand storage adapter for tabs state using tRPC + */ +export const trpcTabsStorage = createJSONStorage(() => + createTrpcStorageAdapter({ + get: () => trpcClient.uiState.tabs.get.query(), + // biome-ignore lint/suspicious/noExplicitAny: Zustand persist passes unknown, tRPC expects typed input + set: (input) => trpcClient.uiState.tabs.set.mutate(input as any), + }), +); + +/** + * Zustand storage adapter for theme state using tRPC + */ +export const trpcThemeStorage = createJSONStorage(() => + createTrpcStorageAdapter({ + get: () => trpcClient.uiState.theme.get.query(), + // biome-ignore lint/suspicious/noExplicitAny: Zustand persist passes unknown, tRPC expects typed input + set: (input) => trpcClient.uiState.theme.set.mutate(input as any), + }), +); diff --git a/apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts b/apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts index 98c1cb9bec4..0b2c78c5af3 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts @@ -14,6 +14,7 @@ export function useCreateWorkspace( ) { const utils = trpc.useUtils(); const addTab = useTabsStore((state) => state.addTab); + const setTabAutoTitle = useTabsStore((state) => state.setTabAutoTitle); const createOrAttach = trpc.terminal.createOrAttach.useMutation(); const openConfigModal = useOpenConfigModal(); const dismissConfigToast = trpc.config.dismissConfigToast.useMutation(); @@ -29,13 +30,14 @@ export function useCreateWorkspace( Array.isArray(data.initialCommands) && data.initialCommands.length > 0 ) { - const { paneId } = addTab(data.workspace.id); + const { tabId, paneId } = addTab(data.workspace.id); + setTabAutoTitle(tabId, "Workspace Setup"); // Pre-create terminal session with initial commands // Terminal component will attach to this session when it mounts createOrAttach.mutate({ - tabId: paneId, + paneId, + tabId, workspaceId: data.workspace.id, - tabTitle: "Terminal", initialCommands: data.initialCommands, }); } else { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index a293f7633e6..04ee96146ea 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -26,7 +26,6 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const paneId = tabId; const panes = useTabsStore((s) => s.panes); const pane = panes[paneId]; - const paneName = pane?.name || "Terminal"; const paneInitialCommands = pane?.initialCommands; const paneInitialCwd = pane?.initialCwd; const clearPaneInitialData = useTabsStore((s) => s.clearPaneInitialData); @@ -84,9 +83,6 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const parentTabIdRef = useRef(parentTabId); parentTabIdRef.current = parentTabId; - const paneNameRef = useRef(paneName); - paneNameRef.current = paneName; - const setTabAutoTitleRef = useRef(setTabAutoTitle); setTabAutoTitleRef.current = setTabAutoTitle; @@ -211,9 +207,9 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { xterm.clear(); createOrAttachRef.current( { - tabId: paneId, + paneId, + tabId: parentTabIdRef.current || paneId, workspaceId, - tabTitle: paneNameRef.current, cols: xterm.cols, rows: xterm.rows, }, @@ -235,7 +231,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { restartTerminal(); return; } - writeRef.current({ tabId: paneId, data }); + writeRef.current({ paneId, data }); }; const handleKeyPress = (event: { @@ -267,9 +263,9 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { createOrAttachRef.current( { - tabId: paneId, + paneId, + tabId: parentTabIdRef.current || paneId, workspaceId, - tabTitle: paneNameRef.current, cols: xterm.cols, rows: xterm.rows, initialCommands, @@ -300,12 +296,12 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const cleanupKeyboard = setupKeyboardHandler(xterm, { onShiftEnter: () => { if (!isExitedRef.current) { - writeRef.current({ tabId: paneId, data: "\\\n" }); + writeRef.current({ paneId, data: "\\\n" }); } }, onClear: () => { xterm.clear(); - clearScrollbackRef.current({ tabId: paneId }); + clearScrollbackRef.current({ paneId }); }, }); @@ -317,7 +313,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { xterm, fitAddon, (cols, rows) => { - resizeRef.current({ tabId: paneId, cols, rows }); + resizeRef.current({ paneId, cols, rows }); }, ); const cleanupPaste = setupPasteHandler(xterm, { @@ -336,7 +332,8 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { cleanupPaste(); cleanupQuerySuppression(); debouncedSetTabAutoTitleRef.current?.cancel?.(); - detachRef.current({ tabId: paneId }); + // Detach instead of kill to keep PTY running for reattachment + detachRef.current({ paneId }); setSubscriptionEnabled(false); xterm.dispose(); xtermRef.current = null; @@ -368,7 +365,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const text = shellEscapePaths(paths); if (!isExitedRef.current) { - writeRef.current({ tabId: paneId, data: text }); + writeRef.current({ paneId, data: text }); } }; diff --git a/apps/desktop/src/renderer/stores/tabs/store.ts b/apps/desktop/src/renderer/stores/tabs/store.ts index a1122bd63df..0077764707c 100644 --- a/apps/desktop/src/renderer/stores/tabs/store.ts +++ b/apps/desktop/src/renderer/stores/tabs/store.ts @@ -2,7 +2,7 @@ import type { MosaicNode } from "react-mosaic-component"; import { updateTree } from "react-mosaic-component"; import { create } from "zustand"; import { devtools, persist } from "zustand/middleware"; -import { electronStorage } from "../../lib/electron-storage"; +import { trpcTabsStorage } from "../../lib/trpc-storage"; import { movePaneToNewTab, movePaneToTab } from "./actions/move-pane"; import type { TabsState, TabsStore } from "./types"; import { @@ -15,7 +15,7 @@ import { isLastPaneInTab, removePaneFromLayout, } from "./utils"; -import { killTerminalForTab } from "./utils/terminal-cleanup"; +import { killTerminalForPane } from "./utils/terminal-cleanup"; /** * Finds the next best tab to activate when closing a tab. @@ -125,7 +125,7 @@ export const useTabsStore = create()( // Kill all terminals for panes in this tab const paneIds = getPaneIdsForTab(state.panes, tabId); for (const paneId of paneIds) { - killTerminalForTab(paneId); + killTerminalForPane(paneId); } // Remove all panes belonging to this tab @@ -291,7 +291,7 @@ export const useTabsStore = create()( const newPanes = { ...state.panes }; for (const paneId of removedPaneIds) { - killTerminalForTab(paneId); + killTerminalForPane(paneId); delete newPanes[paneId]; } @@ -362,7 +362,7 @@ export const useTabsStore = create()( } // Kill the terminal - killTerminalForTab(paneId); + killTerminalForPane(paneId); // Remove pane from layout const newLayout = removePaneFromLayout(tab.layout, paneId); @@ -607,7 +607,7 @@ export const useTabsStore = create()( }), { name: "tabs-storage", - storage: electronStorage, + storage: trpcTabsStorage, }, ), { name: "TabsStore" }, diff --git a/apps/desktop/src/renderer/stores/tabs/types.ts b/apps/desktop/src/renderer/stores/tabs/types.ts index f0d34034fd7..44e363f7018 100644 --- a/apps/desktop/src/renderer/stores/tabs/types.ts +++ b/apps/desktop/src/renderer/stores/tabs/types.ts @@ -1,47 +1,23 @@ import type { MosaicBranch, MosaicNode } from "react-mosaic-component"; +import type { BaseTab, BaseTabsState, Pane, PaneType } from "shared/tabs-types"; -/** - * Pane types that can be displayed within a tab - */ -export type PaneType = "terminal"; - -/** - * A Pane represents a single terminal or content area within a Tab. - * Panes always belong to a Tab and are referenced by ID in the Tab's layout. - */ -export interface Pane { - id: string; - tabId: string; - type: PaneType; - name: string; - isNew?: boolean; - needsAttention?: boolean; - initialCommands?: string[]; - initialCwd?: string; -} +// Re-export shared types +export type { Pane, PaneType }; /** * A Tab is a container that holds one or more Panes in a Mosaic layout. - * Tabs are displayed in the sidebar and always have at least one Pane. + * Extends BaseTab with renderer-specific layout field. */ -export interface Tab { - id: string; - name: string; - userTitle?: string; - workspaceId: string; +export interface Tab extends BaseTab { layout: MosaicNode; // Always defined, leaves are paneIds - createdAt: number; } /** - * State for the tabs/panes store + * State for the tabs/panes store. + * Extends BaseTabsState with renderer-specific Tab type. */ -export interface TabsState { +export interface TabsState extends Omit { tabs: Tab[]; - panes: Record; - activeTabIds: Record; // workspaceId → tabId - focusedPaneIds: Record; // tabId → paneId (last focused pane in each tab) - tabHistoryStacks: Record; // workspaceId → tabId[] (MRU history) } /** diff --git a/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts b/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts index 2fd73fcc6dc..5cddfc90414 100644 --- a/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts +++ b/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts @@ -14,8 +14,9 @@ export function useAgentHookListener() { trpc.notifications.subscribe.useSubscription(undefined, { onData: (event) => { if (event.type === "agent-complete") { - // paneId is passed as tabId for backwards compatibility - const { tabId: paneId, workspaceId } = event.data; + const { paneId, workspaceId } = event.data; + if (!paneId || !workspaceId) return; + const state = useTabsStore.getState(); // Find the tab containing this pane @@ -32,8 +33,8 @@ export function useAgentHookListener() { state.setNeedsAttention(paneId, true); } } else if (event.type === "focus-tab") { - // paneId is passed as tabId for backwards compatibility - const { tabId: paneId, workspaceId } = event.data; + const { paneId, workspaceId } = event.data; + if (!paneId || !workspaceId) return; // Switch to workspace view if not already there const appState = useAppStore.getState(); diff --git a/apps/desktop/src/renderer/stores/tabs/utils/terminal-cleanup.ts b/apps/desktop/src/renderer/stores/tabs/utils/terminal-cleanup.ts index 4aeab7489a7..f867cd0e7d0 100644 --- a/apps/desktop/src/renderer/stores/tabs/utils/terminal-cleanup.ts +++ b/apps/desktop/src/renderer/stores/tabs/utils/terminal-cleanup.ts @@ -4,10 +4,10 @@ import { trpcClient } from "../../../lib/trpc-client"; * Uses standalone tRPC client to avoid React hook dependencies * Permanently deletes terminal history when killing the terminal */ -export const killTerminalForTab = (tabId: string): void => { +export const killTerminalForPane = (paneId: string): void => { trpcClient.terminal.kill - .mutate({ tabId, deleteHistory: true }) + .mutate({ paneId, deleteHistory: true }) .catch((error) => { - console.warn(`Failed to kill terminal for tab ${tabId}:`, error); + console.warn(`Failed to kill terminal for pane ${paneId}:`, error); }); }; diff --git a/apps/desktop/src/renderer/stores/theme/store.ts b/apps/desktop/src/renderer/stores/theme/store.ts index b5b57a63308..762b78e2417 100644 --- a/apps/desktop/src/renderer/stores/theme/store.ts +++ b/apps/desktop/src/renderer/stores/theme/store.ts @@ -7,7 +7,7 @@ import { } from "shared/themes"; import { create } from "zustand"; import { devtools, persist } from "zustand/middleware"; -import { electronStorage } from "../../lib/electron-storage"; +import { trpcThemeStorage } from "../../lib/trpc-storage"; import { applyUIColors, toXtermTheme, updateThemeClass } from "./utils"; interface ThemeState { @@ -159,7 +159,7 @@ export const useThemeStore = create()( }), { name: "theme-storage", - storage: electronStorage, + storage: trpcThemeStorage, partialize: (state) => ({ activeThemeId: state.activeThemeId, customThemes: state.customThemes, diff --git a/apps/desktop/src/shared/constants.ts b/apps/desktop/src/shared/constants.ts index 4f27e23f2ba..55746cb81ea 100644 --- a/apps/desktop/src/shared/constants.ts +++ b/apps/desktop/src/shared/constants.ts @@ -18,9 +18,13 @@ export const PORTS = { // Note: For environment-aware paths, use main/lib/app-environment.ts instead. // Paths require Node.js/Electron APIs that aren't available in renderer. +export const SUPERSET_DIR_NAMES = { + DEV: ".superset-dev", + PROD: ".superset", +} as const; export const SUPERSET_DIR_NAME = ENVIRONMENT.IS_DEV - ? ".superset-dev" - : ".superset"; + ? SUPERSET_DIR_NAMES.DEV + : SUPERSET_DIR_NAMES.PROD; // Project-level directory name (always .superset, not conditional) export const PROJECT_SUPERSET_DIR_NAME = ".superset"; export const WORKTREES_DIR_NAME = "worktrees"; diff --git a/apps/desktop/src/shared/ipc-channels/index.ts b/apps/desktop/src/shared/ipc-channels/index.ts index b0f209d8d61..cc953a76e2d 100644 --- a/apps/desktop/src/shared/ipc-channels/index.ts +++ b/apps/desktop/src/shared/ipc-channels/index.ts @@ -8,7 +8,6 @@ import type { DeepLinkChannels } from "./deep-link"; import type { ExternalChannels } from "./external"; import type { ProxyChannels } from "./proxy"; -import type { StorageChannels } from "./storage"; import type { TabChannels } from "./tab"; import type { TerminalChannels } from "./terminal"; import type { UiChannels } from "./ui"; @@ -32,8 +31,7 @@ export interface IpcChannels ExternalChannels, DeepLinkChannels, WindowChannels, - UiChannels, - StorageChannels {} + UiChannels {} /** * Type-safe IPC channel names diff --git a/apps/desktop/src/shared/ipc-channels/storage.ts b/apps/desktop/src/shared/ipc-channels/storage.ts deleted file mode 100644 index 5c22672e891..00000000000 --- a/apps/desktop/src/shared/ipc-channels/storage.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Storage-related IPC channels for electron-store persistence - */ - -export interface StorageChannels { - "storage:get": { - request: { key: string }; - response: unknown; - }; - - "storage:set": { - request: { key: string; value: unknown }; - response: undefined; - }; - - "storage:delete": { - request: { key: string }; - response: undefined; - }; -} diff --git a/apps/desktop/src/shared/tabs-types.ts b/apps/desktop/src/shared/tabs-types.ts new file mode 100644 index 00000000000..cd987a8aa72 --- /dev/null +++ b/apps/desktop/src/shared/tabs-types.ts @@ -0,0 +1,46 @@ +/** + * Shared types for tabs/panes used by both main and renderer processes. + * Renderer extends these with MosaicNode layout specifics. + */ + +/** + * Pane types that can be displayed within a tab + */ +export type PaneType = "terminal" | "webview"; + +/** + * Base Pane interface - shared between main and renderer + */ +export interface Pane { + id: string; + tabId: string; + type: PaneType; + name: string; + isNew?: boolean; + needsAttention?: boolean; + initialCommands?: string[]; + initialCwd?: string; + url?: string; // For webview panes +} + +/** + * Base Tab interface - shared fields without layout + */ +export interface BaseTab { + id: string; + name: string; + userTitle?: string; + workspaceId: string; + createdAt: number; +} + +/** + * Base tabs state - shared between main and renderer + */ +export interface BaseTabsState { + tabs: BaseTab[]; + panes: Record; + activeTabIds: Record; // workspaceId → tabId + focusedPaneIds: Record; // tabId → paneId + tabHistoryStacks: Record; // workspaceId → tabId[] (MRU history) +} diff --git a/bun.lock b/bun.lock index 3caeb044223..b3bc8cda16d 100644 --- a/bun.lock +++ b/bun.lock @@ -69,7 +69,7 @@ }, "apps/desktop": { "name": "@superset/desktop", - "version": "0.0.10", + "version": "0.0.12", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", @@ -96,7 +96,6 @@ "dnd-core": "^16.0.1", "dotenv": "^17.2.3", "electron-router-dom": "^2.1.0", - "electron-store": "^11.0.2", "electron-updater": "6", "execa": "^9.6.0", "express": "^5.1.0", @@ -1131,9 +1130,7 @@ "aggregate-error": ["aggregate-error@3.1.0", "", { "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" } }, "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA=="], - "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], - - "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], "ajv-keywords": ["ajv-keywords@3.5.2", "", { "peerDependencies": { "ajv": "^6.9.1" } }, "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="], @@ -1173,8 +1170,6 @@ "at-least-node": ["at-least-node@1.0.0", "", {}, "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg=="], - "atomically": ["atomically@2.1.0", "", { "dependencies": { "stubborn-fs": "^2.0.0", "when-exit": "^2.1.4" } }, "sha512-+gDffFXRW6sl/HCwbta7zK4uNqbPjv4YJEAdz7Vu+FLQHe77eZ4bvbJGi4hE0QPeJlMYMA3piXEr1UL3dAwx7Q=="], - "auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="], "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], @@ -1313,8 +1308,6 @@ "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], - "conf": ["conf@15.0.2", "", { "dependencies": { "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "atomically": "^2.0.3", "debounce-fn": "^6.0.0", "dot-prop": "^10.0.0", "env-paths": "^3.0.0", "json-schema-typed": "^8.0.1", "semver": "^7.7.2", "uint8array-extras": "^1.5.0" } }, "sha512-JBSrutapCafTrddF9dH3lc7+T2tBycGF4uPkI4Js+g4vLLEhG6RZcFi3aJd5zntdf5tQxAejJt8dihkoQ/eSJw=="], - "confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], "config-file-ts": ["config-file-ts@0.2.8-rc1", "", { "dependencies": { "glob": "^10.3.12", "typescript": "^5.4.3" } }, "sha512-GtNECbVI82bT4RiDIzBSVuTKoSHufnU7Ce7/42bkWZJZFLjmDF2WBpVsvRkhKCfKBnTBb3qZrBwPpFBU/Myvhg=="], @@ -1423,8 +1416,6 @@ "dayjs": ["dayjs@1.11.19", "", {}, "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="], - "debounce-fn": ["debounce-fn@6.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ=="], - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "decamelize": ["decamelize@6.0.1", "", {}, "sha512-G7Cqgaelq68XHJNGlZ7lrNQyhZGsFqpwtGFexqUv4IQdjKoSYF7ipZ9UuTJZUSQXFj/XaoBLuEVIVqr8EJngEQ=="], @@ -1473,8 +1464,6 @@ "dompurify": ["dompurify@3.3.0", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ=="], - "dot-prop": ["dot-prop@10.1.0", "", { "dependencies": { "type-fest": "^5.0.0" } }, "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q=="], - "dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="], "dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="], @@ -1507,8 +1496,6 @@ "electron-router-dom": ["electron-router-dom@2.1.0", "", { "peerDependencies": { "electron": ">=17.0", "react": ">=18.0", "react-router-dom": ">=6.22.3" } }, "sha512-Ew8uRmraiDZKxnBXudBzYvfuc/+yj3AmQBqWp58AoT7tiiuyyO++XIN1lqsFleO/kpi7DANexztGShkgbbMB6g=="], - "electron-store": ["electron-store@11.0.2", "", { "dependencies": { "conf": "^15.0.2", "type-fest": "^5.0.1" } }, "sha512-4VkNRdN+BImL2KcCi41WvAYbh6zLX5AUTi4so68yPqiItjbgTjqpEnGAqasgnG+lB6GuAyUltKwVopp6Uv+gwQ=="], - "electron-to-chromium": ["electron-to-chromium@1.5.262", "", {}, "sha512-NlAsMteRHek05jRUxUR0a5jpjYq9ykk6+kO0yRaMi5moe7u0fVIOeQ3Y30A8dIiWFBNUoQGi1ljb1i5VtS9WQQ=="], "electron-updater": ["electron-updater@6.6.2", "", { "dependencies": { "builder-util-runtime": "9.3.1", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", "lodash.escaperegexp": "^4.1.2", "lodash.isequal": "^4.5.0", "semver": "^7.6.3", "tiny-typed-emitter": "^2.1.0" } }, "sha512-Cr4GDOkbAUqRHP5/oeOmH/L2Bn6+FQPxVLZtPbcmKZC63a1F3uu5EefYOssgZXG3u/zBlubbJ5PJdITdMVggbw=="], @@ -1603,8 +1590,6 @@ "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], - "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], - "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], "fault": ["fault@1.0.4", "", { "dependencies": { "format": "^0.2.0" } }, "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA=="], @@ -1871,9 +1856,7 @@ "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], - "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - - "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], @@ -2115,8 +2098,6 @@ "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], - "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], - "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], "minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="], @@ -2629,10 +2610,6 @@ "strip-indent": ["strip-indent@4.1.1", "", {}, "sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA=="], - "stubborn-fs": ["stubborn-fs@2.0.0", "", { "dependencies": { "stubborn-utils": "^1.0.1" } }, "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA=="], - - "stubborn-utils": ["stubborn-utils@1.0.2", "", {}, "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg=="], - "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], @@ -2653,8 +2630,6 @@ "tabbable": ["tabbable@6.3.0", "", {}, "sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ=="], - "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], - "tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="], "tailwindcss": ["tailwindcss@4.1.17", "", {}, "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q=="], @@ -2749,8 +2724,6 @@ "ufo": ["ufo@1.6.1", "", {}, "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA=="], - "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], - "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], @@ -2843,8 +2816,6 @@ "webgl-sdf-generator": ["webgl-sdf-generator@1.1.1", "", {}, "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA=="], - "when-exit": ["when-exit@2.1.5", "", {}, "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg=="], - "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "wicked-good-xpath": ["wicked-good-xpath@1.3.0", "", {}, "sha512-Gd9+TUn5nXdwj/hFsPVx5cuHHiF5Bwuc30jZ4+ronF1qHK5O7HD0sgmXWSEgwKquT3ClLoKPVbO6qGwVwLzvAw=="], @@ -2899,8 +2870,6 @@ "@code-inspector/vite/chalk": ["chalk@4.1.1", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg=="], - "@develar/schema-utils/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], - "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], "@electron/asar/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=="], @@ -2979,8 +2948,6 @@ "aggregate-error/indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], - "ajv-keywords/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], - "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "app-builder-lib/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], @@ -3019,8 +2986,6 @@ "code-inspector-plugin/chalk": ["chalk@4.1.1", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg=="], - "conf/env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], - "config-file-ts/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], "crc/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], @@ -3039,10 +3004,6 @@ "dir-compare/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], - "dmg-license/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], - - "dot-prop/type-fest": ["type-fest@5.2.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-xxCJm+Bckc6kQBknN7i9fnP/xobQRsRQxR01CztFkp/h++yfVxUUcmMgfR2HttJx/dpWjS9ubVuyspJv24Q9DA=="], - "dotenv-expand/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], "electron/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], @@ -3051,8 +3012,6 @@ "electron-publish/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "electron-store/type-fest": ["type-fest@5.2.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-xxCJm+Bckc6kQBknN7i9fnP/xobQRsRQxR01CztFkp/h++yfVxUUcmMgfR2HttJx/dpWjS9ubVuyspJv24Q9DA=="], - "electron-winstaller/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], "execa/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], @@ -3235,8 +3194,6 @@ "@code-inspector/vite/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "@develar/schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], - "@electron/fuses/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], @@ -3299,8 +3256,6 @@ "@npmcli/move-file/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=="], - "ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], - "app-builder-lib/hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], "builder-util/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -3343,8 +3298,6 @@ "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], - "dmg-license/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], - "electron-builder/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "electron-publish/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],