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/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/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..76b51eed2e --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.ts @@ -0,0 +1,80 @@ +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, + ignore: [".superset/**"], + }); + + 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/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.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/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 a58415f952..88a1b9514c 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 { productName } 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/react-query/workspaces/useCreateWorkspace.ts b/apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts index 049cac6eed..8e38597d8f 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 (Array.isArray(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/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..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 @@ -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"; interface GroupTabPaneProps { tabId: string; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SetupTabView/SetupTabView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SetupTabView/SetupTabView.tsx new file mode 100644 index 0000000000..94c7970a05 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SetupTabView/SetupTabView.tsx @@ -0,0 +1,49 @@ +import { + type SetupTab, + useRemoveTab, + useSplitTabHorizontal, + useSplitTabVertical, +} from "renderer/stores"; +import { TabContentContextMenu } from "../TabContentContextMenu"; +import { SetupTerminal } from "./SetupTerminal"; + +interface SetupTabViewProps { + tab: SetupTab; + isDropZone: boolean; +} + +export function SetupTabView({ tab }: SetupTabViewProps) { + const splitTabHorizontal = useSplitTabHorizontal(); + const splitTabVertical = useSplitTabVertical(); + const removeTab = useRemoveTab(); + + const handleSplitHorizontal = () => { + splitTabHorizontal(tab.workspaceId, tab.id); + }; + + const handleSplitVertical = () => { + splitTabVertical(tab.workspaceId, tab.id); + }; + + const handleClosePane = () => { + removeTab(tab.id); + }; + + return ( + +
+ +
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SetupTabView/SetupTerminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SetupTabView/SetupTerminal.tsx new file mode 100644 index 0000000000..617f3c9d4a --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SetupTabView/SetupTerminal.tsx @@ -0,0 +1,160 @@ +import "@xterm/xterm/css/xterm.css"; +import { useEffect, useRef, useState } from "react"; +import { trpc } from "renderer/lib/trpc"; +import { + createTerminalInstance, + setupResizeHandlers, +} from "../Terminal/helpers"; +import type { TerminalStreamEvent } from "../Terminal/types"; + +interface SetupCopyResults { + copied: string[]; + errors: string[]; +} + +interface SetupTerminalProps { + tabId: string; + workspaceId: string; + setupCommands?: string[]; + setupCopyResults?: SetupCopyResults; + setupCwd?: string; +} + +/** + * Terminal that runs setup commands and displays output. + */ +export function SetupTerminal({ + tabId, + workspaceId, + setupCommands, + setupCopyResults, + setupCwd, +}: SetupTerminalProps) { + const terminalRef = useRef(null); + const xtermRef = useRef | null>( + null, + ); + const [subscriptionEnabled, setSubscriptionEnabled] = useState(false); + const setupExecutedRef = useRef(false); + + const createMutation = trpc.terminal.createOrAttach.useMutation(); + const writeMutation = trpc.terminal.write.useMutation(); + const resizeMutation = trpc.terminal.resize.useMutation(); + const detachMutation = trpc.terminal.detach.useMutation(); + + const handleStreamData = (event: TerminalStreamEvent) => { + 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", + ); + } + }; + + trpc.terminal.stream.useSubscription(tabId, { + onData: handleStreamData, + enabled: subscriptionEnabled, + }); + + useEffect(() => { + const container = terminalRef.current; + if (!container || setupExecutedRef.current) return; + + setupExecutedRef.current = true; + + // 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"); + + if (setupCopyResults) { + 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"); + for (const error of errors) { + xterm.writeln(` ${error}`); + } + } + + xterm.writeln(""); + } + + if (setupCommands && setupCommands.length > 0) { + xterm.writeln("\x1b[36mRunning setup commands...\x1b[0m\r\n"); + } + + // Create terminal session and run commands + createMutation.mutate( + { + tabId, + workspaceId, + tabTitle: "Setup", + cols: xterm.cols, + rows: xterm.rows, + cwd: setupCwd, + }, + { + onSuccess: () => { + setSubscriptionEnabled(true); + + // Send commands once + if (setupCommands && setupCommands.length > 0) { + const combinedCommands = `${setupCommands.join(" && ")} && exit\n`; + writeMutation.mutate({ tabId, data: combinedCommands }); + } + }, + }, + ); + + // Setup resize + const cleanupResize = setupResizeHandlers( + container, + xterm, + fitAddon, + (cols, rows) => { + resizeMutation.mutate({ tabId, cols, rows }); + }, + ); + + return () => { + cleanupResize(); + detachMutation.mutate({ tabId }); + setSubscriptionEnabled(false); + xterm.dispose(); + xtermRef.current = null; + }; + }, [ + 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/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index a9a09d68ae..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 } 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,6 +25,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const pendingEventsRef = useRef([]); const [subscriptionEnabled, setSubscriptionEnabled] = useState(false); const setActiveTab = useSetActiveTab(); + const sessionReadyCalledRef = useRef(false); // Get the workspace CWD for resolving relative file paths const { data: workspaceCwd } = @@ -161,6 +165,15 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { applyInitialScrollback(result); setSubscriptionEnabled(true); flushPendingEvents(); + + // 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: () => { setSubscriptionEnabled(true); @@ -194,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/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/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 5355c8a688..e4316dc273 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,43 @@ export const handleMarkTabAsUsed = ( ), }; }; + +export const handleAddSetupTab = ( + state: TabsState, + workspaceId: string, + setupCommands: string[], + setupCwd: string, + setupCopyResults?: { copied: string[]; errors: string[] }, +): Partial => { + const id = `tab-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; + + const setupTab = { + id, + type: TabType.Setup as const, + title: "Setup Worktree", + workspaceId, + isNew: true, + 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..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 { @@ -23,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[]; @@ -33,6 +42,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; 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/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/packages/ui/src/components/alert-dialog.tsx b/packages/ui/src/components/alert-dialog.tsx index c2942be38a..5f240bd276 100644 --- a/packages/ui/src/components/alert-dialog.tsx +++ b/packages/ui/src/components/alert-dialog.tsx @@ -1,5 +1,5 @@ -import * as React from "react"; import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; +import type * as React from "react"; import { cn } from "../lib/utils"; import { buttonVariants } from "./button";