Skip to content
Merged
11 changes: 10 additions & 1 deletion apps/desktop/src/lib/trpc/routers/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@ import { observable } from "@trpc/server/observable";
import {
menuEmitter,
type OpenSettingsEvent,
type OpenWorkspaceEvent,
type SettingsSection,
} from "main/lib/menu-events";
import { publicProcedure, router } from "..";

type MenuEvent = { type: "open-settings"; data: OpenSettingsEvent };
type MenuEvent =
| { type: "open-settings"; data: OpenSettingsEvent }
| { type: "open-workspace"; data: OpenWorkspaceEvent };

export const createMenuRouter = () => {
return router({
Expand All @@ -16,10 +19,16 @@ export const createMenuRouter = () => {
emit.next({ type: "open-settings", data: { section } });
};

const onOpenWorkspace = (workspaceId: string) => {
emit.next({ type: "open-workspace", data: { workspaceId } });
};

menuEmitter.on("open-settings", onOpenSettings);
menuEmitter.on("open-workspace", onOpenWorkspace);

return () => {
menuEmitter.off("open-settings", onOpenSettings);
menuEmitter.off("open-workspace", onOpenWorkspace);
};
});
}),
Expand Down
8 changes: 8 additions & 0 deletions apps/desktop/src/lib/trpc/routers/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
type TerminalPreset,
} from "@superset/local-db";
import { TRPCError } from "@trpc/server";
import { app } from "electron";
import { quitWithoutConfirmation } from "main/index";
import { localDb } from "main/lib/local-db";
import {
DEFAULT_CONFIRM_ON_QUIT,
Expand Down Expand Up @@ -265,5 +267,11 @@ export const createSettingsRouter = () => {

return { success: true };
}),

restartApp: publicProcedure.mutation(() => {
app.relaunch();
quitWithoutConfirmation();
return { success: true };
}),
});
};
29 changes: 29 additions & 0 deletions apps/desktop/src/lib/trpc/routers/terminal/terminal.stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,35 @@ mock.module("main/lib/local-db", () => ({
},
}));

// Mock terminal module to avoid Electron imports from terminal-host/client
// The mock checks mockTerminal.management to determine daemon mode
mock.module("main/lib/terminal", () => ({
tryListExistingDaemonSessions: async () => {
// Check if mockTerminal.management is set to simulate daemon mode
if (mockTerminal.management) {
const result = await mockTerminal.management.listSessions();
return {
daemonRunning: true,
sessions: result.sessions,
};
}
return {
daemonRunning: false,
sessions: [],
};
},
}));

// Mock terminal-host/client to avoid Electron app import
mock.module("main/lib/terminal-host/client", () => ({
getTerminalHostClient: () => ({
tryConnectAndAuthenticate: async () => false,
listSessions: async () => ({ sessions: [] }),
killAll: async () => ({}),
kill: async () => ({}),
}),
}));

const { createTerminalRouter } = await import("./terminal");

