From 1481a65b067dc5ea01da7b85d98d4e5d534041a3 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Mon, 1 Dec 2025 14:27:16 -0800 Subject: [PATCH 1/8] WIP - looks like it's working --- .env.example | 1 + .gitignore | 2 +- .superset/{setup.json => config.json} | 0 README.md | 2 +- .../src/lib/trpc/routers/config/config.ts | 120 ++++++++++++ .../src/lib/trpc/routers/config/index.ts | 2 + apps/desktop/src/lib/trpc/routers/index.ts | 2 + .../src/lib/trpc/routers/projects/projects.ts | 6 + .../routers/workspaces/utils/setup.test.ts | 11 +- .../trpc/routers/workspaces/utils/setup.ts | 2 +- .../trpc/routers/workspaces/utils/teardown.ts | 2 +- .../lib/trpc/routers/workspaces/workspaces.ts | 1 + apps/desktop/src/main/lib/db/schemas.ts | 1 + .../assets => assets/app-icons}/cursor.svg | 0 .../assets => assets/app-icons}/finder.png | Bin .../assets => assets/app-icons}/iterm.png | Bin .../assets => assets/app-icons}/terminal.png | Bin .../assets => assets/app-icons}/vscode.svg | 0 .../assets => assets/app-icons}/warp.png | Bin .../assets => assets/app-icons}/xcode.svg | 0 .../components/OpenInButton/OpenInButton.tsx | 149 +++++++++++++++ .../renderer/components/OpenInButton/index.ts | 2 + .../SetupConfigModal/SetupConfigModal.tsx | 87 +++++++++ .../components/SetupConfigModal/index.ts | 1 + .../main/components/StartView/index.tsx | 30 ++- .../WorkspaceTabs/WorkspaceDropdown.tsx | 22 ++- .../WorkspaceHeader/WorkspaceHeader.tsx | 139 +------------- .../src/renderer/screens/main/index.tsx | 2 + .../src/renderer/stores/config-modal.ts | 37 ++++ apps/desktop/src/shared/constants.ts | 3 + apps/website/src/app/scripts/page.tsx | 176 ++++++++++++++++++ 31 files changed, 656 insertions(+), 144 deletions(-) rename .superset/{setup.json => config.json} (100%) create mode 100644 apps/desktop/src/lib/trpc/routers/config/config.ts create mode 100644 apps/desktop/src/lib/trpc/routers/config/index.ts rename apps/desktop/src/renderer/{screens/main/components/WorkspaceView/WorkspaceHeader/assets => assets/app-icons}/cursor.svg (100%) rename apps/desktop/src/renderer/{screens/main/components/WorkspaceView/WorkspaceHeader/assets => assets/app-icons}/finder.png (100%) rename apps/desktop/src/renderer/{screens/main/components/WorkspaceView/WorkspaceHeader/assets => assets/app-icons}/iterm.png (100%) rename apps/desktop/src/renderer/{screens/main/components/WorkspaceView/WorkspaceHeader/assets => assets/app-icons}/terminal.png (100%) rename apps/desktop/src/renderer/{screens/main/components/WorkspaceView/WorkspaceHeader/assets => assets/app-icons}/vscode.svg (100%) rename apps/desktop/src/renderer/{screens/main/components/WorkspaceView/WorkspaceHeader/assets => assets/app-icons}/warp.png (100%) rename apps/desktop/src/renderer/{screens/main/components/WorkspaceView/WorkspaceHeader/assets => assets/app-icons}/xcode.svg (100%) create mode 100644 apps/desktop/src/renderer/components/OpenInButton/OpenInButton.tsx create mode 100644 apps/desktop/src/renderer/components/OpenInButton/index.ts create mode 100644 apps/desktop/src/renderer/components/SetupConfigModal/SetupConfigModal.tsx create mode 100644 apps/desktop/src/renderer/components/SetupConfigModal/index.ts create mode 100644 apps/desktop/src/renderer/stores/config-modal.ts create mode 100644 apps/website/src/app/scripts/page.tsx diff --git a/.env.example b/.env.example index 862e3b59a7b..74557ad21f0 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ DATABASE_URL=postgresql://postgres:postgres@localhost:5432/superset VITE_DEV_SERVER_PORT=4927 +WEBSITE_URL=http://localhost:3001 diff --git a/.gitignore b/.gitignore index a4df714cf5e..28cdb69a6ab 100644 --- a/.gitignore +++ b/.gitignore @@ -55,7 +55,7 @@ mise.toml # Superset # Ignore .superset directory except for config and scripts .superset/* -!.superset/setup.json +!.superset/config.json !.superset/setup.sh !.superset/teardown.sh diff --git a/.superset/setup.json b/.superset/config.json similarity index 100% rename from .superset/setup.json rename to .superset/config.json diff --git a/README.md b/README.md index 47f32f22379..446b59e9871 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ open apps/desktop/release ### Usage -For each parallel tasks, Superset uses git worktrees to clone a new branch on your machine. Automate copying env variables, installing dependencies, etc. through a setup script (`./superset/setup.json`) +For each parallel tasks, Superset uses git worktrees to clone a new branch on your machine. Automate copying env variables, installing dependencies, etc. through a config file (`.superset/config.json`)
Creating a worktree diff --git a/apps/desktop/src/lib/trpc/routers/config/config.ts b/apps/desktop/src/lib/trpc/routers/config/config.ts new file mode 100644 index 00000000000..8e88eed22bb --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/config/config.ts @@ -0,0 +1,120 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { shell } from "electron"; +import { db } from "main/lib/db"; +import { z } from "zod"; +import { publicProcedure, router } from "../.."; + +function configExists(mainRepoPath: string): boolean { + const configPath = join(mainRepoPath, ".superset", "config.json"); + return existsSync(configPath); +} + +const CONFIG_TEMPLATE = `{ + "setup": [], + "teardown": [] +} +`; + +function getConfigPath(mainRepoPath: string): string { + return join(mainRepoPath, ".superset", "config.json"); +} + +function ensureConfigExists(mainRepoPath: string): string { + const configPath = getConfigPath(mainRepoPath); + const supersetDir = join(mainRepoPath, ".superset"); + + if (!existsSync(configPath)) { + // Create .superset directory if it doesn't exist + if (!existsSync(supersetDir)) { + mkdirSync(supersetDir, { recursive: true }); + } + // Create config.json with template + writeFileSync(configPath, CONFIG_TEMPLATE, "utf-8"); + } + + return configPath; +} + +export const createConfigRouter = () => { + return router({ + // Check if we should show the config toast for a project + shouldShowConfigToast: publicProcedure + .input(z.object({ projectId: z.string() })) + .query(({ input }) => { + const project = db.data.projects.find((p) => p.id === input.projectId); + if (!project) { + return false; + } + + // Don't show if already dismissed or if config exists + if (project.configToastDismissed) { + return false; + } + + return !configExists(project.mainRepoPath); + }), + + // Mark the config toast as dismissed for a project + dismissConfigToast: publicProcedure + .input(z.object({ projectId: z.string() })) + .mutation(async ({ input }) => { + await db.update((data) => { + const project = data.projects.find((p) => p.id === input.projectId); + if (project) { + project.configToastDismissed = true; + } + }); + return { success: true }; + }), + + // Get the config file path (creates it if it doesn't exist) + getConfigFilePath: publicProcedure + .input(z.object({ projectId: z.string() })) + .query(({ input }) => { + const project = db.data.projects.find((p) => p.id === input.projectId); + if (!project) { + return null; + } + return ensureConfigExists(project.mainRepoPath); + }), + + openConfigFile: publicProcedure + .input(z.object({ projectId: z.string() })) + .mutation(async ({ input }) => { + const project = db.data.projects.find((p) => p.id === input.projectId); + if (!project) { + throw new Error(`Project ${input.projectId} not found`); + } + + const configPath = ensureConfigExists(project.mainRepoPath); + + // Open in default editor + const result = await shell.openPath(configPath); + if (result) { + // Non-empty string means error + throw new Error(`Failed to open config file: ${result}`); + } + + return { success: true }; + }), + + revealConfigFile: publicProcedure + .input(z.object({ projectId: z.string() })) + .mutation(async ({ input }) => { + const project = db.data.projects.find((p) => p.id === input.projectId); + if (!project) { + throw new Error(`Project ${input.projectId} not found`); + } + + const configPath = ensureConfigExists(project.mainRepoPath); + + // Reveal in Finder + shell.showItemInFolder(configPath); + + return { success: true }; + }), + }); +}; + +export type ConfigRouter = ReturnType; diff --git a/apps/desktop/src/lib/trpc/routers/config/index.ts b/apps/desktop/src/lib/trpc/routers/config/index.ts new file mode 100644 index 00000000000..eca16092786 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/config/index.ts @@ -0,0 +1,2 @@ +export type { ConfigRouter } from "./config"; +export { createConfigRouter } from "./config"; diff --git a/apps/desktop/src/lib/trpc/routers/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts index c35ac6e5b32..44817a87322 100644 --- a/apps/desktop/src/lib/trpc/routers/index.ts +++ b/apps/desktop/src/lib/trpc/routers/index.ts @@ -1,5 +1,6 @@ import type { BrowserWindow } from "electron"; import { router } from ".."; +import { createConfigRouter } from "./config"; import { createExternalRouter } from "./external"; import { createNotificationsRouter } from "./notifications"; import { createProjectsRouter } from "./projects"; @@ -21,6 +22,7 @@ export const createAppRouter = (window: BrowserWindow) => { notifications: createNotificationsRouter(), external: createExternalRouter(), settings: createSettingsRouter(), + config: createConfigRouter(), }); }; diff --git a/apps/desktop/src/lib/trpc/routers/projects/projects.ts b/apps/desktop/src/lib/trpc/routers/projects/projects.ts index ca60519ef2e..e4cb22d1e25 100644 --- a/apps/desktop/src/lib/trpc/routers/projects/projects.ts +++ b/apps/desktop/src/lib/trpc/routers/projects/projects.ts @@ -82,6 +82,12 @@ function extractRepoName(urlInput: string): string | null { export const createProjectsRouter = (window: BrowserWindow) => { return router({ + get: publicProcedure + .input(z.object({ id: z.string() })) + .query(({ input }): Project | null => { + return db.data.projects.find((p) => p.id === input.id) ?? null; + }), + getRecents: publicProcedure.query((): Project[] => { return db.data.projects .slice() 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 index b3c63955d72..69af31182a7 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.test.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.test.ts @@ -19,7 +19,7 @@ describe("loadSetupConfig", () => { } }); - test("returns null when setup.json does not exist", () => { + test("returns null when config.json does not exist", () => { const config = loadSetupConfig(MAIN_REPO); expect(config).toBeNull(); }); @@ -30,7 +30,7 @@ describe("loadSetupConfig", () => { }; writeFileSync( - join(MAIN_REPO, ".superset", "setup.json"), + join(MAIN_REPO, ".superset", "config.json"), JSON.stringify(setupConfig), ); @@ -39,7 +39,10 @@ describe("loadSetupConfig", () => { }); test("returns null for invalid JSON", () => { - writeFileSync(join(MAIN_REPO, ".superset", "setup.json"), "{ invalid json"); + writeFileSync( + join(MAIN_REPO, ".superset", "config.json"), + "{ invalid json", + ); const config = loadSetupConfig(MAIN_REPO); expect(config).toBeNull(); @@ -47,7 +50,7 @@ describe("loadSetupConfig", () => { test("validates setup field must be an array", () => { writeFileSync( - join(MAIN_REPO, ".superset", "setup.json"), + join(MAIN_REPO, ".superset", "config.json"), JSON.stringify({ setup: "not-an-array" }), ); 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 d2cbfe6a8d5..573a118a6e5 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.ts @@ -3,7 +3,7 @@ import { join } from "node:path"; import type { SetupConfig } from "shared/types"; export function loadSetupConfig(mainRepoPath: string): SetupConfig | null { - const configPath = join(mainRepoPath, ".superset", "setup.json"); + const configPath = join(mainRepoPath, ".superset", "config.json"); if (!existsSync(configPath)) { return null; diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/teardown.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/teardown.ts index 26bef181460..170e533bf10 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/teardown.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/teardown.ts @@ -11,7 +11,7 @@ export interface TeardownResult { } function loadSetupConfig(mainRepoPath: string): SetupConfig | null { - const configPath = join(mainRepoPath, ".superset", "setup.json"); + const configPath = join(mainRepoPath, ".superset", "config.json"); if (!existsSync(configPath)) { return null; diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts index 9e826a6cb70..84206e661bb 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts @@ -118,6 +118,7 @@ export const createWorkspacesRouter = () => { workspace, initialCommands: setupConfig?.setup || null, worktreePath, + projectId: project.id, }; }), diff --git a/apps/desktop/src/main/lib/db/schemas.ts b/apps/desktop/src/main/lib/db/schemas.ts index 00abfd7f852..ef0d6480734 100644 --- a/apps/desktop/src/main/lib/db/schemas.ts +++ b/apps/desktop/src/main/lib/db/schemas.ts @@ -6,6 +6,7 @@ export interface Project { tabOrder: number | null; lastOpenedAt: number; createdAt: number; + configToastDismissed?: boolean; } export interface GitStatus { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceHeader/assets/cursor.svg b/apps/desktop/src/renderer/assets/app-icons/cursor.svg similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceHeader/assets/cursor.svg rename to apps/desktop/src/renderer/assets/app-icons/cursor.svg diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceHeader/assets/finder.png b/apps/desktop/src/renderer/assets/app-icons/finder.png similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceHeader/assets/finder.png rename to apps/desktop/src/renderer/assets/app-icons/finder.png diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceHeader/assets/iterm.png b/apps/desktop/src/renderer/assets/app-icons/iterm.png similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceHeader/assets/iterm.png rename to apps/desktop/src/renderer/assets/app-icons/iterm.png diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceHeader/assets/terminal.png b/apps/desktop/src/renderer/assets/app-icons/terminal.png similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceHeader/assets/terminal.png rename to apps/desktop/src/renderer/assets/app-icons/terminal.png diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceHeader/assets/vscode.svg b/apps/desktop/src/renderer/assets/app-icons/vscode.svg similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceHeader/assets/vscode.svg rename to apps/desktop/src/renderer/assets/app-icons/vscode.svg diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceHeader/assets/warp.png b/apps/desktop/src/renderer/assets/app-icons/warp.png similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceHeader/assets/warp.png rename to apps/desktop/src/renderer/assets/app-icons/warp.png diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceHeader/assets/xcode.svg b/apps/desktop/src/renderer/assets/app-icons/xcode.svg similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceHeader/assets/xcode.svg rename to apps/desktop/src/renderer/assets/app-icons/xcode.svg diff --git a/apps/desktop/src/renderer/components/OpenInButton/OpenInButton.tsx b/apps/desktop/src/renderer/components/OpenInButton/OpenInButton.tsx new file mode 100644 index 00000000000..646cf645719 --- /dev/null +++ b/apps/desktop/src/renderer/components/OpenInButton/OpenInButton.tsx @@ -0,0 +1,149 @@ +import { Button } from "@superset/ui/button"; +import { ButtonGroup } from "@superset/ui/button-group"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import type { ExternalApp } from "main/lib/db/schemas"; +import { useState } from "react"; +import { HiChevronDown } from "react-icons/hi2"; +import { LuCopy } from "react-icons/lu"; +import cursorIcon from "renderer/assets/app-icons/cursor.svg"; +import finderIcon from "renderer/assets/app-icons/finder.png"; +import itermIcon from "renderer/assets/app-icons/iterm.png"; +import terminalIcon from "renderer/assets/app-icons/terminal.png"; +import vscodeIcon from "renderer/assets/app-icons/vscode.svg"; +import warpIcon from "renderer/assets/app-icons/warp.png"; +import xcodeIcon from "renderer/assets/app-icons/xcode.svg"; +import { trpc } from "renderer/lib/trpc"; + +interface AppOption { + id: ExternalApp; + label: string; + icon: string; +} + +const APP_OPTIONS: AppOption[] = [ + { id: "finder", label: "Finder", icon: finderIcon }, + { id: "cursor", label: "Cursor", icon: cursorIcon }, + { id: "vscode", label: "VS Code", icon: vscodeIcon }, + { id: "xcode", label: "Xcode", icon: xcodeIcon }, + { id: "iterm", label: "iTerm", icon: itermIcon }, + { id: "warp", label: "Warp", icon: warpIcon }, + { id: "terminal", label: "Terminal", icon: terminalIcon }, +]; + +const getAppOption = (id: ExternalApp) => + APP_OPTIONS.find((app) => app.id === id) ?? APP_OPTIONS[1]; + +export interface OpenInButtonProps { + path: string | undefined; + /** Optional label to show next to the icon (e.g., folder name) */ + label?: string; + /** Show keyboard shortcut hints */ + showShortcuts?: boolean; +} + +export function OpenInButton({ + path, + label, + showShortcuts = false, +}: OpenInButtonProps) { + const [isOpen, setIsOpen] = useState(false); + const utils = trpc.useUtils(); + + const { data: lastUsedApp = "cursor" } = + trpc.settings.getLastUsedApp.useQuery(); + + const openInApp = trpc.external.openInApp.useMutation({ + onSuccess: () => utils.settings.getLastUsedApp.invalidate(), + }); + const copyPath = trpc.external.copyPath.useMutation(); + + const currentApp = getAppOption(lastUsedApp); + + const handleOpenIn = (app: ExternalApp) => { + if (!path) return; + openInApp.mutate({ path, app }); + setIsOpen(false); + }; + + const handleCopyPath = () => { + if (!path) return; + copyPath.mutate(path); + setIsOpen(false); + }; + + const handleOpenLastUsed = () => { + if (!path) return; + openInApp.mutate({ path, app: lastUsedApp }); + }; + + return ( + + {label && ( + + )} + + + + + + {APP_OPTIONS.map((app) => ( + handleOpenIn(app.id)} + className="flex items-center justify-between" + > +
+ {app.label} + {app.label} +
+ {showShortcuts && app.id === lastUsedApp && ( + ⌘O + )} +
+ ))} + + +
+ + Copy path +
+ {showShortcuts && ( + ⌘⇧C + )} +
+
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/OpenInButton/index.ts b/apps/desktop/src/renderer/components/OpenInButton/index.ts new file mode 100644 index 00000000000..a0fffc24752 --- /dev/null +++ b/apps/desktop/src/renderer/components/OpenInButton/index.ts @@ -0,0 +1,2 @@ +export type { OpenInButtonProps } from "./OpenInButton"; +export { OpenInButton } from "./OpenInButton"; diff --git a/apps/desktop/src/renderer/components/SetupConfigModal/SetupConfigModal.tsx b/apps/desktop/src/renderer/components/SetupConfigModal/SetupConfigModal.tsx new file mode 100644 index 00000000000..9aec57ec9c7 --- /dev/null +++ b/apps/desktop/src/renderer/components/SetupConfigModal/SetupConfigModal.tsx @@ -0,0 +1,87 @@ +import { Button } from "@superset/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@superset/ui/dialog"; +import { HiArrowTopRightOnSquare } from "react-icons/hi2"; +import { OpenInButton } from "renderer/components/OpenInButton"; +import { trpc } from "renderer/lib/trpc"; +import { + useCloseConfigModal, + useConfigModalOpen, + useConfigModalProjectId, +} from "renderer/stores/config-modal"; +import { WEBSITE_URL } from "shared/constants"; + +const CONFIG_TEMPLATE = `{ + "setup": [], + "teardown": [] +}`; + +export function SetupConfigModal() { + const isOpen = useConfigModalOpen(); + const projectId = useConfigModalProjectId(); + const closeModal = useCloseConfigModal(); + + const { data: project } = trpc.projects.get.useQuery( + { id: projectId ?? "" }, + { enabled: !!projectId }, + ); + + const { data: configFilePath } = trpc.config.getConfigFilePath.useQuery( + { projectId: projectId ?? "" }, + { enabled: !!projectId }, + ); + + const projectName = project?.name ?? "your-project"; + + const handleLearnMore = () => { + window.open(`${WEBSITE_URL}/scripts`, "_blank"); + }; + + return ( + !open && closeModal()}> + + + Configure scripts + + Edit config.json to automate setting up workspaces and running your + app. + + + +
+ {/* Header */} +
+ + {projectName}/.superset/config.json + + +
+ + {/* Code preview */} +
+
+							{CONFIG_TEMPLATE}
+						
+
+
+ +
+ +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/SetupConfigModal/index.ts b/apps/desktop/src/renderer/components/SetupConfigModal/index.ts new file mode 100644 index 00000000000..cb7f67a024b --- /dev/null +++ b/apps/desktop/src/renderer/components/SetupConfigModal/index.ts @@ -0,0 +1 @@ +export { SetupConfigModal } from "./SetupConfigModal"; diff --git a/apps/desktop/src/renderer/screens/main/components/StartView/index.tsx b/apps/desktop/src/renderer/screens/main/components/StartView/index.tsx index d5ea4d96271..05cea5d0278 100644 --- a/apps/desktop/src/renderer/screens/main/components/StartView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/StartView/index.tsx @@ -1,3 +1,4 @@ +import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { ChevronUp, FolderGit, FolderOpen, X } from "lucide-react"; import { useState } from "react"; @@ -5,6 +6,7 @@ import { HiExclamationTriangle } from "react-icons/hi2"; import { trpc } from "renderer/lib/trpc"; import { useOpenNew } from "renderer/react-query/projects"; import { useCreateWorkspace } from "renderer/react-query/workspaces"; +import { useOpenConfigModal } from "renderer/stores/config-modal"; import { ActionCard } from "./ActionCard"; import { CloneRepoDialog } from "./CloneRepoDialog"; import { StartTopBar } from "./StartTopBar"; @@ -54,17 +56,42 @@ export function StartView() { const { data: homeDir } = trpc.window.getHomeDir.useQuery(); const openNew = useOpenNew(); const createWorkspace = useCreateWorkspace(); + const openConfigModal = useOpenConfigModal(); + const dismissConfigToast = trpc.config.dismissConfigToast.useMutation(); const [error, setError] = useState(null); const [isCloneDialogOpen, setIsCloneDialogOpen] = useState(false); const [showAllProjects, setShowAllProjects] = useState(false); const [visibleCount, setVisibleCount] = useState(50); + const showConfigToastIfNeeded = (data: { + initialCommands: string[] | null; + projectId: string; + }) => { + if (!data.initialCommands || data.initialCommands.length === 0) { + toast.info("No setup script configured", { + description: "Automate workspace setup with a config.json file", + action: { + label: "Configure", + onClick: () => openConfigModal(data.projectId), + }, + onDismiss: () => { + dismissConfigToast.mutate({ projectId: data.projectId }); + }, + }); + } + }; + const handleOpenProject = () => { setError(null); openNew.mutate(undefined, { onSuccess: (result) => { if (!result.canceled && result.project) { - createWorkspace.mutate({ projectId: result.project.id }); + createWorkspace.mutate( + { projectId: result.project.id }, + { + onSuccess: showConfigToastIfNeeded, + }, + ); } }, onError: (err) => { @@ -78,6 +105,7 @@ export function StartView() { createWorkspace.mutate( { projectId }, { + onSuccess: showConfigToastIfNeeded, onError: (err) => { setError(err.message || "Failed to create workspace"); }, 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 518b422d02f..bd563b3ad2a 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 @@ -10,6 +10,7 @@ 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"; +import { useOpenConfigModal } from "renderer/stores/config-modal"; export interface WorkspaceDropdownProps { className?: string; @@ -21,12 +22,29 @@ export function WorkspaceDropdown({ className }: WorkspaceDropdownProps) { const { data: recentProjects = [] } = trpc.projects.getRecents.useQuery(); const createWorkspace = useCreateWorkspace(); const openNew = useOpenNew(); + const openConfigModal = useOpenConfigModal(); + const dismissConfigToast = trpc.config.dismissConfigToast.useMutation(); - const handleCreateWorkspace = (projectId: string) => { + const handleCreateWorkspace = async (projectId: string) => { toast.promise(createWorkspace.mutateAsync({ projectId }), { loading: "Creating workspace...", - success: () => { + success: (data) => { setIsOpen(false); + // Show config toast if no setup commands + if (!data.initialCommands || data.initialCommands.length === 0) { + setTimeout(() => { + toast.info("No setup script configured", { + description: "Automate workspace setup with a config.json file", + action: { + label: "Configure", + onClick: () => openConfigModal(projectId), + }, + onDismiss: () => { + dismissConfigToast.mutate({ projectId }); + }, + }); + }, 500); + } return "Workspace created"; }, error: (err) => diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceHeader/WorkspaceHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceHeader/WorkspaceHeader.tsx index 627a255f637..a872351f713 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceHeader/WorkspaceHeader.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceHeader/WorkspaceHeader.tsx @@ -1,148 +1,21 @@ -import { Button } from "@superset/ui/button"; -import { ButtonGroup } from "@superset/ui/button-group"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@superset/ui/dropdown-menu"; -import type { ExternalApp } from "main/lib/db/schemas"; -import { useState } from "react"; -import { HiChevronDown } from "react-icons/hi2"; -import { LuCopy } from "react-icons/lu"; -import { trpc } from "renderer/lib/trpc"; - -import cursorIcon from "./assets/cursor.svg"; -import finderIcon from "./assets/finder.png"; -import itermIcon from "./assets/iterm.png"; -import terminalIcon from "./assets/terminal.png"; -import vscodeIcon from "./assets/vscode.svg"; -import warpIcon from "./assets/warp.png"; -import xcodeIcon from "./assets/xcode.svg"; - -interface AppOption { - id: ExternalApp; - label: string; - icon: string; -} - -const APP_OPTIONS: AppOption[] = [ - { id: "finder", label: "Finder", icon: finderIcon }, - { id: "cursor", label: "Cursor", icon: cursorIcon }, - { id: "vscode", label: "VS Code", icon: vscodeIcon }, - { id: "xcode", label: "Xcode", icon: xcodeIcon }, - { id: "iterm", label: "iTerm", icon: itermIcon }, - { id: "warp", label: "Warp", icon: warpIcon }, - { id: "terminal", label: "Terminal", icon: terminalIcon }, -]; - -const getAppOption = (id: ExternalApp) => - APP_OPTIONS.find((app) => app.id === id) ?? APP_OPTIONS[1]; +import { OpenInButton } from "renderer/components/OpenInButton"; interface WorkspaceHeaderProps { worktreePath: string | undefined; } export function WorkspaceHeader({ worktreePath }: WorkspaceHeaderProps) { - const [isOpen, setIsOpen] = useState(false); - const utils = trpc.useUtils(); - - const { data: lastUsedApp = "cursor" } = - trpc.settings.getLastUsedApp.useQuery(); - - const openInApp = trpc.external.openInApp.useMutation({ - onSuccess: () => utils.settings.getLastUsedApp.invalidate(), - }); - const copyPath = trpc.external.copyPath.useMutation(); - const folderName = worktreePath ? worktreePath.split("/").filter(Boolean).pop() || worktreePath : null; - const currentApp = getAppOption(lastUsedApp); - - const handleOpenIn = (app: ExternalApp) => { - if (!worktreePath) return; - openInApp.mutate({ path: worktreePath, app }); - setIsOpen(false); - }; - - const handleCopyPath = () => { - if (!worktreePath) return; - copyPath.mutate(worktreePath); - setIsOpen(false); - }; - - const handleOpenLastUsed = () => { - if (!worktreePath) return; - openInApp.mutate({ path: worktreePath, app: lastUsedApp }); - }; return (
- - {folderName && ( - - )} - - - - - - {APP_OPTIONS.map((app) => ( - handleOpenIn(app.id)} - className="flex items-center justify-between" - > -
- {app.label} - {app.label} -
- {app.id === lastUsedApp && ( - ⌘O - )} -
- ))} - - -
- - Copy path -
- ⌘⇧C -
-
-
-
+
); } diff --git a/apps/desktop/src/renderer/screens/main/index.tsx b/apps/desktop/src/renderer/screens/main/index.tsx index 9bf8c70fdb5..196d6a8702f 100644 --- a/apps/desktop/src/renderer/screens/main/index.tsx +++ b/apps/desktop/src/renderer/screens/main/index.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { DndProvider } from "react-dnd"; import { useHotkeys } from "react-hotkeys-hook"; import { HiArrowPath } from "react-icons/hi2"; +import { SetupConfigModal } from "renderer/components/SetupConfigModal"; import { trpc } from "renderer/lib/trpc"; import { useCurrentView, useOpenSettings } from "renderer/stores/app-state"; import { useSidebarStore } from "renderer/stores/sidebar-state"; @@ -153,6 +154,7 @@ export function MainScreen() {
)} + ); } diff --git a/apps/desktop/src/renderer/stores/config-modal.ts b/apps/desktop/src/renderer/stores/config-modal.ts new file mode 100644 index 00000000000..e383435faba --- /dev/null +++ b/apps/desktop/src/renderer/stores/config-modal.ts @@ -0,0 +1,37 @@ +import { create } from "zustand"; +import { devtools } from "zustand/middleware"; + +interface ConfigModalState { + isOpen: boolean; + projectId: string | null; + openModal: (projectId: string) => void; + closeModal: () => void; +} + +export const useConfigModalStore = create()( + devtools( + (set) => ({ + isOpen: false, + projectId: null, + + openModal: (projectId) => { + set({ isOpen: true, projectId }); + }, + + closeModal: () => { + set({ isOpen: false, projectId: null }); + }, + }), + { name: "ConfigModalStore" }, + ), +); + +// Convenience hooks +export const useConfigModalOpen = () => + useConfigModalStore((state) => state.isOpen); +export const useConfigModalProjectId = () => + useConfigModalStore((state) => state.projectId); +export const useOpenConfigModal = () => + useConfigModalStore((state) => state.openModal); +export const useCloseConfigModal = () => + useConfigModalStore((state) => state.closeModal); diff --git a/apps/desktop/src/shared/constants.ts b/apps/desktop/src/shared/constants.ts index a5fecadd589..4021a83dbf8 100644 --- a/apps/desktop/src/shared/constants.ts +++ b/apps/desktop/src/shared/constants.ts @@ -22,3 +22,6 @@ export const SUPERSET_DIR_NAME = ENVIRONMENT.IS_DEV ? ".superset-dev" : ".superset"; export const WORKTREES_DIR_NAME = "worktrees"; + +// Website URL - defaults to production, can be overridden via env var for local dev +export const WEBSITE_URL = process.env.WEBSITE_URL || "https://superset.dev"; diff --git a/apps/website/src/app/scripts/page.tsx b/apps/website/src/app/scripts/page.tsx new file mode 100644 index 00000000000..55646fda475 --- /dev/null +++ b/apps/website/src/app/scripts/page.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { motion } from "framer-motion"; +import { Footer } from "../components/Footer"; +import { Header } from "../components/Header"; + +const CONFIG_EXAMPLE = `{ + "setup": [ + "bun install", + "bun run db:migrate" + ], + "teardown": [ + "docker-compose down" + ] +}`; + +const SHELL_SCRIPT_EXAMPLE = `{ + "setup": ["./.superset/setup.sh"], + "teardown": ["./.superset/teardown.sh"] +}`; + +export default function ScriptsPage() { + return ( + <> +
+
+
+ +

+ Setup & Teardown Scripts +

+

+ Automate workspace initialization and cleanup with config.json +

+ +
+

+ Overview +

+

+ Superset can automatically run commands when creating or + deleting workspaces. This is useful for: +

+
    +
  • Installing dependencies
  • +
  • Running database migrations
  • +
  • Starting background services
  • +
  • Cleaning up resources when done
  • +
+
+ +
+

+ Configuration +

+

+ Create a config.json{" "} + file in your project's{" "} + .superset directory: +

+
+								
+									your-project/.superset/config.json
+								
+							
+
+ +
+

Schema

+

+ The config file has two optional arrays: +

+
+								{CONFIG_EXAMPLE}
+							
+ +
+
+

+ setup +

+

+ Array of shell commands to run when a new workspace is + created. Commands run sequentially in the workspace's + worktree directory. +

+
+ +
+

+ teardown +

+

+ Array of shell commands to run when a workspace is deleted. + Useful for cleaning up resources like Docker containers or + temporary files. +

+
+
+
+ +
+

+ Using Shell Scripts +

+

+ For complex setup logic, reference shell scripts instead of + inline commands: +

+
+								
+									{SHELL_SCRIPT_EXAMPLE}
+								
+							
+

+ Make sure your scripts are executable:{" "} + + chmod +x .superset/setup.sh + +

+
+ +
+

+ How It Works +

+
    +
  1. + When you create a new workspace, Superset creates a git + worktree for your branch +
  2. +
  3. + If config.json exists + with setup commands, they run automatically in the new + worktree +
  4. +
  5. + Commands execute in a terminal tab so you can see output +
  6. +
  7. + When deleting a workspace, teardown commands run before the + worktree is removed +
  8. +
+
+ +
+

Tips

+
    +
  • + Keep setup scripts fast - they run every time you create a + workspace +
  • +
  • + Use && to chain + commands that depend on each other +
  • +
  • + Add .superset/ to your{" "} + .gitignore if you + don't want to share configs +
  • +
  • Or commit it to share workspace setup with your team
  • +
+
+
+
+
+