Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/superset
VITE_DEV_SERVER_PORT=4927
WEBSITE_URL=http://localhost:3001
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
File renamed without changes.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

<div align="center">
<img width="600" alt="Creating a worktree" src="assets/create-worktree.gif" />
Expand Down
83 changes: 83 additions & 0 deletions apps/desktop/src/lib/trpc/routers/config/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
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;
}
Comment on lines +22 to +36
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add error handling for file system operations.

The ensureConfigExists function performs file system operations (mkdirSync, writeFileSync) without error handling. Operations can fail due to permissions, disk space, or other I/O issues, potentially causing unhandled exceptions.

Consider wrapping in try-catch to provide better error feedback:

function ensureConfigExists(mainRepoPath: string): string {
	const configPath = getConfigPath(mainRepoPath);
	const supersetDir = join(mainRepoPath, ".superset");

	try {
		if (!existsSync(configPath)) {
			if (!existsSync(supersetDir)) {
				mkdirSync(supersetDir, { recursive: true });
			}
			writeFileSync(configPath, CONFIG_TEMPLATE, "utf-8");
		}
		return configPath;
	} catch (error) {
		throw new Error(`Failed to create config file: ${error instanceof Error ? error.message : 'Unknown error'}`);
	}
}

This ensures failures are caught and reported with context rather than causing silent crashes.

🤖 Prompt for AI Agents
In apps/desktop/src/lib/trpc/routers/config/config.ts around lines 22 to 36, the
ensureConfigExists function performs filesystem operations (mkdirSync,
writeFileSync) without error handling; wrap the block that checks/creates the
.superset directory and writes config.json in a try-catch, on error throw or
rethrow a new Error with contextual message including the original error message
(safely checking error instanceof Error), so callers receive a clear, readable
failure reason instead of an unhandled exception.


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<typeof createConfigRouter>;
2 changes: 2 additions & 0 deletions apps/desktop/src/lib/trpc/routers/config/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export type { ConfigRouter } from "./config";
export { createConfigRouter } from "./config";
2 changes: 2 additions & 0 deletions apps/desktop/src/lib/trpc/routers/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -21,6 +22,7 @@ export const createAppRouter = (window: BrowserWindow) => {
notifications: createNotificationsRouter(),
external: createExternalRouter(),
settings: createSettingsRouter(),
config: createConfigRouter(),
});
};

Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/lib/trpc/routers/projects/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
11 changes: 7 additions & 4 deletions apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand All @@ -30,7 +30,7 @@ describe("loadSetupConfig", () => {
};

writeFileSync(
join(MAIN_REPO, ".superset", "setup.json"),
join(MAIN_REPO, ".superset", "config.json"),
JSON.stringify(setupConfig),
);

Expand All @@ -39,15 +39,18 @@ 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();
});

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" }),
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ export const createWorkspacesRouter = () => {
workspace,
initialCommands: setupConfig?.setup || null,
worktreePath,
projectId: project.id,
};
}),

Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/main/lib/db/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export interface Project {
tabOrder: number | null;
lastOpenedAt: number;
createdAt: number;
configToastDismissed?: boolean;
}

export interface GitStatus {
Expand Down
149 changes: 149 additions & 0 deletions apps/desktop/src/renderer/components/OpenInButton/OpenInButton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ButtonGroup>
{label && (
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={handleOpenLastUsed}
disabled={!path}
title={`Open in ${currentApp.label}${showShortcuts ? " (⌘O)" : ""}`}
>
<img src={currentApp.icon} alt="" className="size-4 object-contain" />
<span className="font-medium">{label}</span>
</Button>
)}
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="gap-1"
disabled={!path}
>
<span>Open</span>
<HiChevronDown className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{APP_OPTIONS.map((app) => (
<DropdownMenuItem
key={app.id}
onClick={() => handleOpenIn(app.id)}
className="flex items-center justify-between"
>
<div className="flex items-center gap-2">
<img
src={app.icon}
alt={app.label}
className="size-4 object-contain"
/>
<span>{app.label}</span>
</div>
{showShortcuts && app.id === lastUsedApp && (
<span className="text-xs text-muted-foreground">⌘O</span>
)}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleCopyPath}
className="flex items-center justify-between"
>
<div className="flex items-center gap-2">
<LuCopy className="size-4" />
<span>Copy path</span>
</div>
{showShortcuts && (
<span className="text-xs text-muted-foreground">⌘⇧C</span>
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
);
}
2 changes: 2 additions & 0 deletions apps/desktop/src/renderer/components/OpenInButton/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export type { OpenInButtonProps } from "./OpenInButton";
export { OpenInButton } from "./OpenInButton";
Loading