describe("terminal.stream", () => {
Expand Down
34 changes: 15 additions & 19 deletions apps/desktop/src/lib/trpc/routers/terminal/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable";
import { eq } from "drizzle-orm";
import { localDb } from "main/lib/local-db";
import { tryListExistingDaemonSessions } from "main/lib/terminal";
import { getTerminalHostClient } from "main/lib/terminal-host/client";
import { getWorkspaceRuntimeRegistry } from "main/lib/workspace-runtime";
import { z } from "zod";
import { publicProcedure, router } from "../..";
Expand Down Expand Up @@ -285,23 +287,19 @@ export const createTerminalRouter = () => {
}),

listDaemonSessions: publicProcedure.query(async () => {
// Use capability-based check instead of instanceof
if (!terminal.management) {
return { daemonModeEnabled: false, sessions: [] };
}

const response = await terminal.management.listSessions();
return { daemonModeEnabled: true, sessions: response.sessions };
const { daemonRunning, sessions } = await tryListExistingDaemonSessions();
return { daemonModeEnabled: daemonRunning, sessions };
}),

killAllDaemonSessions: publicProcedure.mutation(async () => {
// Use capability-based check instead of instanceof
if (!terminal.management) {
const client = getTerminalHostClient();
const connected = await client.tryConnectAndAuthenticate();
if (!connected) {
return { daemonModeEnabled: false, killedCount: 0, remainingCount: 0 };
}

// Get sessions before kill for accurate count
const before = await terminal.management.listSessions();
const before = await client.listSessions();
const beforeIds = before.sessions.map((s) => s.sessionId);
for (const id of beforeIds) {
userKilledSessions.add(id);
Expand All @@ -314,7 +312,7 @@ export const createTerminalRouter = () => {
);

// Request kill of all sessions
await terminal.management.killAllSessions();
await client.killAll({});

// Wait and verify loop - poll until sessions are actually dead
// This ensures we don't return success before daemon has finished cleanup
Expand All @@ -325,7 +323,7 @@ export const createTerminalRouter = () => {

for (let i = 0; i < MAX_RETRIES && remainingCount > 0; i++) {
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
const after = await terminal.management.listSessions();
const after = await client.listSessions();
afterIds = after.sessions
.filter((s) => s.isAlive)
.map((s) => s.sessionId);
Expand Down Expand Up @@ -355,31 +353,29 @@ export const createTerminalRouter = () => {
killDaemonSessionsForWorkspace: publicProcedure
.input(z.object({ workspaceId: z.string() }))
.mutation(async ({ input }) => {
// Use capability-based check instead of instanceof
if (!terminal.management) {
const client = getTerminalHostClient();
const connected = await client.tryConnectAndAuthenticate();
if (!connected) {
return { daemonModeEnabled: false, killedCount: 0 };
}

const { sessions } = await terminal.management.listSessions();
const { sessions } = await client.listSessions();
const toKill = sessions.filter(
(session) => session.workspaceId === input.workspaceId,
);

for (const session of toKill) {
userKilledSessions.add(session.sessionId);
await terminal.kill({ paneId: session.sessionId });
await client.kill({ sessionId: session.sessionId });
}

return { daemonModeEnabled: true, killedCount: toKill.length };
}),

clearTerminalHistory: publicProcedure.mutation(async () => {
// Note: Disk-based terminal history was removed. This is now a no-op
// for non-daemon mode. In daemon mode, it resets the history persistence.
if (terminal.management) {
await terminal.management.resetHistoryPersistence();
}

return { success: true };
}),

Expand Down
1 change: 0 additions & 1 deletion apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,6 @@ if (!gotTheLock) {
await reconcileDaemonSessions();

// Shutdown orphaned daemon if persistence is disabled
// (cleans up daemon left from previous session with persistence enabled)
await shutdownOrphanedDaemon();

try {
Expand Down
7 changes: 6 additions & 1 deletion apps/desktop/src/main/lib/menu-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@ export type SettingsSection =
| "project"
| "workspace"
| "appearance"
| "keyboard";
| "keyboard"
| "terminal";

export interface OpenSettingsEvent {
section?: SettingsSection;
}

export interface OpenWorkspaceEvent {
workspaceId: string;
}

export const menuEmitter = new EventEmitter();
61 changes: 36 additions & 25 deletions apps/desktop/src/main/lib/terminal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
disposeTerminalHostClient,
getTerminalHostClient,
} from "main/lib/terminal-host/client";
import type { ListSessionsResponse } from "main/lib/terminal-host/types";
import { DEFAULT_TERMINAL_PERSISTENCE } from "shared/constants";
import {
DaemonTerminalManager,
Expand All @@ -25,46 +26,38 @@ export type {
// Terminal Manager Selection
// =============================================================================

// Cache the daemon mode setting to avoid repeated DB reads
// This is set once at app startup and doesn't change until restart
let cachedDaemonMode: boolean | null = null;
const DEBUG_TERMINAL = process.env.SUPERSET_TERMINAL_DEBUG === "1";

/**
* Check if daemon mode is enabled.
* Reads from user settings (terminalPersistence) or falls back to env var.
* The value is cached since it requires app restart to take effect.
*/
export function isDaemonModeEnabled(): boolean {
// Return cached value if available
if (cachedDaemonMode !== null) {
return cachedDaemonMode;
}

// First check environment variable override (for development/testing)
if (process.env.SUPERSET_TERMINAL_DAEMON === "1") {
console.log(
"[TerminalManager] Daemon mode: ENABLED (via SUPERSET_TERMINAL_DAEMON env var)",
);
cachedDaemonMode = true;
if (DEBUG_TERMINAL) {
console.log(
"[TerminalManager] Daemon mode: ENABLED (via SUPERSET_TERMINAL_DAEMON env var)",
);
}
return true;
}

// Read from user settings
try {
const row = localDb.select().from(settings).get();
const enabled = row?.terminalPersistence ?? DEFAULT_TERMINAL_PERSISTENCE;
console.log(
`[TerminalManager] Daemon mode: ${enabled ? "ENABLED" : "DISABLED"} (via settings.terminalPersistence)`,
);
cachedDaemonMode = enabled;
if (DEBUG_TERMINAL) {
console.log(
`[TerminalManager] Daemon mode: ${enabled ? "ENABLED" : "DISABLED"} (via settings.terminalPersistence)`,
);
}
return enabled;
} catch (error) {
console.warn(
"[TerminalManager] Failed to read settings, defaulting to disabled:",
error,
);
cachedDaemonMode = DEFAULT_TERMINAL_PERSISTENCE;
return DEFAULT_TERMINAL_PERSISTENCE;
}
}
Expand Down Expand Up @@ -117,20 +110,16 @@ export async function reconcileDaemonSessions(): Promise<void> {

/**
* Shutdown any orphaned daemon process.
* Should be called on app startup when daemon mode is disabled to clean up
* Called on app startup when daemon mode is disabled to clean up
* any daemon left running from a previous session with persistence enabled.
*
* Uses shutdownIfRunning() to avoid spawning a new daemon just to shut it down.
*/
export async function shutdownOrphanedDaemon(): Promise<void> {
if (isDaemonModeEnabled()) {
// Daemon mode is enabled, don't shutdown
return;
}

try {
const client = getTerminalHostClient();
// Use shutdownIfRunning to avoid spawning a daemon if none exists
const { wasRunning } = await client.shutdownIfRunning({
killSessions: true,
});
Expand All @@ -140,13 +129,35 @@ export async function shutdownOrphanedDaemon(): Promise<void> {
console.log("[TerminalManager] No orphaned daemon to shutdown");
}
} catch (error) {
// Unexpected error during shutdown attempt
console.warn(
"[TerminalManager] Error during orphan daemon cleanup:",
error,
);
} finally {
// Always dispose the client to clean up any partial state
disposeTerminalHostClient();
}
}

export async function tryListExistingDaemonSessions(): Promise<{
daemonRunning: boolean;
sessions: ListSessionsResponse["sessions"];
}> {
try {
const client = getTerminalHostClient();
const connected = await client.tryConnectAndAuthenticate();
if (!connected) {
return { daemonRunning: false, sessions: [] };
}

const result = await client.listSessions();
return { daemonRunning: true, sessions: result.sessions };
} catch (error) {
if (DEBUG_TERMINAL) {
console.log(
"[TerminalManager] Failed to list existing daemon sessions:",
error,
);
}
return { daemonRunning: false, sessions: [] };
}
}
Loading
Loading