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..dc327c0868f --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/config/config.ts @@ -0,0 +1,88 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { db } from "main/lib/db"; +import { CONFIG_FILE_NAME, PROJECT_SUPERSET_DIR_NAME } from "shared/constants"; +import { z } from "zod"; +import { publicProcedure, router } from "../.."; + +function configExists(mainRepoPath: string): boolean { + const configPath = join( + mainRepoPath, + PROJECT_SUPERSET_DIR_NAME, + CONFIG_FILE_NAME, + ); + return existsSync(configPath); +} + +const CONFIG_TEMPLATE = `{ + "setup": [], + "teardown": [] +} +`; + +function getConfigPath(mainRepoPath: string): string { + return join(mainRepoPath, PROJECT_SUPERSET_DIR_NAME, CONFIG_FILE_NAME); +} + +function ensureConfigExists(mainRepoPath: string): string { + const configPath = getConfigPath(mainRepoPath); + const supersetDir = join(mainRepoPath, PROJECT_SUPERSET_DIR_NAME); + + 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); + }), + }); +}; + +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..e2d5d821ba9 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 @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +import { CONFIG_FILE_NAME, PROJECT_SUPERSET_DIR_NAME } from "shared/constants"; import { loadSetupConfig } from "./setup"; const TEST_DIR = join(__dirname, ".test-tmp"); @@ -9,7 +10,7 @@ const MAIN_REPO = join(TEST_DIR, "main-repo"); describe("loadSetupConfig", () => { beforeEach(() => { // Create test directories - mkdirSync(join(MAIN_REPO, ".superset"), { recursive: true }); + mkdirSync(join(MAIN_REPO, PROJECT_SUPERSET_DIR_NAME), { recursive: true }); }); afterEach(() => { @@ -19,7 +20,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 +31,7 @@ describe("loadSetupConfig", () => { }; writeFileSync( - join(MAIN_REPO, ".superset", "setup.json"), + join(MAIN_REPO, PROJECT_SUPERSET_DIR_NAME, CONFIG_FILE_NAME), JSON.stringify(setupConfig), ); @@ -39,7 +40,10 @@ describe("loadSetupConfig", () => { }); test("returns null for invalid JSON", () => { - writeFileSync(join(MAIN_REPO, ".superset", "setup.json"), "{ invalid json"); + writeFileSync( + join(MAIN_REPO, PROJECT_SUPERSET_DIR_NAME, CONFIG_FILE_NAME), + "{ invalid json", + ); const config = loadSetupConfig(MAIN_REPO); expect(config).toBeNull(); @@ -47,7 +51,7 @@ describe("loadSetupConfig", () => { test("validates setup field must be an array", () => { writeFileSync( - join(MAIN_REPO, ".superset", "setup.json"), + join(MAIN_REPO, PROJECT_SUPERSET_DIR_NAME, CONFIG_FILE_NAME), 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..dc6440864d4 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.ts @@ -1,9 +1,14 @@ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; +import { CONFIG_FILE_NAME, PROJECT_SUPERSET_DIR_NAME } from "shared/constants"; import type { SetupConfig } from "shared/types"; export function loadSetupConfig(mainRepoPath: string): SetupConfig | null { - const configPath = join(mainRepoPath, ".superset", "setup.json"); + const configPath = join( + mainRepoPath, + PROJECT_SUPERSET_DIR_NAME, + CONFIG_FILE_NAME, + ); 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..df9df69e15f 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/teardown.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/teardown.ts @@ -1,6 +1,7 @@ import { execSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; +import { CONFIG_FILE_NAME, PROJECT_SUPERSET_DIR_NAME } from "shared/constants"; import type { SetupConfig } from "shared/types"; const TEARDOWN_TIMEOUT_MS = 60_000; // 60 seconds @@ -11,7 +12,11 @@ export interface TeardownResult { } function loadSetupConfig(mainRepoPath: string): SetupConfig | null { - const configPath = join(mainRepoPath, ".superset", "setup.json"); + const configPath = join( + mainRepoPath, + PROJECT_SUPERSET_DIR_NAME, + CONFIG_FILE_NAME, + ); 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..df41e8ca973 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, }; }), @@ -210,9 +211,26 @@ export const createWorkspacesRouter = () => { ); } + const project = db.data.projects.find( + (p) => p.id === workspace.projectId, + ); + const worktree = db.data.worktrees.find( + (wt) => wt.id === workspace.worktreeId, + ); + return { ...workspace, worktreePath: getWorktreePath(workspace.worktreeId) ?? "", + project: project + ? { + id: project.id, + name: project.name, + mainRepoPath: project.mainRepoPath, + } + : null, + worktree: worktree + ? { branch: worktree.branch, gitStatus: worktree.gitStatus } + : null, }; }), 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/ConfigFilePreview/ConfigFilePreview.tsx b/apps/desktop/src/renderer/components/ConfigFilePreview/ConfigFilePreview.tsx new file mode 100644 index 00000000000..7ad319bc503 --- /dev/null +++ b/apps/desktop/src/renderer/components/ConfigFilePreview/ConfigFilePreview.tsx @@ -0,0 +1,61 @@ +import { cn } from "@superset/ui/utils"; +import { OpenInButton } from "renderer/components/OpenInButton"; +import { + CONFIG_FILE_NAME, + CONFIG_TEMPLATE, + PROJECT_SUPERSET_DIR_NAME, + WEBSITE_URL, +} from "shared/constants"; +import { Button } from "@superset/ui/button"; +import { HiArrowTopRightOnSquare } from "react-icons/hi2"; +export interface ConfigFilePreviewProps { + projectName: string; + configFilePath?: string; + className?: string; +} + +export function ConfigFilePreview({ + projectName, + configFilePath, + className, +}: ConfigFilePreviewProps) { + const handleLearnMore = () => { + window.open(`${WEBSITE_URL}/scripts`, "_blank"); + }; + + return ( + <> +
+
+ + {projectName}/{PROJECT_SUPERSET_DIR_NAME}/{CONFIG_FILE_NAME} + + +
+ +
+
+						{CONFIG_TEMPLATE}
+					
+
+
+ +
+ +
+ + ); +} diff --git a/apps/desktop/src/renderer/components/ConfigFilePreview/index.ts b/apps/desktop/src/renderer/components/ConfigFilePreview/index.ts new file mode 100644 index 00000000000..e52ac58fc8b --- /dev/null +++ b/apps/desktop/src/renderer/components/ConfigFilePreview/index.ts @@ -0,0 +1,4 @@ +export { + ConfigFilePreview, + type ConfigFilePreviewProps, +} from "./ConfigFilePreview"; 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..68f4aacafc9 --- /dev/null +++ b/apps/desktop/src/renderer/components/SetupConfigModal/SetupConfigModal.tsx @@ -0,0 +1,53 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@superset/ui/dialog"; +import { ConfigFilePreview } from "renderer/components/ConfigFilePreview"; +import { trpc } from "renderer/lib/trpc"; +import { + useCloseConfigModal, + useConfigModalOpen, + useConfigModalProjectId, +} from "renderer/stores/config-modal"; +import { CONFIG_FILE_NAME } from "shared/constants"; + +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"; + + return ( + !open && closeModal()}> + + + Configure scripts + + Edit {CONFIG_FILE_NAME} to automate setting up workspaces and + running your app. + + + + + + + ); +} 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/react-query/workspaces/useCreateWorkspace.ts b/apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts index 5db3af70a30..171f98c04ed 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts @@ -1,10 +1,13 @@ +import { toast } from "@superset/ui/sonner"; import { trpc } from "renderer/lib/trpc"; +import { useOpenConfigModal } from "renderer/stores/config-modal"; import { useTabsStore } from "renderer/stores/tabs/store"; /** * Mutation hook for creating a new workspace * Automatically invalidates all workspace queries on success * Creates a terminal tab with setup commands if present + * Shows config toast if no setup commands are configured */ export function useCreateWorkspace( options?: Parameters[0], @@ -12,6 +15,8 @@ export function useCreateWorkspace( const utils = trpc.useUtils(); const addTab = useTabsStore((state) => state.addTab); const createOrAttach = trpc.terminal.createOrAttach.useMutation(); + const openConfigModal = useOpenConfigModal(); + const dismissConfigToast = trpc.config.dismissConfigToast.useMutation(); return trpc.workspaces.create.useMutation({ ...options, @@ -33,6 +38,18 @@ export function useCreateWorkspace( tabTitle: "Terminal", initialCommands: data.initialCommands, }); + } else { + // Show config toast if no setup commands + 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 }); + }, + }); } // Call user's onSuccess if provided diff --git a/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsContent.tsx b/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsContent.tsx index 812e0b84eeb..891f38b75b0 100644 --- a/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsContent.tsx +++ b/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsContent.tsx @@ -1,6 +1,7 @@ import type { SettingsSection } from "renderer/stores"; import { AppearanceSettings } from "./AppearanceSettings"; import { KeyboardShortcutsSettings } from "./KeyboardShortcutsSettings"; +import { WorkspaceSettings } from "./WorkspaceSettings"; interface SettingsContentProps { activeSection: SettingsSection; @@ -9,6 +10,7 @@ interface SettingsContentProps { export function SettingsContent({ activeSection }: SettingsContentProps) { return (
+ {activeSection === "workspace" && } {activeSection === "appearance" && } {activeSection === "keyboard" && }
diff --git a/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsSidebar.tsx b/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsSidebar.tsx index 0bde8d42e11..c6b5ab96ad6 100644 --- a/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsSidebar.tsx +++ b/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsSidebar.tsx @@ -1,9 +1,15 @@ import { cn } from "@superset/ui/utils"; +import { useState } from "react"; import { HiArrowLeft, + HiChevronDown, + HiChevronRight, HiOutlineCommandLine, + HiOutlineFolder, HiOutlinePaintBrush, } from "react-icons/hi2"; +import { trpc } from "renderer/lib/trpc"; +import { useSetActiveWorkspace } from "renderer/react-query/workspaces"; import { type SettingsSection, useCloseSettings } from "renderer/stores"; interface SettingsSidebarProps { @@ -11,11 +17,16 @@ interface SettingsSidebarProps { onSectionChange: (section: SettingsSection) => void; } -const SECTIONS: { +const GENERAL_SECTIONS: { id: SettingsSection; label: string; icon: React.ReactNode; }[] = [ + { + id: "workspace", + label: "Workspace", + icon: , + }, { id: "appearance", label: "Appearance", @@ -33,9 +44,33 @@ export function SettingsSidebar({ onSectionChange, }: SettingsSidebarProps) { const closeSettings = useCloseSettings(); + const { data: groups = [] } = trpc.workspaces.getAllGrouped.useQuery(); + const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); + const setActiveWorkspace = useSetActiveWorkspace(); + const [expandedProjects, setExpandedProjects] = useState>( + () => + new Set(activeWorkspace?.projectId ? [activeWorkspace.projectId] : []), + ); + + const toggleProject = (projectId: string) => { + setExpandedProjects((prev) => { + const next = new Set(prev); + if (next.has(projectId)) { + next.delete(projectId); + } else { + next.add(projectId); + } + return next; + }); + }; + + const handleWorkspaceClick = (workspaceId: string) => { + setActiveWorkspace.mutate({ id: workspaceId }); + onSectionChange("workspace"); + }; return ( -
+
{/* Back button */} - ))} - + {/* Projects & Workspaces */} +
+
+

+ Workspaces +

+ +
+ + {/* General Settings */} +
+

+ General +

+ +
+
); } diff --git a/apps/desktop/src/renderer/screens/main/components/SettingsView/WorkspaceSettings/WorkspaceSettings.tsx b/apps/desktop/src/renderer/screens/main/components/SettingsView/WorkspaceSettings/WorkspaceSettings.tsx new file mode 100644 index 00000000000..2ca324ef1ae --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/SettingsView/WorkspaceSettings/WorkspaceSettings.tsx @@ -0,0 +1,125 @@ +import { Input } from "@superset/ui/input"; +import { + HiOutlineCog6Tooth, + HiOutlineFolder, + HiOutlinePencilSquare, +} from "react-icons/hi2"; +import { LuGitBranch } from "react-icons/lu"; +import { ConfigFilePreview } from "renderer/components/ConfigFilePreview"; +import { trpc } from "renderer/lib/trpc"; +import { useWorkspaceRename } from "renderer/screens/main/components/TopBar/WorkspaceTabs/useWorkspaceRename"; + +export function WorkspaceSettings() { + const { data: activeWorkspace, isLoading } = + trpc.workspaces.getActive.useQuery(); + + const { data: configFilePath } = trpc.config.getConfigFilePath.useQuery( + { projectId: activeWorkspace?.projectId ?? "" }, + { enabled: !!activeWorkspace?.projectId }, + ); + + const rename = useWorkspaceRename( + activeWorkspace?.id ?? "", + activeWorkspace?.name ?? "", + ); + + if (isLoading) { + return ( +
+
+
+
+
+
+ ); + } + + if (!activeWorkspace) { + return ( +
+
+

Workspace

+

+ No active workspace selected +

+
+
+ ); + } + + return ( +
+
+

Workspace

+
+ +
+
+

Name

+ {rename.isRenaming ? ( + rename.setRenameValue(e.target.value)} + onBlur={rename.submitRename} + onKeyDown={rename.handleKeyDown} + className="text-base" + /> + ) : ( + + )} +
+ + {activeWorkspace.worktree && ( +
+

+ + Branch +

+
+

{activeWorkspace.worktree.branch}

+ {activeWorkspace.worktree.gitStatus?.needsRebase && ( + + Needs Rebase + + )} +
+
+ )} + +
+

+ + Path +

+

+ {activeWorkspace.worktreePath} +

+
+ + {activeWorkspace.project && ( +
+
+

+ + Scripts +

+
+ +
+ )} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/SettingsView/WorkspaceSettings/index.ts b/apps/desktop/src/renderer/screens/main/components/SettingsView/WorkspaceSettings/index.ts new file mode 100644 index 00000000000..52c413ca2c9 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/SettingsView/WorkspaceSettings/index.ts @@ -0,0 +1 @@ +export { WorkspaceSettings } from "./WorkspaceSettings"; 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..0ec504a01a1 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 @@ -22,7 +22,7 @@ export function WorkspaceDropdown({ className }: WorkspaceDropdownProps) { const createWorkspace = useCreateWorkspace(); const openNew = useOpenNew(); - const handleCreateWorkspace = (projectId: string) => { + const handleCreateWorkspace = async (projectId: string) => { toast.promise(createWorkspace.mutateAsync({ projectId }), { loading: "Creating workspace...", success: () => { diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroupContextMenu.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroupContextMenu.tsx index 2e299fe7393..d1cbb5326a1 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroupContextMenu.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroupContextMenu.tsx @@ -118,7 +118,7 @@ export function WorkspaceGroupContextMenu({ -
+
{PROJECT_COLORS.map((color) => ( - )} - - - - - - {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/app-state.ts b/apps/desktop/src/renderer/stores/app-state.ts index 9fe5264fbf9..b934b577631 100644 --- a/apps/desktop/src/renderer/stores/app-state.ts +++ b/apps/desktop/src/renderer/stores/app-state.ts @@ -2,7 +2,7 @@ import { create } from "zustand"; import { devtools } from "zustand/middleware"; export type AppView = "workspace" | "settings"; -export type SettingsSection = "appearance" | "keyboard"; +export type SettingsSection = "workspace" | "appearance" | "keyboard"; interface AppState { currentView: AppView; @@ -20,7 +20,7 @@ export const useAppStore = create()( (set) => ({ currentView: "workspace", isSettingsTabOpen: false, - settingsSection: "appearance", + settingsSection: "workspace", setView: (view) => { set({ currentView: view }); 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..eb813f2540a 100644 --- a/apps/desktop/src/shared/constants.ts +++ b/apps/desktop/src/shared/constants.ts @@ -21,4 +21,16 @@ export const PORTS = { export const SUPERSET_DIR_NAME = ENVIRONMENT.IS_DEV ? ".superset-dev" : ".superset"; +// Project-level directory name (always .superset, not conditional) +export const PROJECT_SUPERSET_DIR_NAME = ".superset"; export const WORKTREES_DIR_NAME = "worktrees"; +export const CONFIG_FILE_NAME = "config.json"; + +// Website URL - defaults to production, can be overridden via env var for local dev +export const WEBSITE_URL = process.env.WEBSITE_URL || "https://superset.sh"; + +// Config file template +export const CONFIG_TEMPLATE = `{ + "setup": [], + "teardown": [] +}`; 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
  • +
+
+
+
+
+