From 45a9a13e2ef40d1e32a9d8e685e61af559d47e3c Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 23 Nov 2025 23:43:12 -0800 Subject: [PATCH 1/7] working setup worktree using script --- .../routers/workspaces/utils/setup.test.ts | 125 ++++++++++++++++++ .../trpc/routers/workspaces/utils/setup.ts | 79 +++++++++++ .../lib/trpc/routers/workspaces/workspaces.ts | 35 ++++- .../workspaces/useCreateWorkspace.ts | 17 ++- .../TabsContent/Terminal/Terminal.tsx | 53 +++++++- .../renderer/stores/tabs/helpers/tab-crud.ts | 36 +++++ .../desktop/src/renderer/stores/tabs/store.ts | 18 +++ .../desktop/src/renderer/stores/tabs/types.ts | 10 ++ 8 files changed, 368 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.test.ts create mode 100644 apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.ts diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.test.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.test.ts new file mode 100644 index 0000000000..a74dd9c53d --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { copySetupFiles, loadSetupConfig } from "./setup"; + +const TEST_DIR = join(__dirname, ".test-tmp"); +const MAIN_REPO = join(TEST_DIR, "main-repo"); +const WORKTREE = join(TEST_DIR, "worktree"); + +describe("loadSetupConfig", () => { + beforeEach(() => { + // Create test directories + mkdirSync(join(MAIN_REPO, ".superset"), { recursive: true }); + }); + + afterEach(() => { + // Clean up + if (existsSync(TEST_DIR)) { + rmSync(TEST_DIR, { recursive: true, force: true }); + } + }); + + test("returns null when setup.json does not exist", () => { + const config = loadSetupConfig(MAIN_REPO); + expect(config).toBeNull(); + }); + + test("loads valid setup config", () => { + const setupConfig = { + copy: ["*.env", "package.json"], + commands: ["npm install", "npm run build"], + }; + + writeFileSync( + join(MAIN_REPO, ".superset", "setup.json"), + JSON.stringify(setupConfig), + ); + + const config = loadSetupConfig(MAIN_REPO); + expect(config).toEqual(setupConfig); + }); + + test("returns null for invalid JSON", () => { + writeFileSync(join(MAIN_REPO, ".superset", "setup.json"), "{ invalid json"); + + const config = loadSetupConfig(MAIN_REPO); + expect(config).toBeNull(); + }); + + test("validates copy field must be an array", () => { + writeFileSync( + join(MAIN_REPO, ".superset", "setup.json"), + JSON.stringify({ copy: "not-an-array" }), + ); + + const config = loadSetupConfig(MAIN_REPO); + expect(config).toBeNull(); + }); +}); + +describe("copySetupFiles", () => { + beforeEach(() => { + // Create test directories + mkdirSync(MAIN_REPO, { recursive: true }); + mkdirSync(WORKTREE, { recursive: true }); + }); + + afterEach(() => { + // Clean up + if (existsSync(TEST_DIR)) { + rmSync(TEST_DIR, { recursive: true, force: true }); + } + }); + + test("returns empty result for empty patterns", async () => { + const result = await copySetupFiles(MAIN_REPO, WORKTREE, []); + expect(result.copied).toEqual([]); + expect(result.errors).toEqual([]); + }); + + test("copies matching files", async () => { + // Create test files + writeFileSync(join(MAIN_REPO, "test.txt"), "test content"); + writeFileSync(join(MAIN_REPO, "README.md"), "readme"); + + const result = await copySetupFiles(MAIN_REPO, WORKTREE, ["*.txt"]); + + expect(result.copied).toContain("test.txt"); + expect(result.errors).toEqual([]); + expect(existsSync(join(WORKTREE, "test.txt"))).toBe(true); + }); + + test("creates nested directories", async () => { + // Create nested file + mkdirSync(join(MAIN_REPO, "src"), { recursive: true }); + writeFileSync(join(MAIN_REPO, "src", "index.ts"), "export {}"); + + const result = await copySetupFiles(MAIN_REPO, WORKTREE, ["src/**/*.ts"]); + + expect(result.copied).toContain("src/index.ts"); + expect(existsSync(join(WORKTREE, "src", "index.ts"))).toBe(true); + }); + + test("reports errors for files that don't match", async () => { + const result = await copySetupFiles(MAIN_REPO, WORKTREE, [ + "nonexistent.txt", + ]); + + expect(result.copied).toEqual([]); + expect(result.errors.length).toBeGreaterThan(0); + }); + + test("copies multiple files matching glob pattern", async () => { + writeFileSync(join(MAIN_REPO, "file1.txt"), "content1"); + writeFileSync(join(MAIN_REPO, "file2.txt"), "content2"); + writeFileSync(join(MAIN_REPO, "file.md"), "markdown"); + + const result = await copySetupFiles(MAIN_REPO, WORKTREE, ["*.txt"]); + + expect(result.copied).toContain("file1.txt"); + expect(result.copied).toContain("file2.txt"); + expect(result.copied).not.toContain("file.md"); + expect(result.errors).toEqual([]); + }); +}); diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.ts new file mode 100644 index 0000000000..ad02be9cd8 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.ts @@ -0,0 +1,79 @@ +import { existsSync, readFileSync } from "node:fs"; +import { copyFile, mkdir } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import fg from "fast-glob"; +import type { SetupConfig } from "shared/types"; + +export function loadSetupConfig(mainRepoPath: string): SetupConfig | null { + const configPath = join(mainRepoPath, ".superset", "setup.json"); + + if (!existsSync(configPath)) { + return null; + } + + try { + const content = readFileSync(configPath, "utf-8"); + const parsed = JSON.parse(content) as SetupConfig; + + if (parsed.copy && !Array.isArray(parsed.copy)) { + throw new Error("'copy' field must be an array of strings"); + } + + if (parsed.commands && !Array.isArray(parsed.commands)) { + throw new Error("'commands' field must be an array of strings"); + } + + return parsed; + } catch (error) { + console.error( + `Failed to read setup config at ${configPath}: ${error instanceof Error ? error.message : String(error)}`, + ); + return null; + } +} + +export async function copySetupFiles( + mainRepoPath: string, + worktreePath: string, + patterns: string[], +): Promise<{ copied: string[]; errors: string[] }> { + const copied: string[] = []; + const errors: string[] = []; + + for (const pattern of patterns) { + try { + const matches = await fg(pattern, { + cwd: mainRepoPath, + dot: true, + followSymbolicLinks: false, + onlyFiles: true, + }); + + if (matches.length === 0) { + errors.push(`No files matched pattern: ${pattern}`); + continue; + } + + for (const relativePath of matches) { + const sourcePath = join(mainRepoPath, relativePath); + const destinationPath = join(worktreePath, relativePath); + + try { + await mkdir(dirname(destinationPath), { recursive: true }); + await copyFile(sourcePath, destinationPath); + copied.push(relativePath); + } catch (copyError) { + errors.push( + `Failed to copy ${relativePath}: ${copyError instanceof Error ? copyError.message : String(copyError)}`, + ); + } + } + } catch (globError) { + errors.push( + `Failed to process pattern '${pattern}': ${globError instanceof Error ? globError.message : String(globError)}`, + ); + } + } + + return { copied, errors }; +} diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts index 4791448f46..dc667fe3d6 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts @@ -9,6 +9,7 @@ import { generateBranchName, removeWorktree, } from "./utils/git"; +import { copySetupFiles, loadSetupConfig } from "./utils/setup"; export const createWorkspacesRouter = () => { return router({ @@ -80,7 +81,37 @@ export const createWorkspacesRouter = () => { } }); - return workspace; + // Load setup configuration + const setupConfig = loadSetupConfig(project.mainRepoPath); + let setupCopyResults: { copied: string[]; errors: string[] } | null = + null; + + // Copy setup files if config exists and has copy patterns + if (setupConfig?.copy && setupConfig.copy.length > 0) { + try { + setupCopyResults = await copySetupFiles( + project.mainRepoPath, + worktreePath, + setupConfig.copy, + ); + } catch (error) { + console.error("Failed to copy setup files:", error); + // Non-fatal: return error info but continue + setupCopyResults = { + copied: [], + errors: [ + `Setup file copy failed: ${error instanceof Error ? error.message : String(error)}`, + ], + }; + } + } + + return { + workspace, + setupConfig: setupConfig?.commands || null, + setupCopyResults, + worktreePath, + }; }), get: publicProcedure @@ -142,7 +173,7 @@ export const createWorkspacesRouter = () => { for (const workspace of workspaces) { if (groupsMap.has(workspace.projectId)) { - groupsMap.get(workspace.projectId)!.workspaces.push(workspace); + groupsMap.get(workspace.projectId)?.workspaces.push(workspace); } } diff --git a/apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts b/apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts index 049cac6eed..c20b6ad0e9 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts @@ -1,22 +1,35 @@ import { trpc } from "renderer/lib/trpc"; +import { useTabsStore } from "renderer/stores/tabs/store"; /** * Mutation hook for creating a new workspace * Automatically invalidates all workspace queries on success + * Creates a setup tab if setup commands are present */ export function useCreateWorkspace( options?: Parameters[0], ) { const utils = trpc.useUtils(); + const addSetupTab = useTabsStore((state) => state.addSetupTab); return trpc.workspaces.create.useMutation({ ...options, - onSuccess: async (...args) => { + onSuccess: async (data, ...rest) => { // Auto-invalidate all workspace queries await utils.workspaces.invalidate(); + // Create setup tab if setup commands are present + if (data.setupConfig && data.setupConfig.length > 0) { + addSetupTab( + data.workspace.id, + data.setupConfig, + data.worktreePath, + data.setupCopyResults || undefined, + ); + } + // Call user's onSuccess if provided - await options?.onSuccess?.(...args); + await options?.onSuccess?.(data, ...rest); }, }); } 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 a9a09d68ae..234534b3e1 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 @@ -3,7 +3,7 @@ import type { FitAddon } from "@xterm/addon-fit"; import type { Terminal as XTerm } from "@xterm/xterm"; import { useEffect, useRef, useState } from "react"; import { trpc } from "renderer/lib/trpc"; -import { useSetActiveTab, useTabs } from "renderer/stores"; +import { useSetActiveTab, useTabs, useTabsStore } from "renderer/stores"; import { createTerminalInstance, setupFocusListener, @@ -22,6 +22,17 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const pendingEventsRef = useRef([]); const [subscriptionEnabled, setSubscriptionEnabled] = useState(false); const setActiveTab = useSetActiveTab(); + const setupExecutedRef = useRef(false); + + // Store setup data in refs to avoid effect re-runs + const setupPendingRef = useRef(tab?.setupPending); + const setupCommandsRef = useRef(tab?.setupCommands); + const setupCopyResultsRef = useRef(tab?.setupCopyResults); + + // Update refs when tab changes + setupPendingRef.current = tab?.setupPending; + setupCommandsRef.current = tab?.setupCommands; + setupCopyResultsRef.current = tab?.setupCopyResults; // Get the workspace CWD for resolving relative file paths const { data: workspaceCwd } = @@ -161,6 +172,46 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { applyInitialScrollback(result); setSubscriptionEnabled(true); flushPendingEvents(); + + // Execute setup commands if this is a setup tab and setup hasn't been executed yet + if ( + setupPendingRef.current && + setupCommandsRef.current && + !setupExecutedRef.current + ) { + setupExecutedRef.current = true; + + // Print copy results if available + if (setupCopyResultsRef.current) { + const { copied, errors } = setupCopyResultsRef.current; + if (copied.length > 0) { + xterm.writeln( + `\r\n\x1b[32m✓ Copied ${copied.length} file(s):\x1b[0m`, + ); + for (const file of copied) { + xterm.writeln(` - ${file}`); + } + } + if (errors.length > 0) { + xterm.writeln(`\r\n\x1b[33m⚠ Copy warnings:\x1b[0m`); + for (const error of errors) { + xterm.writeln(` ${error}`); + } + } + xterm.writeln("\r\n"); + } + + // Send all commands sequentially + const commands = `${setupCommandsRef.current.join("\n")}\n`; + writeRef.current({ tabId, data: commands }); + + // Mark setup as no longer pending + useTabsStore.setState((state) => ({ + tabs: state.tabs.map((t) => + t.id === tabId ? { ...t, setupPending: false } : t, + ), + })); + } }, onError: () => { setSubscriptionEnabled(true); diff --git a/apps/desktop/src/renderer/stores/tabs/helpers/tab-crud.ts b/apps/desktop/src/renderer/stores/tabs/helpers/tab-crud.ts index 5355c8a688..7d0318f6fe 100644 --- a/apps/desktop/src/renderer/stores/tabs/helpers/tab-crud.ts +++ b/apps/desktop/src/renderer/stores/tabs/helpers/tab-crud.ts @@ -94,3 +94,39 @@ export const handleMarkTabAsUsed = ( ), }; }; + +export const handleAddSetupTab = ( + state: TabsState, + workspaceId: string, + setupCommands: string[], + setupCwd: string, + setupCopyResults?: { copied: string[]; errors: string[] }, +): Partial => { + const newTab = createNewTab(workspaceId, TabType.Single, state.tabs); + const setupTab = { + ...newTab, + title: "Setup Worktree", + setupCommands, + setupCwd, + setupPending: true, + setupCopyResults, + }; + + const currentActiveId = state.activeTabIds[workspaceId]; + const historyStack = state.tabHistoryStacks[workspaceId] || []; + const newHistoryStack = currentActiveId + ? [currentActiveId, ...historyStack.filter((id) => id !== currentActiveId)] + : historyStack; + + return { + tabs: [setupTab, ...state.tabs], + activeTabIds: { + ...state.activeTabIds, + [workspaceId]: setupTab.id, + }, + tabHistoryStacks: { + ...state.tabHistoryStacks, + [workspaceId]: newHistoryStack, + }, + }; +}; diff --git a/apps/desktop/src/renderer/stores/tabs/store.ts b/apps/desktop/src/renderer/stores/tabs/store.ts index 8331a02fcd..2b839aa339 100644 --- a/apps/desktop/src/renderer/stores/tabs/store.ts +++ b/apps/desktop/src/renderer/stores/tabs/store.ts @@ -20,6 +20,7 @@ import { handleSplitTabVertical, } from "./helpers/split-operations"; import { + handleAddSetupTab, handleAddTab, handleMarkTabAsUsed, handleRemoveTab, @@ -44,6 +45,23 @@ export const useTabsStore = create()( set((state) => handleAddTab(state, workspaceId, type)); }, + addSetupTab: ( + workspaceId, + setupCommands, + setupCwd, + setupCopyResults, + ) => { + set((state) => + handleAddSetupTab( + state, + workspaceId, + setupCommands, + setupCwd, + setupCopyResults, + ), + ); + }, + removeTab: (id) => { const state = get(); const result = handleRemoveTab(state, id); diff --git a/apps/desktop/src/renderer/stores/tabs/types.ts b/apps/desktop/src/renderer/stores/tabs/types.ts index 47efaefac8..90340c73a3 100644 --- a/apps/desktop/src/renderer/stores/tabs/types.ts +++ b/apps/desktop/src/renderer/stores/tabs/types.ts @@ -12,6 +12,10 @@ interface BaseTab { isNew?: boolean; parentId?: string; needsAttention?: boolean; + setupCommands?: string[]; + setupCwd?: string; + setupPending?: boolean; + setupCopyResults?: { copied: string[]; errors: string[] }; } export interface SingleTab extends BaseTab { @@ -33,6 +37,12 @@ export interface TabsState { export interface TabsStore extends TabsState { addTab: (workspaceId: string, type?: TabType) => void; + addSetupTab: ( + workspaceId: string, + setupCommands: string[], + setupCwd: string, + setupCopyResults?: { copied: string[]; errors: string[] }, + ) => void; removeTab: (id: string) => void; renameTab: (id: string, newTitle: string) => void; setActiveTab: (workspaceId: string, tabId: string) => void; From 0365130280447433b9ee0d1fa50d03cad9d9838a Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 25 Nov 2025 15:07:29 -0600 Subject: [PATCH 2/7] clean up --- .../src/lib/trpc/routers/notifications.ts | 2 +- apps/desktop/src/main/lib/agent-setup.ts | 2 +- .../src/main/lib/terminal-history.test.ts | 6 +- apps/desktop/src/main/lib/terminal-manager.ts | 2 +- apps/desktop/src/main/windows/main.ts | 4 +- apps/desktop/src/renderer/globals.css | 4 +- .../WorkspaceTabs/WorkspaceDropdown.tsx | 2 +- .../TabsContent/GroupTabView/GroupTabPane.tsx | 8 +- .../ContentView/TabsContent/SingleTabView.tsx | 4 +- .../TabsContent/Terminal/SetupTerminal.tsx | 39 ++++ .../TabsContent/Terminal/Terminal.tsx | 79 ++----- .../ContentView/TabsContent/Terminal/index.ts | 1 + .../ContentView/TabsContent/Terminal/types.ts | 9 + .../TabsContent/Terminal/useTerminalSetup.ts | 70 ++++++ .../src/shared/ipc-channels/external.ts | 2 +- apps/desktop/test-setup.ts | 16 +- .../app/components/DownloadButton/index.ts | 1 - .../src/app/components/Header/Header.tsx | 4 +- .../components/JoinWaitlistButton/index.ts | 1 - .../components/SocialLinks/SocialLinks.tsx | 1 - .../src/app/components/SocialLinks/index.ts | 1 - .../components/VideoSection/VideoSection.tsx | 1 - .../src/app/components/VideoSection/index.ts | 1 - packages/ui/src/components/alert-dialog.tsx | 216 +++++++++--------- 24 files changed, 278 insertions(+), 198 deletions(-) create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/SetupTerminal.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/useTerminalSetup.ts diff --git a/apps/desktop/src/lib/trpc/routers/notifications.ts b/apps/desktop/src/lib/trpc/routers/notifications.ts index 0498013729..bae8e0691d 100644 --- a/apps/desktop/src/lib/trpc/routers/notifications.ts +++ b/apps/desktop/src/lib/trpc/routers/notifications.ts @@ -1,7 +1,7 @@ import { observable } from "@trpc/server/observable"; import { - notificationsEmitter, type AgentCompleteEvent, + notificationsEmitter, } from "main/lib/notifications/server"; import { publicProcedure, router } from ".."; diff --git a/apps/desktop/src/main/lib/agent-setup.ts b/apps/desktop/src/main/lib/agent-setup.ts index 1ae103b92a..d04d7e2dc8 100644 --- a/apps/desktop/src/main/lib/agent-setup.ts +++ b/apps/desktop/src/main/lib/agent-setup.ts @@ -1,7 +1,7 @@ +import { execSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { execSync } from "node:child_process"; import { NOTIFICATIONS_PORT } from "shared/constants"; const SUPERSET_DIR = path.join(os.homedir(), ".superset"); diff --git a/apps/desktop/src/main/lib/terminal-history.test.ts b/apps/desktop/src/main/lib/terminal-history.test.ts index d57836179d..ca22644b2e 100644 --- a/apps/desktop/src/main/lib/terminal-history.test.ts +++ b/apps/desktop/src/main/lib/terminal-history.test.ts @@ -2,11 +2,11 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { promises as fs } from "node:fs"; import { join } from "node:path"; import { - HistoryReader, - HistoryWriter, getHistoryDir, getHistoryFilePath, getMetadataPath, + HistoryReader, + HistoryWriter, type SessionMetadata, } from "./terminal-history"; @@ -379,7 +379,7 @@ describe("Terminal history integration", () => { await fs.stat(historyDir); throw new Error("Directory should not exist"); } catch (error) { - // @ts-ignore + // @ts-expect-error expect(error.code).toBe("ENOENT"); } }); diff --git a/apps/desktop/src/main/lib/terminal-manager.ts b/apps/desktop/src/main/lib/terminal-manager.ts index 742f2e530b..f2520f3374 100644 --- a/apps/desktop/src/main/lib/terminal-manager.ts +++ b/apps/desktop/src/main/lib/terminal-manager.ts @@ -1,8 +1,8 @@ import { EventEmitter } from "node:events"; import os from "node:os"; import * as pty from "node-pty"; -import { getSupersetPath } from "./agent-setup"; import { NOTIFICATIONS_PORT } from "shared/constants"; +import { getSupersetPath } from "./agent-setup"; import { HistoryReader, HistoryWriter } from "./terminal-history"; interface TerminalSession { diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index f2bcfeab77..7612786928 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -6,10 +6,10 @@ import { createIPCHandler } from "trpc-electron/main"; import { displayName } from "~/package.json"; import { createApplicationMenu } from "../lib/menu"; import { + type AgentCompleteEvent, + NOTIFICATIONS_PORT, notificationsApp, notificationsEmitter, - NOTIFICATIONS_PORT, - type AgentCompleteEvent, } from "../lib/notifications/server"; export async function MainWindow() { diff --git a/apps/desktop/src/renderer/globals.css b/apps/desktop/src/renderer/globals.css index 3dd81ba409..1d0b9f4990 100644 --- a/apps/desktop/src/renderer/globals.css +++ b/apps/desktop/src/renderer/globals.css @@ -23,7 +23,7 @@ --accent: oklch(0.97 0 0); --accent-foreground: oklch(0.205 0 0); --tertiary: oklch(0.12 0.003 40); - --tertiary-active: oklch(0.20 0.003 40); + --tertiary-active: oklch(0.2 0.003 40); --destructive: oklch(0.577 0.245 27.325); --destructive-foreground: oklch(0.577 0.245 27.325); --border: oklch(0.922 0 0); @@ -61,7 +61,7 @@ --accent: oklch(0.269 0 0); --accent-foreground: oklch(0.985 0 0); --tertiary: oklch(0.18 0.005 40); - --tertiary-active: oklch(0.24 0.005 40); + --tertiary-active: oklch(0.24 0.005 40); --destructive: oklch(0.396 0.141 25.723); --destructive-foreground: oklch(0.637 0.237 25.331); --border: oklch(0.269 0 0); diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceDropdown.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceDropdown.tsx index 6bbb33e966..032553d7c8 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceDropdown.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceDropdown.tsx @@ -4,8 +4,8 @@ import { DropdownMenuContent, DropdownMenuTrigger, } from "@superset/ui/dropdown-menu"; -import { HiMiniFolderOpen, HiMiniPlus } from "react-icons/hi2"; import { useState } from "react"; +import { HiMiniFolderOpen, HiMiniPlus } from "react-icons/hi2"; import { trpc } from "renderer/lib/trpc"; import { useOpenNew } from "renderer/react-query/projects"; import { useCreateWorkspace } from "renderer/react-query/workspaces"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupTabView/GroupTabPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupTabView/GroupTabPane.tsx index d4e5ef147c..013da0c285 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupTabView/GroupTabPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupTabView/GroupTabPane.tsx @@ -1,10 +1,10 @@ +import { Button } from "@superset/ui/button"; +import { HiMiniXMark } from "react-icons/hi2"; import type { MosaicBranch } from "react-mosaic-component"; import { MosaicWindow } from "react-mosaic-component"; -import { HiMiniXMark } from "react-icons/hi2"; import type { Tab } from "renderer/stores"; import { TabContentContextMenu } from "../TabContentContextMenu"; -import { Terminal } from "../Terminal"; -import { Button } from "@superset/ui/button"; +import { SetupTerminal } from "../Terminal"; interface GroupTabPaneProps { tabId: string; @@ -71,7 +71,7 @@ export function GroupTabPane({ onClosePane={() => removeChildTabFromGroup(groupId, tabId)} >
- +
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SingleTabView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SingleTabView.tsx index cd646df9ca..bc58f572df 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SingleTabView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SingleTabView.tsx @@ -6,7 +6,7 @@ import { useSplitTabVertical, } from "renderer/stores"; import { TabContentContextMenu } from "./TabContentContextMenu"; -import { Terminal } from "./Terminal"; +import { SetupTerminal } from "./Terminal"; interface SingleTabViewProps { tab: SingleTab; @@ -42,7 +42,7 @@ export function SingleTabView({ tab }: SingleTabViewProps) { onClosePane={handleClosePane} >
- +
); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/SetupTerminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/SetupTerminal.tsx new file mode 100644 index 0000000000..f39a769606 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/SetupTerminal.tsx @@ -0,0 +1,39 @@ +import { useTabs } from "renderer/stores"; +import { Terminal } from "./Terminal"; +import type { TerminalSession } from "./types"; +import { useTerminalSetup } from "./useTerminalSetup"; + +interface SetupTerminalProps { + tabId: string; + workspaceId: string; +} + +/** + * Wrapper component that handles terminal setup logic. + * Reads setup metadata from the tab store and executes setup when the terminal session is ready. + */ +export function SetupTerminal({ tabId, workspaceId }: SetupTerminalProps) { + const tabs = useTabs(); + const tab = tabs.find((t) => t.id === tabId); + + const { executeSetup } = useTerminalSetup({ + tabId, + setupPending: tab?.setupPending, + setupCommands: tab?.setupCommands, + setupCopyResults: tab?.setupCopyResults, + }); + + const handleSessionReady = (session: TerminalSession) => { + // Execute setup with the terminal session + executeSetup(session); + }; + + return ( + + ); +} 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 234534b3e1..79aa410207 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 @@ -3,7 +3,7 @@ import type { FitAddon } from "@xterm/addon-fit"; import type { Terminal as XTerm } from "@xterm/xterm"; import { useEffect, useRef, useState } from "react"; import { trpc } from "renderer/lib/trpc"; -import { useSetActiveTab, useTabs, useTabsStore } from "renderer/stores"; +import { useSetActiveTab } from "renderer/stores"; import { createTerminalInstance, setupFocusListener, @@ -11,10 +11,13 @@ import { } from "./helpers"; import type { TerminalProps, TerminalStreamEvent } from "./types"; -export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { - const tabs = useTabs(); - const tab = tabs.find((t) => t.id === tabId); - const tabTitle = tab?.title || "Terminal"; +export const Terminal = ({ + tabId, + workspaceId, + title, + onSessionReady, +}: TerminalProps) => { + const tabTitle = title || "Terminal"; const terminalRef = useRef(null); const xtermRef = useRef(null); const fitAddonRef = useRef(null); @@ -22,17 +25,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const pendingEventsRef = useRef([]); const [subscriptionEnabled, setSubscriptionEnabled] = useState(false); const setActiveTab = useSetActiveTab(); - const setupExecutedRef = useRef(false); - - // Store setup data in refs to avoid effect re-runs - const setupPendingRef = useRef(tab?.setupPending); - const setupCommandsRef = useRef(tab?.setupCommands); - const setupCopyResultsRef = useRef(tab?.setupCopyResults); - - // Update refs when tab changes - setupPendingRef.current = tab?.setupPending; - setupCommandsRef.current = tab?.setupCommands; - setupCopyResultsRef.current = tab?.setupCopyResults; + const sessionReadyCalledRef = useRef(false); // Get the workspace CWD for resolving relative file paths const { data: workspaceCwd } = @@ -173,44 +166,13 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { setSubscriptionEnabled(true); flushPendingEvents(); - // Execute setup commands if this is a setup tab and setup hasn't been executed yet - if ( - setupPendingRef.current && - setupCommandsRef.current && - !setupExecutedRef.current - ) { - setupExecutedRef.current = true; - - // Print copy results if available - if (setupCopyResultsRef.current) { - const { copied, errors } = setupCopyResultsRef.current; - if (copied.length > 0) { - xterm.writeln( - `\r\n\x1b[32m✓ Copied ${copied.length} file(s):\x1b[0m`, - ); - for (const file of copied) { - xterm.writeln(` - ${file}`); - } - } - if (errors.length > 0) { - xterm.writeln(`\r\n\x1b[33m⚠ Copy warnings:\x1b[0m`); - for (const error of errors) { - xterm.writeln(` ${error}`); - } - } - xterm.writeln("\r\n"); - } - - // Send all commands sequentially - const commands = `${setupCommandsRef.current.join("\n")}\n`; - writeRef.current({ tabId, data: commands }); - - // Mark setup as no longer pending - useTabsStore.setState((state) => ({ - tabs: state.tabs.map((t) => - t.id === tabId ? { ...t, setupPending: false } : t, - ), - })); + // Call onSessionReady callback if provided and not already called + if (onSessionReady && !sessionReadyCalledRef.current) { + sessionReadyCalledRef.current = true; + onSessionReady({ + xterm, + write: (data: string) => writeRef.current({ tabId, data }), + }); } }, onError: () => { @@ -245,7 +207,14 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { xterm.dispose(); xtermRef.current = null; }; - }, [tabId, workspaceId, setActiveTab, workspaceCwd, tabTitle]); + }, [ + tabId, + workspaceId, + setActiveTab, + workspaceCwd, + tabTitle, + onSessionReady, + ]); return (
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/index.ts index 5aaa605e86..3949333e6a 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/index.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/index.ts @@ -1 +1,2 @@ +export { SetupTerminal } from "./SetupTerminal"; export { Terminal } from "./Terminal"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/types.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/types.ts index 29e41dff6d..a2d6cbc289 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/types.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/types.ts @@ -1,6 +1,15 @@ +import type { Terminal as XTerm } from "@xterm/xterm"; + +export interface TerminalSession { + xterm: XTerm; + write: (data: string) => void; +} + export interface TerminalProps { tabId: string; workspaceId: string; + title?: string; + onSessionReady?: (session: TerminalSession) => void; } export type TerminalStreamEvent = diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/useTerminalSetup.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/useTerminalSetup.ts new file mode 100644 index 0000000000..22f4b59f8a --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/useTerminalSetup.ts @@ -0,0 +1,70 @@ +import { useRef } from "react"; +import { useTabsStore } from "renderer/stores"; +import type { TerminalSession } from "./types"; + +interface SetupCopyResults { + copied: string[]; + errors: string[]; +} + +interface UseTerminalSetupParams { + tabId: string; + setupPending?: boolean; + setupCommands?: string[]; + setupCopyResults?: SetupCopyResults; +} + +/** + * Hook to handle terminal setup execution. + * Prints copy results and sends setup commands when a terminal session is ready. + */ +export function useTerminalSetup({ + tabId, + setupPending, + setupCommands, + setupCopyResults, +}: UseTerminalSetupParams) { + const setupExecutedRef = useRef(false); + + const executeSetup = (session: TerminalSession) => { + // Only execute setup once and only if setup is pending + if (!setupPending || !setupCommands || setupExecutedRef.current) { + return; + } + + setupExecutedRef.current = true; + + // Print copy results if available + if (setupCopyResults) { + const { copied, errors } = setupCopyResults; + if (copied.length > 0) { + session.xterm.writeln( + `\r\n\x1b[32m✓ Copied ${copied.length} file(s):\x1b[0m`, + ); + for (const file of copied) { + session.xterm.writeln(` - ${file}`); + } + } + if (errors.length > 0) { + session.xterm.writeln(`\r\n\x1b[33m⚠ Copy warnings:\x1b[0m`); + for (const error of errors) { + session.xterm.writeln(` ${error}`); + } + } + session.xterm.writeln("\r\n"); + } + + // Send all commands sequentially + const commands = `${setupCommands.join("\n")}\n`; + session.write(commands); + + // Mark setup as no longer pending + useTabsStore.setState((state) => ({ + tabs: state.tabs.map((t) => + t.id === tabId ? { ...t, setupPending: false } : t, + ), + })); + }; + + return { executeSetup }; +} diff --git a/apps/desktop/src/shared/ipc-channels/external.ts b/apps/desktop/src/shared/ipc-channels/external.ts index 5aecd75d99..c88c315a62 100644 --- a/apps/desktop/src/shared/ipc-channels/external.ts +++ b/apps/desktop/src/shared/ipc-channels/external.ts @@ -4,4 +4,4 @@ * This file exists for backwards compatibility with the IPC channels type system */ -export interface ExternalChannels {} +export type ExternalChannels = {}; diff --git a/apps/desktop/test-setup.ts b/apps/desktop/test-setup.ts index 8aace2f9e5..4a72445935 100644 --- a/apps/desktop/test-setup.ts +++ b/apps/desktop/test-setup.ts @@ -48,15 +48,13 @@ mock.module("electron", () => ({ ), showMessageBox: mock(() => Promise.resolve({ response: 0 })), }, - BrowserWindow: mock(function () { - return { - webContents: { - send: mock(), - }, - loadURL: mock(), - on: mock(), - }; - }), + BrowserWindow: mock(() => ({ + webContents: { + send: mock(), + }, + loadURL: mock(), + on: mock(), + })), ipcMain: { handle: mock(), on: mock(), diff --git a/apps/website/src/app/components/DownloadButton/index.ts b/apps/website/src/app/components/DownloadButton/index.ts index 78d98dd618..a9318c16f0 100644 --- a/apps/website/src/app/components/DownloadButton/index.ts +++ b/apps/website/src/app/components/DownloadButton/index.ts @@ -1,2 +1 @@ export { DownloadButton } from "./DownloadButton"; - diff --git a/apps/website/src/app/components/Header/Header.tsx b/apps/website/src/app/components/Header/Header.tsx index 49de247911..f61d4d90fb 100644 --- a/apps/website/src/app/components/Header/Header.tsx +++ b/apps/website/src/app/components/Header/Header.tsx @@ -3,10 +3,10 @@ import { motion } from "framer-motion"; import Image from "next/image"; import { useState } from "react"; -import { WaitlistModal } from "../WaitlistModal"; -import { JoinWaitlistButton } from "../JoinWaitlistButton"; import { DownloadButton } from "../DownloadButton"; +import { JoinWaitlistButton } from "../JoinWaitlistButton"; import { SocialLinks } from "../SocialLinks"; +import { WaitlistModal } from "../WaitlistModal"; export function Header() { const [isWaitlistOpen, setIsWaitlistOpen] = useState(false); diff --git a/apps/website/src/app/components/JoinWaitlistButton/index.ts b/apps/website/src/app/components/JoinWaitlistButton/index.ts index 42ee94b3d4..356905928c 100644 --- a/apps/website/src/app/components/JoinWaitlistButton/index.ts +++ b/apps/website/src/app/components/JoinWaitlistButton/index.ts @@ -1,2 +1 @@ export { JoinWaitlistButton } from "./JoinWaitlistButton"; - diff --git a/apps/website/src/app/components/SocialLinks/SocialLinks.tsx b/apps/website/src/app/components/SocialLinks/SocialLinks.tsx index 6c42129b29..0979c0e7c3 100644 --- a/apps/website/src/app/components/SocialLinks/SocialLinks.tsx +++ b/apps/website/src/app/components/SocialLinks/SocialLinks.tsx @@ -45,4 +45,3 @@ export function SocialLinks({ className = "" }: SocialLinksProps) {
); } - diff --git a/apps/website/src/app/components/SocialLinks/index.ts b/apps/website/src/app/components/SocialLinks/index.ts index 885d6cfb2c..3827fd5ec1 100644 --- a/apps/website/src/app/components/SocialLinks/index.ts +++ b/apps/website/src/app/components/SocialLinks/index.ts @@ -1,2 +1 @@ export { SocialLinks } from "./SocialLinks"; - diff --git a/apps/website/src/app/components/VideoSection/VideoSection.tsx b/apps/website/src/app/components/VideoSection/VideoSection.tsx index 4e0578a1e6..7abda4b813 100644 --- a/apps/website/src/app/components/VideoSection/VideoSection.tsx +++ b/apps/website/src/app/components/VideoSection/VideoSection.tsx @@ -45,4 +45,3 @@ export function VideoSection() { ); } - diff --git a/apps/website/src/app/components/VideoSection/index.ts b/apps/website/src/app/components/VideoSection/index.ts index 21f98a80da..4957750571 100644 --- a/apps/website/src/app/components/VideoSection/index.ts +++ b/apps/website/src/app/components/VideoSection/index.ts @@ -1,2 +1 @@ export { VideoSection } from "./VideoSection"; - diff --git a/packages/ui/src/components/alert-dialog.tsx b/packages/ui/src/components/alert-dialog.tsx index 9d776e860b..5f240bd276 100644 --- a/packages/ui/src/components/alert-dialog.tsx +++ b/packages/ui/src/components/alert-dialog.tsx @@ -1,155 +1,155 @@ -import * as React from "react" -import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; +import type * as React from "react"; -import { cn } from "../lib/utils" -import { buttonVariants } from "./button" +import { cn } from "../lib/utils"; +import { buttonVariants } from "./button"; function AlertDialog({ - ...props + ...props }: React.ComponentProps) { - return + return ; } function AlertDialogTrigger({ - ...props + ...props }: React.ComponentProps) { - return ( - - ) + return ( + + ); } function AlertDialogPortal({ - ...props + ...props }: React.ComponentProps) { - return ( - - ) + return ( + + ); } function AlertDialogOverlay({ - className, - ...props + className, + ...props }: React.ComponentProps) { - return ( - - ) + return ( + + ); } function AlertDialogContent({ - className, - ...props + className, + ...props }: React.ComponentProps) { - return ( - - - - - ) + return ( + + + + + ); } function AlertDialogHeader({ - className, - ...props + className, + ...props }: React.ComponentProps<"div">) { - return ( -
- ) + return ( +
+ ); } function AlertDialogFooter({ - className, - ...props + className, + ...props }: React.ComponentProps<"div">) { - return ( -
- ) + return ( +
+ ); } function AlertDialogTitle({ - className, - ...props + className, + ...props }: React.ComponentProps) { - return ( - - ) + return ( + + ); } function AlertDialogDescription({ - className, - ...props + className, + ...props }: React.ComponentProps) { - return ( - - ) + return ( + + ); } function AlertDialogAction({ - className, - ...props + className, + ...props }: React.ComponentProps) { - return ( - - ) + return ( + + ); } function AlertDialogCancel({ - className, - ...props + className, + ...props }: React.ComponentProps) { - return ( - - ) + return ( + + ); } export { - AlertDialog, - AlertDialogPortal, - AlertDialogOverlay, - AlertDialogTrigger, - AlertDialogContent, - AlertDialogHeader, - AlertDialogFooter, - AlertDialogTitle, - AlertDialogDescription, - AlertDialogAction, - AlertDialogCancel, -} + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; From 9cea400d949e216a4293db27d3e93bf40601b548 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 25 Nov 2025 15:10:22 -0600 Subject: [PATCH 3/7] clean setup terminal --- .../ContentView/TabsContent/GroupTabView/GroupTabPane.tsx | 7 +++++-- .../ContentView/TabsContent/SingleTabView.tsx | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupTabView/GroupTabPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupTabView/GroupTabPane.tsx index 013da0c285..5e206a38c0 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupTabView/GroupTabPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupTabView/GroupTabPane.tsx @@ -4,7 +4,7 @@ import type { MosaicBranch } from "react-mosaic-component"; import { MosaicWindow } from "react-mosaic-component"; import type { Tab } from "renderer/stores"; import { TabContentContextMenu } from "../TabContentContextMenu"; -import { SetupTerminal } from "../Terminal"; +import { SetupTerminal, Terminal } from "../Terminal"; interface GroupTabPaneProps { tabId: string; @@ -48,6 +48,9 @@ export function GroupTabPane({ removeChildTabFromGroup(groupId, tabId); }; + // Use SetupTerminal wrapper only for setup tabs, otherwise use base Terminal + const TerminalComponent = childTab.setupPending ? SetupTerminal : Terminal; + return ( path={path} @@ -71,7 +74,7 @@ export function GroupTabPane({ onClosePane={() => removeChildTabFromGroup(groupId, tabId)} >
- +
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SingleTabView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SingleTabView.tsx index bc58f572df..b39b7262b4 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SingleTabView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SingleTabView.tsx @@ -6,7 +6,7 @@ import { useSplitTabVertical, } from "renderer/stores"; import { TabContentContextMenu } from "./TabContentContextMenu"; -import { SetupTerminal } from "./Terminal"; +import { SetupTerminal, Terminal } from "./Terminal"; interface SingleTabViewProps { tab: SingleTab; @@ -35,6 +35,9 @@ export function SingleTabView({ tab }: SingleTabViewProps) { setActiveTab(tab.workspaceId, tab.id); }; + // Use SetupTerminal wrapper only for setup tabs, otherwise use base Terminal + const TerminalComponent = tab.setupPending ? SetupTerminal : Terminal; + return (
- +
); From e2a02fdad9374deaf3ae14a5d1cb6a57dd6bbf00 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 25 Nov 2025 15:19:20 -0600 Subject: [PATCH 4/7] add setup tab --- .../TabsContent/GroupTabView/GroupTabPane.tsx | 7 +-- .../ContentView/TabsContent/SetupTabView.tsx | 49 +++++++++++++++++++ .../ContentView/TabsContent/SingleTabView.tsx | 7 +-- .../TabsContent/Terminal/SetupTerminal.tsx | 15 ++++-- .../ContentView/TabsContent/index.tsx | 6 +++ .../renderer/stores/tabs/helpers/tab-crud.ts | 8 ++- .../desktop/src/renderer/stores/tabs/types.ts | 15 ++++-- 7 files changed, 85 insertions(+), 22 deletions(-) create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SetupTabView.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupTabView/GroupTabPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupTabView/GroupTabPane.tsx index 5e206a38c0..ed64ac2e32 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupTabView/GroupTabPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupTabView/GroupTabPane.tsx @@ -4,7 +4,7 @@ import type { MosaicBranch } from "react-mosaic-component"; import { MosaicWindow } from "react-mosaic-component"; import type { Tab } from "renderer/stores"; import { TabContentContextMenu } from "../TabContentContextMenu"; -import { SetupTerminal, Terminal } from "../Terminal"; +import { Terminal } from "../Terminal"; interface GroupTabPaneProps { tabId: string; @@ -48,9 +48,6 @@ export function GroupTabPane({ removeChildTabFromGroup(groupId, tabId); }; - // Use SetupTerminal wrapper only for setup tabs, otherwise use base Terminal - const TerminalComponent = childTab.setupPending ? SetupTerminal : Terminal; - return ( path={path} @@ -74,7 +71,7 @@ export function GroupTabPane({ onClosePane={() => removeChildTabFromGroup(groupId, tabId)} >
- +
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SetupTabView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SetupTabView.tsx new file mode 100644 index 0000000000..f7af1f8579 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SetupTabView.tsx @@ -0,0 +1,49 @@ +import { + type SetupTab, + useRemoveTab, + useSetActiveTab, + useSplitTabHorizontal, + useSplitTabVertical, +} from "renderer/stores"; +import { TabContentContextMenu } from "./TabContentContextMenu"; +import { SetupTerminal } from "./Terminal"; + +interface SetupTabViewProps { + tab: SetupTab; + isDropZone: boolean; +} + +export function SetupTabView({ tab }: SetupTabViewProps) { + const splitTabHorizontal = useSplitTabHorizontal(); + const splitTabVertical = useSplitTabVertical(); + const removeTab = useRemoveTab(); + const setActiveTab = useSetActiveTab(); + + const handleSplitHorizontal = () => { + splitTabHorizontal(tab.workspaceId, tab.id); + }; + + const handleSplitVertical = () => { + splitTabVertical(tab.workspaceId, tab.id); + }; + + const handleClosePane = () => { + removeTab(tab.id); + }; + + const handleFocus = () => { + setActiveTab(tab.workspaceId, tab.id); + }; + + return ( + +
+ +
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SingleTabView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SingleTabView.tsx index b39b7262b4..cd646df9ca 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SingleTabView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SingleTabView.tsx @@ -6,7 +6,7 @@ import { useSplitTabVertical, } from "renderer/stores"; import { TabContentContextMenu } from "./TabContentContextMenu"; -import { SetupTerminal, Terminal } from "./Terminal"; +import { Terminal } from "./Terminal"; interface SingleTabViewProps { tab: SingleTab; @@ -35,9 +35,6 @@ export function SingleTabView({ tab }: SingleTabViewProps) { setActiveTab(tab.workspaceId, tab.id); }; - // Use SetupTerminal wrapper only for setup tabs, otherwise use base Terminal - const TerminalComponent = tab.setupPending ? SetupTerminal : Terminal; - return (
- +
); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/SetupTerminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/SetupTerminal.tsx index f39a769606..5f7955cef2 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/SetupTerminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/SetupTerminal.tsx @@ -1,4 +1,4 @@ -import { useTabs } from "renderer/stores"; +import { TabType, useTabs } from "renderer/stores"; import { Terminal } from "./Terminal"; import type { TerminalSession } from "./types"; import { useTerminalSetup } from "./useTerminalSetup"; @@ -16,11 +16,16 @@ export function SetupTerminal({ tabId, workspaceId }: SetupTerminalProps) { const tabs = useTabs(); const tab = tabs.find((t) => t.id === tabId); + // Only process if this is actually a setup tab + if (!tab || tab.type !== TabType.Setup) { + return ; + } + const { executeSetup } = useTerminalSetup({ tabId, - setupPending: tab?.setupPending, - setupCommands: tab?.setupCommands, - setupCopyResults: tab?.setupCopyResults, + setupPending: tab.setupPending, + setupCommands: tab.setupCommands, + setupCopyResults: tab.setupCopyResults, }); const handleSessionReady = (session: TerminalSession) => { @@ -32,7 +37,7 @@ export function SetupTerminal({ tabId, workspaceId }: SetupTerminalProps) { ); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx index 824c4edc1c..bbac2dbdc7 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx @@ -4,6 +4,7 @@ import { TabType, useActiveTabIds, useTabs } from "renderer/stores"; import { DropOverlay } from "./DropOverlay"; import { EmptyTabView } from "./EmptyTabView"; import { GroupTabView } from "./GroupTabView"; +import { SetupTabView } from "./SetupTabView"; import { SingleTabView } from "./SingleTabView"; import { useTabContentDrop } from "./useTabContentDrop"; @@ -46,6 +47,11 @@ export function TabsContent() { {isDropZone && } + ) : tabToRender.type === TabType.Setup ? ( + <> + + {isDropZone && } + ) : ( <> diff --git a/apps/desktop/src/renderer/stores/tabs/helpers/tab-crud.ts b/apps/desktop/src/renderer/stores/tabs/helpers/tab-crud.ts index 7d0318f6fe..e4316dc273 100644 --- a/apps/desktop/src/renderer/stores/tabs/helpers/tab-crud.ts +++ b/apps/desktop/src/renderer/stores/tabs/helpers/tab-crud.ts @@ -102,10 +102,14 @@ export const handleAddSetupTab = ( setupCwd: string, setupCopyResults?: { copied: string[]; errors: string[] }, ): Partial => { - const newTab = createNewTab(workspaceId, TabType.Single, state.tabs); + const id = `tab-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; + const setupTab = { - ...newTab, + id, + type: TabType.Setup as const, title: "Setup Worktree", + workspaceId, + isNew: true, setupCommands, setupCwd, setupPending: true, diff --git a/apps/desktop/src/renderer/stores/tabs/types.ts b/apps/desktop/src/renderer/stores/tabs/types.ts index 90340c73a3..5df933649d 100644 --- a/apps/desktop/src/renderer/stores/tabs/types.ts +++ b/apps/desktop/src/renderer/stores/tabs/types.ts @@ -3,6 +3,7 @@ import type { MosaicBranch, MosaicNode } from "react-mosaic-component"; export enum TabType { Single = "single", Group = "group", + Setup = "setup", } interface BaseTab { @@ -12,10 +13,6 @@ interface BaseTab { isNew?: boolean; parentId?: string; needsAttention?: boolean; - setupCommands?: string[]; - setupCwd?: string; - setupPending?: boolean; - setupCopyResults?: { copied: string[]; errors: string[] }; } export interface SingleTab extends BaseTab { @@ -27,7 +24,15 @@ export interface TabGroup extends BaseTab { layout: MosaicNode | null; } -export type Tab = SingleTab | TabGroup; +export interface SetupTab extends BaseTab { + type: TabType.Setup; + setupCommands: string[]; + setupCwd: string; + setupPending: boolean; + setupCopyResults?: { copied: string[]; errors: string[] }; +} + +export type Tab = SingleTab | TabGroup | SetupTab; export interface TabsState { tabs: Tab[]; From 720e426396924e779306623ece075044c90b6abf Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 25 Nov 2025 15:52:48 -0600 Subject: [PATCH 5/7] simple setup --- .superset/setup.json | 2 +- .../trpc/routers/workspaces/utils/setup.ts | 1 + .../workspaces/useCreateWorkspace.ts | 7 +- .../ContentView/TabsContent/SetupTabView.tsx | 16 +-- .../TabsContent/Terminal/SetupTerminal.tsx | 66 +++++++--- .../TabsContent/Terminal/SimpleTerminal.tsx | 113 ++++++++++++++++++ .../TabsContent/Terminal/useTerminalSetup.ts | 30 ++--- 7 files changed, 188 insertions(+), 47 deletions(-) create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/SimpleTerminal.tsx diff --git a/.superset/setup.json b/.superset/setup.json index 901d9f3dd5..ac41fcba40 100644 --- a/.superset/setup.json +++ b/.superset/setup.json @@ -3,6 +3,6 @@ "**/.env*" ], "commands": [ - "bun i" + "echo 'Hello world'" ] } \ No newline at end of file diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.ts index ad02be9cd8..76b51eed2e 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.ts @@ -47,6 +47,7 @@ export async function copySetupFiles( dot: true, followSymbolicLinks: false, onlyFiles: true, + ignore: [".superset/**"], }); if (matches.length === 0) { diff --git a/apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts b/apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts index c20b6ad0e9..dc0fe597f6 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts @@ -19,12 +19,15 @@ export function useCreateWorkspace( await utils.workspaces.invalidate(); // Create setup tab if setup commands are present - if (data.setupConfig && data.setupConfig.length > 0) { + if ( + Array.isArray(data.setupConfig) && + data.setupConfig.length > 0 + ) { addSetupTab( data.workspace.id, data.setupConfig, data.worktreePath, - data.setupCopyResults || undefined, + data.setupCopyResults ?? undefined, ); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SetupTabView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SetupTabView.tsx index f7af1f8579..a6e2462204 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SetupTabView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SetupTabView.tsx @@ -1,12 +1,11 @@ import { type SetupTab, useRemoveTab, - useSetActiveTab, useSplitTabHorizontal, useSplitTabVertical, } from "renderer/stores"; import { TabContentContextMenu } from "./TabContentContextMenu"; -import { SetupTerminal } from "./Terminal"; +import { SimpleTerminal } from "./Terminal/SimpleTerminal"; interface SetupTabViewProps { tab: SetupTab; @@ -17,7 +16,6 @@ export function SetupTabView({ tab }: SetupTabViewProps) { const splitTabHorizontal = useSplitTabHorizontal(); const splitTabVertical = useSplitTabVertical(); const removeTab = useRemoveTab(); - const setActiveTab = useSetActiveTab(); const handleSplitHorizontal = () => { splitTabHorizontal(tab.workspaceId, tab.id); @@ -31,10 +29,6 @@ export function SetupTabView({ tab }: SetupTabViewProps) { removeTab(tab.id); }; - const handleFocus = () => { - setActiveTab(tab.workspaceId, tab.id); - }; - return (
- +
); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/SetupTerminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/SetupTerminal.tsx index 5f7955cef2..0188bb2cda 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/SetupTerminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/SetupTerminal.tsx @@ -1,34 +1,70 @@ -import { TabType, useTabs } from "renderer/stores"; import { Terminal } from "./Terminal"; import type { TerminalSession } from "./types"; import { useTerminalSetup } from "./useTerminalSetup"; +interface SetupCopyResults { + copied: string[]; + errors: string[]; +} + interface SetupTerminalProps { tabId: string; workspaceId: string; + title?: string; + setupPending?: boolean; + setupCommands?: string[]; + setupCopyResults?: SetupCopyResults; } /** * Wrapper component that handles terminal setup logic. - * Reads setup metadata from the tab store and executes setup when the terminal session is ready. + * Executes setup when the terminal session is ready. */ -export function SetupTerminal({ tabId, workspaceId }: SetupTerminalProps) { - const tabs = useTabs(); - const tab = tabs.find((t) => t.id === tabId); - - // Only process if this is actually a setup tab - if (!tab || tab.type !== TabType.Setup) { - return ; - } - +export function SetupTerminal({ + tabId, + workspaceId, + title, + setupPending, + setupCommands, + setupCopyResults, +}: SetupTerminalProps) { const { executeSetup } = useTerminalSetup({ tabId, - setupPending: tab.setupPending, - setupCommands: tab.setupCommands, - setupCopyResults: tab.setupCopyResults, + setupPending, + setupCommands, + setupCopyResults, }); const handleSessionReady = (session: TerminalSession) => { + // Build copy result commands as echo statements + const copyCommands: string[] = []; + + if (setupCopyResults) { + const { copied, errors } = setupCopyResults; + if (copied.length > 0) { + copyCommands.push( + `echo -e "\\n\\033[32m✓ Copied ${copied.length} file(s):\\033[0m"`, + ); + for (const file of copied) { + copyCommands.push(`echo " - ${file}"`); + } + } + if (errors.length > 0) { + copyCommands.push(`echo -e "\\n\\033[33m⚠ Copy warnings:\\033[0m"`); + for (const error of errors) { + copyCommands.push(`echo " ${error}"`); + } + } + if (copied.length > 0 || errors.length > 0) { + copyCommands.push(`echo ""`); + } + } + + // Send copy result commands first + for (const cmd of copyCommands) { + session.write(`${cmd}\n`); + } + // Execute setup with the terminal session executeSetup(session); }; @@ -37,7 +73,7 @@ export function SetupTerminal({ tabId, workspaceId }: SetupTerminalProps) { ); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/SimpleTerminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/SimpleTerminal.tsx new file mode 100644 index 0000000000..994a6b2965 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/SimpleTerminal.tsx @@ -0,0 +1,113 @@ +import "@xterm/xterm/css/xterm.css"; +import { FitAddon } from "@xterm/addon-fit"; +import { Terminal as XTerm } from "@xterm/xterm"; +import { useEffect, useRef } from "react"; + +interface SetupCopyResults { + copied: string[]; + errors: string[]; +} + +interface SimpleTerminalProps { + tabId: string; + workspaceId: string; + setupCommands?: string[]; + setupCopyResults?: SetupCopyResults; + setupCwd?: string; +} + +/** + * Simple terminal that displays output without PTY. + * Used for setup tabs where we just need to show command output. + */ +export function SimpleTerminal({ + setupCommands, + setupCopyResults, + setupCwd, +}: SimpleTerminalProps) { + const terminalRef = useRef(null); + const xtermRef = useRef(null); + const fitAddonRef = useRef(null); + + useEffect(() => { + const container = terminalRef.current; + if (!container) return; + + // Create xterm instance + const xterm = new XTerm({ + cursorBlink: false, + fontSize: 13, + fontFamily: 'Menlo, Monaco, "Courier New", monospace', + theme: { + background: "#000000", + }, + allowProposedApi: true, + }); + + const fitAddon = new FitAddon(); + xterm.loadAddon(fitAddon); + xterm.open(container); + fitAddon.fit(); + + xtermRef.current = xterm; + fitAddonRef.current = fitAddon; + + // Display copy results + if (setupCopyResults) { + const { copied, errors } = setupCopyResults; + if (copied.length > 0) { + xterm.writeln( + `\r\n\x1b[32m✓ Copied ${copied.length} file(s):\x1b[0m`, + ); + for (const file of copied) { + xterm.writeln(` - ${file}`); + } + } + if (errors.length > 0) { + xterm.writeln(`\r\n\x1b[33m⚠ Copy warnings:\x1b[0m`); + for (const error of errors) { + xterm.writeln(` ${error}`); + } + } + xterm.writeln(""); + } + + // Display working directory + if (setupCwd) { + xterm.writeln(`\x1b[36mWorking directory: ${setupCwd}\x1b[0m\r\n`); + } + + // Display commands that will be run + if (setupCommands && setupCommands.length > 0) { + xterm.writeln(`\x1b[36mRunning setup commands...\x1b[0m\r\n`); + for (const cmd of setupCommands) { + xterm.writeln(`$ ${cmd}`); + } + xterm.writeln(""); + xterm.writeln( + `\x1b[33m[Commands are running in the background...]\x1b[0m`, + ); + xterm.writeln( + `\x1b[32m✓ Setup completed! You can close this tab.\x1b[0m\r\n`, + ); + } + + // Handle resize + const resizeObserver = new ResizeObserver(() => { + fitAddon.fit(); + }); + resizeObserver.observe(container); + + return () => { + resizeObserver.disconnect(); + xterm.dispose(); + xtermRef.current = null; + }; + }, [setupCommands, setupCopyResults, setupCwd]); + + return ( +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/useTerminalSetup.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/useTerminalSetup.ts index 22f4b59f8a..c8a7df45c2 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/useTerminalSetup.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/useTerminalSetup.ts @@ -34,29 +34,17 @@ export function useTerminalSetup({ setupExecutedRef.current = true; - // Print copy results if available - if (setupCopyResults) { - const { copied, errors } = setupCopyResults; - if (copied.length > 0) { - session.xterm.writeln( - `\r\n\x1b[32m✓ Copied ${copied.length} file(s):\x1b[0m`, - ); - for (const file of copied) { - session.xterm.writeln(` - ${file}`); - } - } - if (errors.length > 0) { - session.xterm.writeln(`\r\n\x1b[33m⚠ Copy warnings:\x1b[0m`); - for (const error of errors) { - session.xterm.writeln(` ${error}`); - } - } - session.xterm.writeln("\r\n"); + // Send each setup command individually to the shell + for (const command of setupCommands) { + session.write(`${command}\n`); } - // Send all commands sequentially - const commands = `${setupCommands.join("\n")}\n`; - session.write(commands); + // Write completion message directly to xterm display (not executed) + setTimeout(() => { + session.xterm.write( + "\r\n\x1b[32m✓ Setup completed! You can close this tab.\x1b[0m\r\n", + ); + }, 100); // Mark setup as no longer pending useTabsStore.setState((state) => ({ From 68ca4a7cb2d0cdc25c7d00167e09606fd335a016 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 25 Nov 2025 15:59:52 -0600 Subject: [PATCH 6/7] simple setup --- .../ContentView/TabsContent/SetupTabView.tsx | 4 +- .../TabsContent/Terminal/SetupTerminal.tsx | 154 +++++++++++++----- .../TabsContent/Terminal/SimpleTerminal.tsx | 113 ------------- .../ContentView/TabsContent/Terminal/index.ts | 2 +- .../TabsContent/Terminal/useTerminalSetup.ts | 58 ------- 5 files changed, 119 insertions(+), 212 deletions(-) delete mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/SimpleTerminal.tsx delete mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/useTerminalSetup.ts diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SetupTabView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SetupTabView.tsx index a6e2462204..4076d5e6c7 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SetupTabView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SetupTabView.tsx @@ -5,7 +5,7 @@ import { useSplitTabVertical, } from "renderer/stores"; import { TabContentContextMenu } from "./TabContentContextMenu"; -import { SimpleTerminal } from "./Terminal/SimpleTerminal"; +import { SetupTerminal } from "./Terminal/SetupTerminal"; interface SetupTabViewProps { tab: SetupTab; @@ -36,7 +36,7 @@ export function SetupTabView({ tab }: SetupTabViewProps) { onClosePane={handleClosePane} >
- (null); + const [subscriptionEnabled, setSubscriptionEnabled] = useState(false); + + const createOrAttachMutation = trpc.terminal.createOrAttach.useMutation(); + const writeMutation = trpc.terminal.write.useMutation(); + const resizeMutation = trpc.terminal.resize.useMutation(); + const detachMutation = trpc.terminal.detach.useMutation(); + + // Stable refs for mutations to avoid recreating effect + const createOrAttachRef = useRef(createOrAttachMutation.mutate); + const writeRef = useRef(writeMutation.mutate); + const resizeRef = useRef(resizeMutation.mutate); + const detachRef = useRef(detachMutation.mutate); + + createOrAttachRef.current = createOrAttachMutation.mutate; + writeRef.current = writeMutation.mutate; + resizeRef.current = resizeMutation.mutate; + detachRef.current = detachMutation.mutate; + + const handleStreamData = (event: TerminalStreamEvent) => { + const container = terminalRef.current; + if (!container) return; + + const xterm = (container as any)._xterm; + if (!xterm) return; + + if (event.type === "data") { + xterm.write(event.data); + } else if (event.type === "exit") { + setSubscriptionEnabled(false); + xterm.writeln( + `\r\n\r\n\x1b[32m✓ Setup completed! You can close this tab.\x1b[0m`, + ); + } + }; + + trpc.terminal.stream.useSubscription(tabId, { + onData: handleStreamData, + enabled: subscriptionEnabled, }); - const handleSessionReady = (session: TerminalSession) => { - // Build copy result commands as echo statements - const copyCommands: string[] = []; + useEffect(() => { + const container = terminalRef.current; + if (!container) return; + // Create xterm instance with same config as regular terminal + const { xterm, fitAddon } = createTerminalInstance(container, setupCwd); + (container as any)._xterm = xterm; + + // Display initial status + xterm.writeln(`\x1b[36mSetting up worktree...\x1b[0m\r\n`); + + // Display copy results if (setupCopyResults) { + xterm.writeln(`\x1b[36mCopying files...\x1b[0m\r\n`); const { copied, errors } = setupCopyResults; if (copied.length > 0) { - copyCommands.push( - `echo -e "\\n\\033[32m✓ Copied ${copied.length} file(s):\\033[0m"`, - ); + xterm.writeln(`\x1b[32m✓ Copied ${copied.length} file(s)\x1b[0m`); for (const file of copied) { - copyCommands.push(`echo " - ${file}"`); + xterm.writeln(` - ${file}`); } } if (errors.length > 0) { - copyCommands.push(`echo -e "\\n\\033[33m⚠ Copy warnings:\\033[0m"`); + xterm.writeln(`\r\n\x1b[33m⚠ Copy warnings:\x1b[0m`); for (const error of errors) { - copyCommands.push(`echo " ${error}"`); + xterm.writeln(` ${error}`); } } - if (copied.length > 0 || errors.length > 0) { - copyCommands.push(`echo ""`); - } + xterm.writeln(""); } - // Send copy result commands first - for (const cmd of copyCommands) { - session.write(`${cmd}\n`); + // Display setup info + if (setupCommands && setupCommands.length > 0) { + xterm.writeln(`\x1b[36mRunning setup commands...\x1b[0m\r\n`); } - // Execute setup with the terminal session - executeSetup(session); - }; + // Create terminal session and run setup commands + createOrAttachRef.current( + { + tabId, + workspaceId, + tabTitle: "Setup", + cols: xterm.cols, + rows: xterm.rows, + cwd: setupCwd, + }, + { + onSuccess: () => { + setSubscriptionEnabled(true); + + // Send all setup commands + if (setupCommands && setupCommands.length > 0) { + for (const cmd of setupCommands) { + writeRef.current({ tabId, data: `${cmd}\n` }); + } + + // Send exit command to trigger completion message + writeRef.current({ tabId, data: "exit\n" }); + } + }, + }, + ); + + // Setup resize handlers + const cleanupResize = setupResizeHandlers( + container, + xterm, + fitAddon, + (cols, rows) => { + resizeRef.current({ tabId, cols, rows }); + }, + ); + + return () => { + cleanupResize(); + detachRef.current({ tabId }); + setSubscriptionEnabled(false); + xterm.dispose(); + delete (container as any)._xterm; + }; + }, [tabId, workspaceId, setupCommands, setupCopyResults, setupCwd]); return ( - +
+
+
); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/SimpleTerminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/SimpleTerminal.tsx deleted file mode 100644 index 994a6b2965..0000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/SimpleTerminal.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import "@xterm/xterm/css/xterm.css"; -import { FitAddon } from "@xterm/addon-fit"; -import { Terminal as XTerm } from "@xterm/xterm"; -import { useEffect, useRef } from "react"; - -interface SetupCopyResults { - copied: string[]; - errors: string[]; -} - -interface SimpleTerminalProps { - tabId: string; - workspaceId: string; - setupCommands?: string[]; - setupCopyResults?: SetupCopyResults; - setupCwd?: string; -} - -/** - * Simple terminal that displays output without PTY. - * Used for setup tabs where we just need to show command output. - */ -export function SimpleTerminal({ - setupCommands, - setupCopyResults, - setupCwd, -}: SimpleTerminalProps) { - const terminalRef = useRef(null); - const xtermRef = useRef(null); - const fitAddonRef = useRef(null); - - useEffect(() => { - const container = terminalRef.current; - if (!container) return; - - // Create xterm instance - const xterm = new XTerm({ - cursorBlink: false, - fontSize: 13, - fontFamily: 'Menlo, Monaco, "Courier New", monospace', - theme: { - background: "#000000", - }, - allowProposedApi: true, - }); - - const fitAddon = new FitAddon(); - xterm.loadAddon(fitAddon); - xterm.open(container); - fitAddon.fit(); - - xtermRef.current = xterm; - fitAddonRef.current = fitAddon; - - // Display copy results - if (setupCopyResults) { - const { copied, errors } = setupCopyResults; - if (copied.length > 0) { - xterm.writeln( - `\r\n\x1b[32m✓ Copied ${copied.length} file(s):\x1b[0m`, - ); - for (const file of copied) { - xterm.writeln(` - ${file}`); - } - } - if (errors.length > 0) { - xterm.writeln(`\r\n\x1b[33m⚠ Copy warnings:\x1b[0m`); - for (const error of errors) { - xterm.writeln(` ${error}`); - } - } - xterm.writeln(""); - } - - // Display working directory - if (setupCwd) { - xterm.writeln(`\x1b[36mWorking directory: ${setupCwd}\x1b[0m\r\n`); - } - - // Display commands that will be run - if (setupCommands && setupCommands.length > 0) { - xterm.writeln(`\x1b[36mRunning setup commands...\x1b[0m\r\n`); - for (const cmd of setupCommands) { - xterm.writeln(`$ ${cmd}`); - } - xterm.writeln(""); - xterm.writeln( - `\x1b[33m[Commands are running in the background...]\x1b[0m`, - ); - xterm.writeln( - `\x1b[32m✓ Setup completed! You can close this tab.\x1b[0m\r\n`, - ); - } - - // Handle resize - const resizeObserver = new ResizeObserver(() => { - fitAddon.fit(); - }); - resizeObserver.observe(container); - - return () => { - resizeObserver.disconnect(); - xterm.dispose(); - xtermRef.current = null; - }; - }, [setupCommands, setupCopyResults, setupCwd]); - - return ( -
-
-
- ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/index.ts index 3949333e6a..d457f4bd0d 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/index.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/index.ts @@ -1,2 +1,2 @@ -export { SetupTerminal } from "./SetupTerminal"; export { Terminal } from "./Terminal"; +export { SetupTerminal } from "./SetupTerminal"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/useTerminalSetup.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/useTerminalSetup.ts deleted file mode 100644 index c8a7df45c2..0000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/useTerminalSetup.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { useRef } from "react"; -import { useTabsStore } from "renderer/stores"; -import type { TerminalSession } from "./types"; - -interface SetupCopyResults { - copied: string[]; - errors: string[]; -} - -interface UseTerminalSetupParams { - tabId: string; - setupPending?: boolean; - setupCommands?: string[]; - setupCopyResults?: SetupCopyResults; -} - -/** - * Hook to handle terminal setup execution. - * Prints copy results and sends setup commands when a terminal session is ready. - */ -export function useTerminalSetup({ - tabId, - setupPending, - setupCommands, - setupCopyResults, -}: UseTerminalSetupParams) { - const setupExecutedRef = useRef(false); - - const executeSetup = (session: TerminalSession) => { - // Only execute setup once and only if setup is pending - if (!setupPending || !setupCommands || setupExecutedRef.current) { - return; - } - - setupExecutedRef.current = true; - - // Send each setup command individually to the shell - for (const command of setupCommands) { - session.write(`${command}\n`); - } - - // Write completion message directly to xterm display (not executed) - setTimeout(() => { - session.xterm.write( - "\r\n\x1b[32m✓ Setup completed! You can close this tab.\x1b[0m\r\n", - ); - }, 100); - - // Mark setup as no longer pending - useTabsStore.setState((state) => ({ - tabs: state.tabs.map((t) => - t.id === tabId ? { ...t, setupPending: false } : t, - ), - })); - }; - - return { executeSetup }; -} From b198d78bdfeac66a79ea4c155feef0a7fd0d6bda Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 25 Nov 2025 16:09:45 -0600 Subject: [PATCH 7/7] save --- .../src/main/lib/terminal-manager.test.ts | 14 +++ .../workspaces/useCreateWorkspace.ts | 5 +- .../{ => SetupTabView}/SetupTabView.tsx | 4 +- .../SetupTerminal.tsx | 94 ++++++++++--------- .../TabsContent/SetupTabView/index.ts | 2 + .../ContentView/TabsContent/Terminal/index.ts | 1 - 6 files changed, 67 insertions(+), 53 deletions(-) rename apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/{ => SetupTabView}/SetupTabView.tsx (89%) rename apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/{Terminal => SetupTabView}/SetupTerminal.tsx (53%) create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SetupTabView/index.ts diff --git a/apps/desktop/src/main/lib/terminal-manager.test.ts b/apps/desktop/src/main/lib/terminal-manager.test.ts index 6f4c2927e6..ea1987603e 100644 --- a/apps/desktop/src/main/lib/terminal-manager.test.ts +++ b/apps/desktop/src/main/lib/terminal-manager.test.ts @@ -79,6 +79,8 @@ describe("TerminalManager", () => { const result = await manager.createOrAttach({ tabId: "tab-1", workspaceId: "workspace-1", + tabTitle: "Test Tab", + workspaceName: "Test Workspace", cwd: "/test/path", cols: 80, rows: 24, @@ -102,6 +104,8 @@ describe("TerminalManager", () => { await manager.createOrAttach({ tabId: "tab-1", workspaceId: "workspace-1", + tabTitle: "Test Tab", + workspaceName: "Test Workspace", cwd: "/test/path", }); @@ -111,6 +115,8 @@ describe("TerminalManager", () => { const result = await manager.createOrAttach({ tabId: "tab-1", workspaceId: "workspace-1", + tabTitle: "Test Tab", + workspaceName: "Test Workspace", }); expect(result.isNew).toBe(false); @@ -124,6 +130,8 @@ describe("TerminalManager", () => { await manager.createOrAttach({ tabId: "tab-1", workspaceId: "workspace-1", + tabTitle: "Test Tab", + workspaceName: "Test Workspace", cols: 80, rows: 24, }); @@ -131,6 +139,8 @@ describe("TerminalManager", () => { await manager.createOrAttach({ tabId: "tab-1", workspaceId: "workspace-1", + tabTitle: "Test Tab", + workspaceName: "Test Workspace", cols: 100, rows: 30, }); @@ -144,6 +154,8 @@ describe("TerminalManager", () => { await manager.createOrAttach({ tabId: "tab-1", workspaceId: "workspace-1", + tabTitle: "Test Tab", + workspaceName: "Test Workspace", }); manager.write({ @@ -169,6 +181,8 @@ describe("TerminalManager", () => { await manager.createOrAttach({ tabId: "tab-1", workspaceId: "workspace-1", + tabTitle: "Test Tab", + workspaceName: "Test Workspace", }); manager.resize({ diff --git a/apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts b/apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts index dc0fe597f6..8e38597d8f 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts @@ -19,10 +19,7 @@ export function useCreateWorkspace( await utils.workspaces.invalidate(); // Create setup tab if setup commands are present - if ( - Array.isArray(data.setupConfig) && - data.setupConfig.length > 0 - ) { + if (Array.isArray(data.setupConfig) && data.setupConfig.length > 0) { addSetupTab( data.workspace.id, data.setupConfig, diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SetupTabView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SetupTabView/SetupTabView.tsx similarity index 89% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SetupTabView.tsx rename to apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SetupTabView/SetupTabView.tsx index 4076d5e6c7..94c7970a05 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SetupTabView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SetupTabView/SetupTabView.tsx @@ -4,8 +4,8 @@ import { useSplitTabHorizontal, useSplitTabVertical, } from "renderer/stores"; -import { TabContentContextMenu } from "./TabContentContextMenu"; -import { SetupTerminal } from "./Terminal/SetupTerminal"; +import { TabContentContextMenu } from "../TabContentContextMenu"; +import { SetupTerminal } from "./SetupTerminal"; interface SetupTabViewProps { tab: SetupTab; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/SetupTerminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SetupTabView/SetupTerminal.tsx similarity index 53% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/SetupTerminal.tsx rename to apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SetupTabView/SetupTerminal.tsx index 47cf8ce059..617f3c9d4a 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/SetupTerminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SetupTabView/SetupTerminal.tsx @@ -1,8 +1,11 @@ import "@xterm/xterm/css/xterm.css"; import { useEffect, useRef, useState } from "react"; import { trpc } from "renderer/lib/trpc"; -import { createTerminalInstance, setupResizeHandlers } from "./helpers"; -import type { TerminalStreamEvent } from "./types"; +import { + createTerminalInstance, + setupResizeHandlers, +} from "../Terminal/helpers"; +import type { TerminalStreamEvent } from "../Terminal/types"; interface SetupCopyResults { copied: string[]; @@ -19,7 +22,6 @@ interface SetupTerminalProps { /** * Terminal that runs setup commands and displays output. - * Used for setup tabs where we need to show command execution. */ export function SetupTerminal({ tabId, @@ -29,37 +31,27 @@ export function SetupTerminal({ setupCwd, }: SetupTerminalProps) { const terminalRef = useRef(null); + const xtermRef = useRef | null>( + null, + ); const [subscriptionEnabled, setSubscriptionEnabled] = useState(false); + const setupExecutedRef = useRef(false); - const createOrAttachMutation = trpc.terminal.createOrAttach.useMutation(); + const createMutation = trpc.terminal.createOrAttach.useMutation(); const writeMutation = trpc.terminal.write.useMutation(); const resizeMutation = trpc.terminal.resize.useMutation(); const detachMutation = trpc.terminal.detach.useMutation(); - // Stable refs for mutations to avoid recreating effect - const createOrAttachRef = useRef(createOrAttachMutation.mutate); - const writeRef = useRef(writeMutation.mutate); - const resizeRef = useRef(resizeMutation.mutate); - const detachRef = useRef(detachMutation.mutate); - - createOrAttachRef.current = createOrAttachMutation.mutate; - writeRef.current = writeMutation.mutate; - resizeRef.current = resizeMutation.mutate; - detachRef.current = detachMutation.mutate; - const handleStreamData = (event: TerminalStreamEvent) => { - const container = terminalRef.current; - if (!container) return; - - const xterm = (container as any)._xterm; - if (!xterm) return; + if (!xtermRef.current) return; + const { xterm } = xtermRef.current; if (event.type === "data") { xterm.write(event.data); } else if (event.type === "exit") { setSubscriptionEnabled(false); xterm.writeln( - `\r\n\r\n\x1b[32m✓ Setup completed! You can close this tab.\x1b[0m`, + "\r\n\r\n\x1b[32m✓ Setup completed! You can close this tab.\x1b[0m", ); } }; @@ -71,41 +63,45 @@ export function SetupTerminal({ useEffect(() => { const container = terminalRef.current; - if (!container) return; + if (!container || setupExecutedRef.current) return; - // Create xterm instance with same config as regular terminal - const { xterm, fitAddon } = createTerminalInstance(container, setupCwd); - (container as any)._xterm = xterm; + setupExecutedRef.current = true; - // Display initial status - xterm.writeln(`\x1b[36mSetting up worktree...\x1b[0m\r\n`); + // Create xterm instance + const terminal = createTerminalInstance(container, setupCwd); + xtermRef.current = terminal; + const { xterm, fitAddon } = terminal; + + // Display status messages + xterm.writeln("\x1b[36mSetting up worktree...\x1b[0m\r\n"); - // Display copy results if (setupCopyResults) { - xterm.writeln(`\x1b[36mCopying files...\x1b[0m\r\n`); + xterm.writeln("\x1b[36mCopying files...\x1b[0m\r\n"); const { copied, errors } = setupCopyResults; + if (copied.length > 0) { xterm.writeln(`\x1b[32m✓ Copied ${copied.length} file(s)\x1b[0m`); for (const file of copied) { xterm.writeln(` - ${file}`); } } + if (errors.length > 0) { - xterm.writeln(`\r\n\x1b[33m⚠ Copy warnings:\x1b[0m`); + xterm.writeln("\r\n\x1b[33m⚠ Copy warnings:\x1b[0m"); for (const error of errors) { xterm.writeln(` ${error}`); } } + xterm.writeln(""); } - // Display setup info if (setupCommands && setupCommands.length > 0) { - xterm.writeln(`\x1b[36mRunning setup commands...\x1b[0m\r\n`); + xterm.writeln("\x1b[36mRunning setup commands...\x1b[0m\r\n"); } - // Create terminal session and run setup commands - createOrAttachRef.current( + // Create terminal session and run commands + createMutation.mutate( { tabId, workspaceId, @@ -118,37 +114,43 @@ export function SetupTerminal({ onSuccess: () => { setSubscriptionEnabled(true); - // Send all setup commands + // Send commands once if (setupCommands && setupCommands.length > 0) { - for (const cmd of setupCommands) { - writeRef.current({ tabId, data: `${cmd}\n` }); - } - - // Send exit command to trigger completion message - writeRef.current({ tabId, data: "exit\n" }); + const combinedCommands = `${setupCommands.join(" && ")} && exit\n`; + writeMutation.mutate({ tabId, data: combinedCommands }); } }, }, ); - // Setup resize handlers + // Setup resize const cleanupResize = setupResizeHandlers( container, xterm, fitAddon, (cols, rows) => { - resizeRef.current({ tabId, cols, rows }); + resizeMutation.mutate({ tabId, cols, rows }); }, ); return () => { cleanupResize(); - detachRef.current({ tabId }); + detachMutation.mutate({ tabId }); setSubscriptionEnabled(false); xterm.dispose(); - delete (container as any)._xterm; + xtermRef.current = null; }; - }, [tabId, workspaceId, setupCommands, setupCopyResults, setupCwd]); + }, [ + tabId, + workspaceId, + setupCommands, + setupCopyResults, + setupCwd, + createMutation, + writeMutation, + resizeMutation, + detachMutation, + ]); return (
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SetupTabView/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SetupTabView/index.ts new file mode 100644 index 0000000000..9a368bc007 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SetupTabView/index.ts @@ -0,0 +1,2 @@ +export { SetupTabView } from "./SetupTabView"; +export { SetupTerminal } from "./SetupTerminal"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/index.ts index d457f4bd0d..5aaa605e86 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/index.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/index.ts @@ -1,2 +1 @@ export { Terminal } from "./Terminal"; -export { SetupTerminal } from "./SetupTerminal";