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
11 changes: 11 additions & 0 deletions src-tauri/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -268,10 +268,21 @@ pub(crate) struct WorkspaceSettings {
pub(crate) codex_args: Option<String>,
#[serde(default, rename = "launchScript")]
pub(crate) launch_script: Option<String>,
#[serde(default, rename = "launchScripts")]
pub(crate) launch_scripts: Option<Vec<LaunchScriptEntry>>,
#[serde(default, rename = "worktreeSetupScript")]
pub(crate) worktree_setup_script: Option<String>,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
pub(crate) struct LaunchScriptEntry {
pub(crate) id: String,
pub(crate) script: String,
pub(crate) icon: String,
#[serde(default)]
pub(crate) label: Option<String>,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
pub(crate) struct WorktreeSetupStatus {
#[serde(rename = "shouldRun")]
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/workspaces/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ fn workspace_with_id_and_kind(
codex_home: None,
codex_args: None,
launch_script: None,
launch_scripts: None,
worktree_setup_script: None,
},
}
Expand Down
19 changes: 19 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ import { useLiquidGlassEffect } from "./features/app/hooks/useLiquidGlassEffect"
import { useCopyThread } from "./features/threads/hooks/useCopyThread";
import { useTerminalController } from "./features/terminal/hooks/useTerminalController";
import { useWorkspaceLaunchScript } from "./features/app/hooks/useWorkspaceLaunchScript";
import { useWorkspaceLaunchScripts } from "./features/app/hooks/useWorkspaceLaunchScripts";
import { useWorktreeSetupScript } from "./features/app/hooks/useWorktreeSetupScript";
import { useGitCommitController } from "./features/app/hooks/useGitCommitController";
import { WorkspaceHome } from "./features/workspaces/components/WorkspaceHome";
Expand Down Expand Up @@ -772,6 +773,23 @@ function MainApp() {
activeTerminalId,
});

const launchScriptsState = useWorkspaceLaunchScripts({
activeWorkspace,
updateWorkspaceSettings,
openTerminal,
ensureLaunchTerminal: (workspaceId, entry, title) => {
const label = entry.label?.trim() || entry.icon;
return ensureTerminalWithTitle(
workspaceId,
`launch:${entry.id}`,
title || `Launch ${label}`,
);
},
restartLaunchSession: restartTerminalSession,
terminalState,
activeTerminalId,
});

const worktreeSetupScriptState = useWorktreeSetupScript({
ensureTerminalWithTitle,
restartTerminalSession,
Expand Down Expand Up @@ -1717,6 +1735,7 @@ function MainApp() {
onCloseLaunchScriptEditor: launchScriptState.onCloseEditor,
onLaunchScriptDraftChange: launchScriptState.onDraftScriptChange,
onSaveLaunchScript: launchScriptState.onSaveLaunchScript,
launchScriptsState,
mainHeaderActionsNode: (
<MainHeaderActions
centerMode={centerMode}
Expand Down
90 changes: 88 additions & 2 deletions src/features/app/components/LaunchScriptButton.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { useEffect, useRef } from "react";
import Play from "lucide-react/dist/esm/icons/play";
import type { LaunchScriptIconId } from "../../../types";
import { LaunchScriptIconPicker } from "./LaunchScriptIconPicker";
import { DEFAULT_LAUNCH_SCRIPT_ICON } from "../utils/launchScriptIcons";

type LaunchScriptButtonProps = {
launchScript: string | null;
Expand All @@ -12,6 +15,18 @@ type LaunchScriptButtonProps = {
onCloseEditor: () => void;
onDraftChange: (value: string) => void;
onSave: () => void;
showNew?: boolean;
newEditorOpen?: boolean;
newDraftScript?: string;
newDraftIcon?: LaunchScriptIconId;
newDraftLabel?: string;
newError?: string | null;
onOpenNew?: () => void;
onCloseNew?: () => void;
onNewDraftChange?: (value: string) => void;
onNewDraftIconChange?: (value: LaunchScriptIconId) => void;
onNewDraftLabelChange?: (value: string) => void;
onCreateNew?: () => void;
};

export function LaunchScriptButton({
Expand All @@ -25,6 +40,18 @@ export function LaunchScriptButton({
onCloseEditor,
onDraftChange,
onSave,
showNew = false,
newEditorOpen = false,
newDraftScript = "",
newDraftIcon = DEFAULT_LAUNCH_SCRIPT_ICON,
newDraftLabel = "",
newError = null,
onOpenNew,
onCloseNew,
onNewDraftChange,
onNewDraftIconChange,
onNewDraftLabelChange,
onCreateNew,
}: LaunchScriptButtonProps) {
const popoverRef = useRef<HTMLDivElement | null>(null);
const hasLaunchScript = Boolean(launchScript?.trim());
Expand All @@ -39,12 +66,13 @@ export function LaunchScriptButton({
return;
}
onCloseEditor();
onCloseNew?.();
};
window.addEventListener("mousedown", handleClick);
return () => {
window.removeEventListener("mousedown", handleClick);
};
}, [editorOpen, onCloseEditor]);
}, [editorOpen, onCloseEditor, onCloseNew]);

return (
<div className="launch-script-menu" ref={popoverRef}>
Expand Down Expand Up @@ -80,11 +108,24 @@ export function LaunchScriptButton({
<button
type="button"
className="ghost"
onClick={onCloseEditor}
onClick={() => {
onCloseEditor();
onCloseNew?.();
}}
data-tauri-drag-region="false"
>
Cancel
</button>
{showNew && onOpenNew && (
<button
type="button"
className="ghost"
onClick={onOpenNew}
data-tauri-drag-region="false"
>
New
</button>
)}
<button
type="button"
className="primary"
Expand All @@ -95,6 +136,51 @@ export function LaunchScriptButton({
{isSaving ? "Saving..." : "Save"}
</button>
</div>
{showNew && newEditorOpen && onNewDraftChange && onNewDraftIconChange && onCreateNew && (
<div className="launch-script-new">
<div className="launch-script-title">New launch script</div>
<LaunchScriptIconPicker
value={newDraftIcon}
onChange={onNewDraftIconChange}
/>
<input
className="launch-script-input"
type="text"
placeholder="Optional label"
value={newDraftLabel}
onChange={(event) => onNewDraftLabelChange?.(event.target.value)}
data-tauri-drag-region="false"
/>
<textarea
className="launch-script-textarea"
placeholder="e.g. npm run dev"
value={newDraftScript}
onChange={(event) => onNewDraftChange(event.target.value)}
rows={5}
data-tauri-drag-region="false"
/>
{newError && <div className="launch-script-error">{newError}</div>}
<div className="launch-script-actions">
<button
type="button"
className="ghost"
onClick={onCloseNew}
data-tauri-drag-region="false"
>
Cancel
</button>
<button
type="button"
className="primary"
onClick={onCreateNew}
disabled={isSaving}
data-tauri-drag-region="false"
>
{isSaving ? "Saving..." : "Create"}
</button>
</div>
</div>
)}
</div>
)}
</div>
Expand Down
134 changes: 134 additions & 0 deletions src/features/app/components/LaunchScriptEntryButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { useEffect, useRef } from "react";
import type { LaunchScriptEntry, LaunchScriptIconId } from "../../../types";
import { LaunchScriptIconPicker } from "./LaunchScriptIconPicker";
import { getLaunchScriptIcon, getLaunchScriptIconLabel } from "../utils/launchScriptIcons";

type LaunchScriptEntryButtonProps = {
entry: LaunchScriptEntry;
editorOpen: boolean;
draftScript: string;
draftIcon: LaunchScriptIconId;
draftLabel: string;
isSaving: boolean;
error: string | null;
onRun: () => void;
onOpenEditor: () => void;
onCloseEditor: () => void;
onDraftChange: (value: string) => void;
onDraftIconChange: (value: LaunchScriptIconId) => void;
onDraftLabelChange: (value: string) => void;
onSave: () => void;
onDelete: () => void;
};

export function LaunchScriptEntryButton({
entry,
editorOpen,
draftScript,
draftIcon,
draftLabel,
isSaving,
error,
onRun,
onOpenEditor,
onCloseEditor,
onDraftChange,
onDraftIconChange,
onDraftLabelChange,
onSave,
onDelete,
}: LaunchScriptEntryButtonProps) {
const popoverRef = useRef<HTMLDivElement | null>(null);
const Icon = getLaunchScriptIcon(entry.icon);
const iconLabel = getLaunchScriptIconLabel(entry.icon);

useEffect(() => {
if (!editorOpen) {
return;
}
const handleClick = (event: MouseEvent) => {
const target = event.target as Node;
if (popoverRef.current?.contains(target)) {
return;
}
onCloseEditor();
};
window.addEventListener("mousedown", handleClick);
return () => {
window.removeEventListener("mousedown", handleClick);
};
}, [editorOpen, onCloseEditor]);

return (
<div className="launch-script-menu" ref={popoverRef}>
<div className="launch-script-buttons">
<button
type="button"
className="ghost main-header-action launch-script-run"
onClick={onRun}
onContextMenu={(event) => {
event.preventDefault();
onOpenEditor();
}}
data-tauri-drag-region="false"
aria-label={entry.label?.trim() || iconLabel}
title={entry.label?.trim() || iconLabel}
>
<Icon size={14} aria-hidden />
</button>
</div>
{editorOpen && (
<div className="launch-script-popover popover-surface" role="dialog">
<div className="launch-script-title">
{entry.label?.trim() || "Launch script"}
</div>
<LaunchScriptIconPicker value={draftIcon} onChange={onDraftIconChange} />
<input
className="launch-script-input"
type="text"
placeholder="Optional label"
value={draftLabel}
onChange={(event) => onDraftLabelChange(event.target.value)}
data-tauri-drag-region="false"
/>
<textarea
className="launch-script-textarea"
placeholder="e.g. npm run dev"
value={draftScript}
onChange={(event) => onDraftChange(event.target.value)}
rows={6}
data-tauri-drag-region="false"
/>
{error && <div className="launch-script-error">{error}</div>}
<div className="launch-script-actions">
<button
type="button"
className="ghost"
onClick={onCloseEditor}
data-tauri-drag-region="false"
>
Cancel
</button>
<button
type="button"
className="ghost launch-script-delete"
onClick={onDelete}
data-tauri-drag-region="false"
>
Delete
</button>
<button
type="button"
className="primary"
onClick={onSave}
disabled={isSaving}
data-tauri-drag-region="false"
>
{isSaving ? "Saving..." : "Save"}
</button>
</div>
</div>
)}
</div>
);
}
34 changes: 34 additions & 0 deletions src/features/app/components/LaunchScriptIconPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { LaunchScriptIconId } from "../utils/launchScriptIcons";
import {
LAUNCH_SCRIPT_ICON_OPTIONS,
getLaunchScriptIcon,
} from "../utils/launchScriptIcons";

type LaunchScriptIconPickerProps = {
value: LaunchScriptIconId;
onChange: (value: LaunchScriptIconId) => void;
};

export function LaunchScriptIconPicker({ value, onChange }: LaunchScriptIconPickerProps) {
return (
<div className="launch-script-icon-picker">
{LAUNCH_SCRIPT_ICON_OPTIONS.map((option) => {
const Icon = getLaunchScriptIcon(option.id);
const selected = option.id === value;
return (
<button
key={option.id}
type="button"
className={`launch-script-icon-option${selected ? " is-selected" : ""}`}
onClick={() => onChange(option.id)}
aria-label={option.label}
aria-pressed={selected}
data-tauri-drag-region="false"
>
<Icon size={14} aria-hidden />
</button>
);
})}
</div>
);
}
Loading