Skip to content
Closed
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
73 changes: 73 additions & 0 deletions apps/desktop/src/lib/trpc/routers/config/config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { db } from "main/lib/db";
import type { SetupConfig } from "shared/types";
import { z } from "zod";
import { publicProcedure, router } from "../..";

Expand Down Expand Up @@ -99,6 +100,78 @@ export const createConfigRouter = () => {
return { content: null, exists: false };
}
}),

// Get terminal presets for a project
getTerminalPresets: publicProcedure
.input(z.object({ projectId: z.string() }))
.query(({ input }) => {
const project = db.data.projects.find((p) => p.id === input.projectId);
if (!project) return [];

const configPath = getConfigPath(project.mainRepoPath);
if (!existsSync(configPath)) return [];

try {
const content = readFileSync(configPath, "utf-8");
const config = JSON.parse(content) as SetupConfig;
return config.terminalPresets || [];
} catch {
return [];
}
}),

// Save a new terminal preset
saveTerminalPreset: publicProcedure
.input(
z.object({
projectId: z.string(),
preset: z.object({
name: z.string(),
cwd: z.string().optional(),
commands: z.union([z.string(), z.array(z.string())]),
}),
}),
)
.mutation(({ input }) => {
const project = db.data.projects.find((p) => p.id === input.projectId);
if (!project) throw new Error("Project not found");

const configPath = ensureConfigExists(project.mainRepoPath);
const content = readFileSync(configPath, "utf-8");
const config = JSON.parse(content) as SetupConfig;

config.terminalPresets = config.terminalPresets || [];
config.terminalPresets.push(input.preset);

writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
return { success: true };
}),

// Delete a terminal preset by name
deleteTerminalPreset: publicProcedure
.input(
z.object({
projectId: z.string(),
presetName: z.string(),
}),
)
.mutation(({ input }) => {
const project = db.data.projects.find((p) => p.id === input.projectId);
if (!project) throw new Error("Project not found");

const configPath = getConfigPath(project.mainRepoPath);
if (!existsSync(configPath)) return { success: false };

const content = readFileSync(configPath, "utf-8");
const config = JSON.parse(content) as SetupConfig;

config.terminalPresets = (config.terminalPresets || []).filter(
(p) => p.name !== input.presetName,
);

writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
return { success: true };
}),
Comment on lines +123 to +174
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

Harden JSON read/write error handling in preset mutations

Both saveTerminalPreset and deleteTerminalPreset assume that config.json is valid JSON and readable. If the file is manually edited and broken, or a previous write was truncated, JSON.parse will throw and surface as an unhandled error from the TRPC procedure.

Given that getTerminalPresets and getConfigContent already defend against bad JSON, it would be good to harden the mutation paths similarly so a malformed config doesn’t completely break preset management. For example:

saveTerminalPreset: publicProcedure
  ...
- .mutation(({ input }) => {
+ .mutation(({ input }) => {
    const project = db.data.projects.find((p) => p.id === input.projectId);
    if (!project) throw new Error("Project not found");

-   const configPath = ensureConfigExists(project.mainRepoPath);
-   const content = readFileSync(configPath, "utf-8");
-   const config = JSON.parse(content) as SetupConfig;
-
-   config.terminalPresets = config.terminalPresets || [];
-   config.terminalPresets.push(input.preset);
-
-   writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
-   return { success: true };
+   const configPath = ensureConfigExists(project.mainRepoPath);
+   try {
+     const content = readFileSync(configPath, "utf-8");
+     const config = (JSON.parse(content) as SetupConfig) ?? {};
+
+     config.terminalPresets = config.terminalPresets || [];
+     config.terminalPresets.push(input.preset);
+
+     writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
+     return { success: true };
+   } catch (error) {
+     // Optionally log/annotate for debugging
+     return { success: false };
+   }
  }),

and analogously for deleteTerminalPreset (wrap read/parse/write in a try/catch and return { success: false } on failure).

You might also consider, as a follow‑up:

  • Initializing CONFIG_TEMPLATE to include "terminalPresets": [] for a clearer default shape.
  • Deciding whether preset names should be unique. Right now saveTerminalPreset allows duplicates, and deleteTerminalPreset removes all presets with a matching name.

These are not blockers, but tightening the mutation error handling will make the feature more resilient to config corruption.

Committable suggestion skipped: line range outside the PR's diff.

});
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,23 @@ import {
ContextMenuSeparator,
ContextMenuTrigger,
} from "@superset/ui/context-menu";
import { Columns2, Rows2, X } from "lucide-react";
import { Bookmark, Columns2, Rows2, X } from "lucide-react";
import type { ReactNode } from "react";

interface TabContentContextMenuProps {
children: ReactNode;
onSplitHorizontal: () => void;
onSplitVertical: () => void;
onClosePane: () => void;
onSaveAsPreset?: () => void;
}

export function TabContentContextMenu({
children,
onSplitHorizontal,
onSplitVertical,
onClosePane,
onSaveAsPreset,
}: TabContentContextMenuProps) {
return (
<ContextMenu>
Expand All @@ -33,6 +35,15 @@ export function TabContentContextMenu({
<Columns2 className="size-4" />
Split Vertically
</ContextMenuItem>
{onSaveAsPreset && (
<>
<ContextMenuSeparator />
<ContextMenuItem onSelect={onSaveAsPreset}>
<Bookmark className="size-4" />
Save as Preset
</ContextMenuItem>
</>
)}
<ContextMenuSeparator />
<ContextMenuItem variant="destructive" onSelect={onClosePane}>
<X className="size-4" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { HiMiniXMark } from "react-icons/hi2";
import { TbLayoutColumns, TbLayoutRows } from "react-icons/tb";
import type { MosaicBranch } from "react-mosaic-component";
import { MosaicWindow } from "react-mosaic-component";
import { trpc } from "renderer/lib/trpc";
import { useOpenPresetModal } from "renderer/stores/preset-modal";
import {
registerPaneRef,
unregisterPaneRef,
Expand Down Expand Up @@ -57,6 +59,10 @@ export function WindowPane({
const [splitOrientation, setSplitOrientation] =
useState<SplitOrientation>("vertical");

const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery();
const { data: terminalSession } = trpc.terminal.getSession.useQuery(paneId);
const openPresetModal = useOpenPresetModal();

useEffect(() => {
const container = containerRef.current;
if (container) {
Expand Down Expand Up @@ -111,6 +117,15 @@ export function WindowPane({
<TbLayoutRows className="size-4" />
);

const handleSaveAsPreset = () => {
const projectId = activeWorkspace?.projectId;
if (!projectId) return;

// Use terminal session cwd if available
const cwd = terminalSession?.cwd;
openPresetModal(projectId, cwd);
};

return (
<MosaicWindow<string>
path={path}
Expand Down Expand Up @@ -141,6 +156,9 @@ export function WindowPane({
onSplitHorizontal={() => splitPaneHorizontal(windowId, paneId, path)}
onSplitVertical={() => splitPaneVertical(windowId, paneId, path)}
onClosePane={() => removePane(paneId)}
onSaveAsPreset={
activeWorkspace?.projectId ? handleSaveAsPreset : undefined
}
>
{/* biome-ignore lint/a11y/useKeyWithClickEvents lint/a11y/noStaticElementInteractions: Terminal handles its own keyboard events and focus */}
<div
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { Button } from "@superset/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@superset/ui/dialog";
import { Input } from "@superset/ui/input";
import { Label } from "@superset/ui/label";
import { Textarea } from "@superset/ui/textarea";
import { useEffect, useState } from "react";
import { trpc } from "renderer/lib/trpc";
import {
useClosePresetModal,
usePresetModalOpen,
usePresetModalPrefillCwd,
usePresetModalProjectId,
} from "renderer/stores/preset-modal";

export function PresetModal() {
const isOpen = usePresetModalOpen();
const projectId = usePresetModalProjectId();
const prefillCwd = usePresetModalPrefillCwd();
const closeModal = useClosePresetModal();

const [name, setName] = useState("");
const [cwd, setCwd] = useState("");
const [commands, setCommands] = useState("");

const utils = trpc.useUtils();

const savePresetMutation = trpc.config.saveTerminalPreset.useMutation({
onSuccess: () => {
utils.config.getTerminalPresets.invalidate();
closeModal();
},
});

// Reset form when modal opens/closes
useEffect(() => {
if (isOpen) {
setName("");
setCwd(prefillCwd || "");
setCommands("");
}
}, [isOpen, prefillCwd]);

const handleSave = () => {
if (!projectId || !name.trim() || !commands.trim()) return;

// Split commands by newline and filter empty lines
const commandList = commands
.split("\n")
.map((c) => c.trim())
.filter((c) => c.length > 0);

savePresetMutation.mutate({
projectId,
preset: {
name: name.trim(),
cwd: cwd.trim() || undefined,
commands: commandList.length === 1 ? commandList[0] : commandList,
},
});
};

const isValid = name.trim().length > 0 && commands.trim().length > 0;

return (
<Dialog modal open={isOpen} onOpenChange={(open) => !open && closeModal()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Create Terminal Preset</DialogTitle>
<DialogDescription>
Save a terminal configuration for quick access from the sidebar.
</DialogDescription>
</DialogHeader>

<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="preset-name">Name</Label>
<Input
id="preset-name"
placeholder="e.g., Dev Server"
value={name}
onChange={(e) => setName(e.target.value)}
autoFocus
/>
</div>

<div className="space-y-2">
<Label htmlFor="preset-cwd">
Working Directory{" "}
<span className="text-muted-foreground font-normal">
(optional)
</span>
</Label>
<Input
id="preset-cwd"
placeholder="e.g., ./apps/web"
value={cwd}
onChange={(e) => setCwd(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Relative to project root or absolute path
</p>
</div>

<div className="space-y-2">
<Label htmlFor="preset-commands">Commands</Label>
<Textarea
id="preset-commands"
placeholder="One command per line"
value={commands}
onChange={(e) => setCommands(e.target.value)}
rows={3}
/>
<p className="text-xs text-muted-foreground">
Commands run sequentially when the preset is launched
</p>
</div>
</div>

<DialogFooter>
<Button variant="outline" onClick={closeModal}>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={!isValid || savePresetMutation.isPending}
>
{savePresetMutation.isPending ? "Saving..." : "Save Preset"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { PresetModal } from "./PresetModal";
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@superset/ui/context-menu";
import type React from "react";

interface PresetContextMenuProps {
onDelete: () => void;
children: React.ReactNode;
}

export function PresetContextMenu({
onDelete,
children,
}: PresetContextMenuProps) {
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent className="w-48">
<ContextMenuItem onSelect={onDelete} className="text-destructive">
Delete Preset
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
Loading