diff --git a/src-tauri/src/bin/codex_monitor_daemon.rs b/src-tauri/src/bin/codex_monitor_daemon.rs index ddc5e7f14..916e5a122 100644 --- a/src-tauri/src/bin/codex_monitor_daemon.rs +++ b/src-tauri/src/bin/codex_monitor_daemon.rs @@ -37,9 +37,26 @@ use backend::events::{AppServerEvent, EventSink, TerminalOutput}; use storage::{read_settings, read_workspaces, write_settings, write_workspaces}; use types::{ AppSettings, WorkspaceEntry, WorkspaceInfo, WorkspaceKind, WorkspaceSettings, WorktreeInfo, + WorktreeSetupStatus, }; const DEFAULT_LISTEN_ADDR: &str = "127.0.0.1:4732"; +const WORKTREE_SETUP_MARKERS_DIR: &str = "worktree-setup"; +const WORKTREE_SETUP_MARKER_EXT: &str = "ran"; + +fn worktree_setup_marker_path(data_dir: &PathBuf, workspace_id: &str) -> PathBuf { + data_dir + .join(WORKTREE_SETUP_MARKERS_DIR) + .join(format!("{workspace_id}.{WORKTREE_SETUP_MARKER_EXT}")) +} + +fn normalize_setup_script(script: Option) -> Option { + match script { + Some(value) if value.trim().is_empty() => None, + Some(value) => Some(value), + None => None, + } +} #[derive(Clone)] struct DaemonEventSink { @@ -272,7 +289,12 @@ impl DaemonState { worktree: Some(WorktreeInfo { branch: branch.to_string(), }), - settings: WorkspaceSettings::default(), + settings: WorkspaceSettings { + worktree_setup_script: normalize_setup_script( + parent_entry.settings.worktree_setup_script.clone(), + ), + ..WorkspaceSettings::default() + }, }; let (default_bin, codex_args) = { @@ -320,6 +342,51 @@ impl DaemonState { }) } + async fn worktree_setup_status(&self, workspace_id: String) -> Result { + let entry = { + let workspaces = self.workspaces.lock().await; + workspaces + .get(&workspace_id) + .cloned() + .ok_or_else(|| "workspace not found".to_string())? + }; + + let script = normalize_setup_script(entry.settings.worktree_setup_script.clone()); + let marker_exists = if entry.kind.is_worktree() { + worktree_setup_marker_path(&self.data_dir, &entry.id).exists() + } else { + false + }; + let should_run = entry.kind.is_worktree() && script.is_some() && !marker_exists; + + Ok(WorktreeSetupStatus { should_run, script }) + } + + async fn worktree_setup_mark_ran(&self, workspace_id: String) -> Result<(), String> { + let entry = { + let workspaces = self.workspaces.lock().await; + workspaces + .get(&workspace_id) + .cloned() + .ok_or_else(|| "workspace not found".to_string())? + }; + if !entry.kind.is_worktree() { + return Err("Not a worktree workspace.".to_string()); + } + let marker_path = worktree_setup_marker_path(&self.data_dir, &entry.id); + if let Some(parent) = marker_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|err| format!("Failed to prepare worktree marker directory: {err}"))?; + } + let ran_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0); + std::fs::write(&marker_path, format!("ran_at={ran_at}\n")) + .map_err(|err| format!("Failed to write worktree setup marker: {err}"))?; + Ok(()) + } + async fn remove_workspace(&self, id: String) -> Result<(), String> { let (entry, child_worktrees) = { let workspaces = self.workspaces.lock().await; @@ -688,12 +755,16 @@ impl DaemonState { settings: WorkspaceSettings, client_version: String, ) -> Result { + let mut settings = settings; + settings.worktree_setup_script = normalize_setup_script(settings.worktree_setup_script); + let ( previous_entry, entry_snapshot, parent_entry, previous_codex_home, previous_codex_args, + previous_worktree_setup_script, child_entries, ) = { let mut workspaces = self.workspaces.lock().await; @@ -703,6 +774,7 @@ impl DaemonState { .ok_or_else(|| "workspace not found".to_string())?; let previous_codex_home = previous_entry.settings.codex_home.clone(); let previous_codex_args = previous_entry.settings.codex_args.clone(); + let previous_worktree_setup_script = previous_entry.settings.worktree_setup_script.clone(); let entry_snapshot = match workspaces.get_mut(&id) { Some(entry) => { entry.settings = settings.clone(); @@ -726,12 +798,15 @@ impl DaemonState { parent_entry, previous_codex_home, previous_codex_args, + previous_worktree_setup_script, child_entries, ) }; let codex_home_changed = previous_codex_home != entry_snapshot.settings.codex_home; let codex_args_changed = previous_codex_args != entry_snapshot.settings.codex_args; + let worktree_setup_script_changed = + previous_worktree_setup_script != entry_snapshot.settings.worktree_setup_script; let connected = self.sessions.lock().await.contains_key(&id); if connected && (codex_home_changed || codex_args_changed) { let rollback_entry = previous_entry.clone(); @@ -778,7 +853,7 @@ impl DaemonState { if codex_home_changed || codex_args_changed { let app_settings = self.app_settings.lock().await.clone(); let default_bin = app_settings.codex_bin.clone(); - for child in child_entries { + for child in &child_entries { let connected = self.sessions.lock().await.contains_key(&child.id); if !connected { continue; @@ -832,6 +907,21 @@ impl DaemonState { } } } + if worktree_setup_script_changed && !entry_snapshot.kind.is_worktree() { + let child_ids = child_entries + .iter() + .map(|child| child.id.clone()) + .collect::>(); + if !child_ids.is_empty() { + let mut workspaces = self.workspaces.lock().await; + for child_id in child_ids { + if let Some(child) = workspaces.get_mut(&child_id) { + child.settings.worktree_setup_script = + entry_snapshot.settings.worktree_setup_script.clone(); + } + } + } + } let list: Vec<_> = { let workspaces = self.workspaces.lock().await; @@ -1770,6 +1860,16 @@ async fn handle_rpc_request( .await?; serde_json::to_value(workspace).map_err(|err| err.to_string()) } + "worktree_setup_status" => { + let workspace_id = parse_string(¶ms, "workspaceId")?; + let status = state.worktree_setup_status(workspace_id).await?; + serde_json::to_value(status).map_err(|err| err.to_string()) + } + "worktree_setup_mark_ran" => { + let workspace_id = parse_string(¶ms, "workspaceId")?; + state.worktree_setup_mark_ran(workspace_id).await?; + Ok(json!({ "ok": true })) + } "connect_workspace" => { let id = parse_string(¶ms, "id")?; state.connect_workspace(id, client_version).await?; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d3aed9000..0654814e3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -86,6 +86,8 @@ pub fn run() { workspaces::add_workspace, workspaces::add_clone, workspaces::add_worktree, + workspaces::worktree_setup_status, + workspaces::worktree_setup_mark_ran, workspaces::remove_workspace, workspaces::remove_worktree, workspaces::rename_worktree, diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 05deb065f..e858998c3 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -268,6 +268,15 @@ pub(crate) struct WorkspaceSettings { pub(crate) codex_args: Option, #[serde(default, rename = "launchScript")] pub(crate) launch_script: Option, + #[serde(default, rename = "worktreeSetupScript")] + pub(crate) worktree_setup_script: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub(crate) struct WorktreeSetupStatus { + #[serde(rename = "shouldRun")] + pub(crate) should_run: bool, + pub(crate) script: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] diff --git a/src-tauri/src/workspaces/commands.rs b/src-tauri/src/workspaces/commands.rs index 2cf5d1bd2..d9a5223c4 100644 --- a/src-tauri/src/workspaces/commands.rs +++ b/src-tauri/src/workspaces/commands.rs @@ -30,8 +30,30 @@ use crate::state::AppState; use crate::storage::write_workspaces; use crate::types::{ WorkspaceEntry, WorkspaceInfo, WorkspaceKind, WorkspaceSettings, WorktreeInfo, + WorktreeSetupStatus, }; +const WORKTREE_SETUP_MARKERS_DIR: &str = "worktree-setup"; +const WORKTREE_SETUP_MARKER_EXT: &str = "ran"; + +fn worktree_setup_marker_path(app: &AppHandle, workspace_id: &str) -> Result { + let app_data_dir = app + .path() + .app_data_dir() + .map_err(|err| format!("Failed to resolve app data dir: {err}"))?; + Ok(app_data_dir + .join(WORKTREE_SETUP_MARKERS_DIR) + .join(format!("{workspace_id}.{WORKTREE_SETUP_MARKER_EXT}"))) +} + +fn normalize_setup_script(script: Option) -> Option { + match script { + Some(value) if value.trim().is_empty() => None, + Some(value) => Some(value), + None => None, + } +} + #[tauri::command] pub(crate) async fn read_workspace_file( workspace_id: String, @@ -406,7 +428,12 @@ pub(crate) async fn add_worktree( worktree: Some(WorktreeInfo { branch: branch.to_string(), }), - settings: WorkspaceSettings::default(), + settings: WorkspaceSettings { + worktree_setup_script: normalize_setup_script( + parent_entry.settings.worktree_setup_script.clone(), + ), + ..WorkspaceSettings::default() + }, }; let (default_bin, codex_args) = { @@ -444,6 +471,85 @@ pub(crate) async fn add_worktree( }) } +#[tauri::command] +pub(crate) async fn worktree_setup_status( + workspace_id: String, + state: State<'_, AppState>, + app: AppHandle, +) -> Result { + if remote_backend::is_remote_mode(&*state).await { + let response = remote_backend::call_remote( + &*state, + app, + "worktree_setup_status", + json!({ "workspaceId": workspace_id }), + ) + .await?; + return serde_json::from_value(response).map_err(|err| err.to_string()); + } + + let entry = { + let workspaces = state.workspaces.lock().await; + workspaces + .get(&workspace_id) + .cloned() + .ok_or_else(|| "workspace not found".to_string())? + }; + + let script = normalize_setup_script(entry.settings.worktree_setup_script.clone()); + let marker_exists = if entry.kind.is_worktree() { + worktree_setup_marker_path(&app, &entry.id) + .map(|path| path.exists()) + .unwrap_or(false) + } else { + false + }; + let should_run = entry.kind.is_worktree() && script.is_some() && !marker_exists; + + Ok(WorktreeSetupStatus { should_run, script }) +} + +#[tauri::command] +pub(crate) async fn worktree_setup_mark_ran( + workspace_id: String, + state: State<'_, AppState>, + app: AppHandle, +) -> Result<(), String> { + if remote_backend::is_remote_mode(&*state).await { + remote_backend::call_remote( + &*state, + app, + "worktree_setup_mark_ran", + json!({ "workspaceId": workspace_id }), + ) + .await?; + return Ok(()); + } + + let entry = { + let workspaces = state.workspaces.lock().await; + workspaces + .get(&workspace_id) + .cloned() + .ok_or_else(|| "workspace not found".to_string())? + }; + if !entry.kind.is_worktree() { + return Err("Not a worktree workspace.".to_string()); + } + let marker_path = worktree_setup_marker_path(&app, &entry.id)?; + if let Some(parent) = marker_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|err| format!("Failed to prepare worktree marker directory: {err}"))?; + } + let ran_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0); + std::fs::write(&marker_path, format!("ran_at={ran_at}\n")) + .map_err(|err| format!("Failed to write worktree setup marker: {err}"))?; + Ok(()) +} + #[tauri::command] pub(crate) async fn remove_workspace( @@ -1011,12 +1117,16 @@ pub(crate) async fn update_workspace_settings( return serde_json::from_value(response).map_err(|err| err.to_string()); } + let mut settings = settings; + settings.worktree_setup_script = normalize_setup_script(settings.worktree_setup_script); + let ( previous_entry, entry_snapshot, parent_entry, previous_codex_home, previous_codex_args, + previous_worktree_setup_script, child_entries, ) = { let mut workspaces = state.workspaces.lock().await; @@ -1026,6 +1136,7 @@ pub(crate) async fn update_workspace_settings( .ok_or_else(|| "workspace not found".to_string())?; let previous_codex_home = previous_entry.settings.codex_home.clone(); let previous_codex_args = previous_entry.settings.codex_args.clone(); + let previous_worktree_setup_script = previous_entry.settings.worktree_setup_script.clone(); let entry_snapshot = apply_workspace_settings_update(&mut workspaces, &id, settings)?; let parent_entry = entry_snapshot .parent_id @@ -1043,12 +1154,15 @@ pub(crate) async fn update_workspace_settings( parent_entry, previous_codex_home, previous_codex_args, + previous_worktree_setup_script, child_entries, ) }; let codex_home_changed = previous_codex_home != entry_snapshot.settings.codex_home; let codex_args_changed = previous_codex_args != entry_snapshot.settings.codex_args; + let worktree_setup_script_changed = + previous_worktree_setup_script != entry_snapshot.settings.worktree_setup_script; let connected = state.sessions.lock().await.contains_key(&id); if connected && (codex_home_changed || codex_args_changed) { let rollback_entry = previous_entry.clone(); @@ -1089,7 +1203,7 @@ pub(crate) async fn update_workspace_settings( if codex_home_changed || codex_args_changed { let app_settings = state.app_settings.lock().await.clone(); let default_bin = app_settings.codex_bin.clone(); - for child in child_entries { + for child in &child_entries { let connected = state.sessions.lock().await.contains_key(&child.id); if !connected { continue; @@ -1132,6 +1246,21 @@ pub(crate) async fn update_workspace_settings( } } } + if worktree_setup_script_changed && !entry_snapshot.kind.is_worktree() { + let child_ids = child_entries + .iter() + .map(|child| child.id.clone()) + .collect::>(); + if !child_ids.is_empty() { + let mut workspaces = state.workspaces.lock().await; + for child_id in child_ids { + if let Some(child) = workspaces.get_mut(&child_id) { + child.settings.worktree_setup_script = + entry_snapshot.settings.worktree_setup_script.clone(); + } + } + } + } let list: Vec<_> = { let workspaces = state.workspaces.lock().await; workspaces.values().cloned().collect() diff --git a/src-tauri/src/workspaces/tests.rs b/src-tauri/src/workspaces/tests.rs index 8bc5ce69f..c0bda7420 100644 --- a/src-tauri/src/workspaces/tests.rs +++ b/src-tauri/src/workspaces/tests.rs @@ -46,6 +46,7 @@ fn workspace_with_id_and_kind( codex_home: None, codex_args: None, launch_script: None, + worktree_setup_script: None, }, } } @@ -196,6 +197,7 @@ fn update_workspace_settings_persists_sort_and_group() { settings.sidebar_collapsed = true; settings.git_root = Some("/tmp".to_string()); settings.launch_script = Some("npm run dev".to_string()); + settings.worktree_setup_script = Some("pnpm install".to_string()); let updated = apply_workspace_settings_update(&mut workspaces, &id, settings.clone()).expect("update"); @@ -204,6 +206,10 @@ fn update_workspace_settings_persists_sort_and_group() { assert!(updated.settings.sidebar_collapsed); assert_eq!(updated.settings.git_root.as_deref(), Some("/tmp")); assert_eq!(updated.settings.launch_script.as_deref(), Some("npm run dev")); + assert_eq!( + updated.settings.worktree_setup_script.as_deref(), + Some("pnpm install"), + ); let temp_dir = std::env::temp_dir().join(format!("codex-monitor-test-{}", Uuid::new_v4())); std::fs::create_dir_all(&temp_dir).expect("create temp dir"); @@ -218,4 +224,8 @@ fn update_workspace_settings_persists_sort_and_group() { assert!(stored.settings.sidebar_collapsed); assert_eq!(stored.settings.git_root.as_deref(), Some("/tmp")); assert_eq!(stored.settings.launch_script.as_deref(), Some("npm run dev")); + assert_eq!( + stored.settings.worktree_setup_script.as_deref(), + Some("pnpm install"), + ); } diff --git a/src/App.tsx b/src/App.tsx index 1a9eaa902..206f962a7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -89,6 +89,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 { useWorktreeSetupScript } from "./features/app/hooks/useWorktreeSetupScript"; import { useGitCommitController } from "./features/app/hooks/useGitCommitController"; import { WorkspaceHome } from "./features/workspaces/components/WorkspaceHome"; import { useWorkspaceHome } from "./features/workspaces/hooks/useWorkspaceHome"; @@ -694,6 +695,52 @@ function MainApp() { } }, [activeWorkspace, openRenameWorktreePrompt]); + const { + terminalTabs, + activeTerminalId, + onSelectTerminal, + onNewTerminal, + onCloseTerminal, + terminalState, + ensureTerminalWithTitle, + restartTerminalSession, + } = useTerminalController({ + activeWorkspaceId, + activeWorkspace, + terminalOpen, + onCloseTerminalPanel: closeTerminalPanel, + onDebug: addDebugEntry, + }); + + const ensureLaunchTerminal = useCallback( + (workspaceId: string) => ensureTerminalWithTitle(workspaceId, "launch", "Launch"), + [ensureTerminalWithTitle], + ); + + const launchScriptState = useWorkspaceLaunchScript({ + activeWorkspace, + updateWorkspaceSettings, + openTerminal, + ensureLaunchTerminal, + restartLaunchSession: restartTerminalSession, + terminalState, + activeTerminalId, + }); + + const worktreeSetupScriptState = useWorktreeSetupScript({ + ensureTerminalWithTitle, + restartTerminalSession, + openTerminal, + onDebug: addDebugEntry, + }); + + const handleWorktreeCreated = useCallback( + async (worktree: WorkspaceInfo, _parentWorkspace?: WorkspaceInfo) => { + await worktreeSetupScriptState.maybeRunWorktreeSetupScript(worktree); + }, + [worktreeSetupScriptState], + ); + const { exitDiffView, selectWorkspace, selectHome } = useWorkspaceSelection({ workspaces, isCompact, @@ -710,10 +757,13 @@ function MainApp() { confirmPrompt: confirmWorktreePrompt, cancelPrompt: cancelWorktreePrompt, updateBranch: updateWorktreeBranch, + updateSetupScript: updateWorktreeSetupScript, } = useWorktreePrompt({ addWorktreeAgent, + updateWorkspaceSettings, connectWorkspace, onSelectWorkspace: selectWorkspace, + onWorktreeCreated: handleWorktreeCreated, onCompactActivate: isCompact ? () => setActiveTab("codex") : undefined, onError: (message) => { addDebugEntry({ @@ -962,6 +1012,7 @@ function MainApp() { connectWorkspace, startThreadForWorkspace, sendUserMessageToThread, + onWorktreeCreated: handleWorktreeCreated, }); const { @@ -1322,37 +1373,6 @@ function MainApp() { ? centerMode === "chat" || centerMode === "diff" : (isTablet ? tabletTab : activeTab) === "codex") && !showWorkspaceHome; const showGitDetail = Boolean(selectedDiffPath) && isPhone; - const { - terminalTabs, - activeTerminalId, - onSelectTerminal, - onNewTerminal, - onCloseTerminal, - terminalState, - ensureTerminalWithTitle, - restartTerminalSession, - } = useTerminalController({ - activeWorkspaceId, - activeWorkspace, - terminalOpen, - onCloseTerminalPanel: closeTerminalPanel, - onDebug: addDebugEntry, - }); - - const ensureLaunchTerminal = useCallback( - (workspaceId: string) => ensureTerminalWithTitle(workspaceId, "launch", "Launch"), - [ensureTerminalWithTitle], - ); - - const launchScriptState = useWorkspaceLaunchScript({ - activeWorkspace, - updateWorkspaceSettings, - openTerminal, - ensureLaunchTerminal, - restartLaunchSession: restartTerminalSession, - terminalState, - activeTerminalId, - }); const { handleCycleAgent, handleCycleWorkspace } = useWorkspaceCycling({ workspaces, @@ -1912,6 +1932,7 @@ function MainApp() { onRenamePromptConfirm={handleRenamePromptConfirm} worktreePrompt={worktreePrompt} onWorktreePromptChange={updateWorktreeBranch} + onWorktreeSetupScriptChange={updateWorktreeSetupScript} onWorktreePromptCancel={cancelWorktreePrompt} onWorktreePromptConfirm={confirmWorktreePrompt} clonePrompt={clonePrompt} diff --git a/src/features/app/components/AppModals.tsx b/src/features/app/components/AppModals.tsx index 376343d20..80174f97e 100644 --- a/src/features/app/components/AppModals.tsx +++ b/src/features/app/components/AppModals.tsx @@ -34,6 +34,7 @@ type AppModalsProps = { onRenamePromptConfirm: () => void; worktreePrompt: WorktreePromptState; onWorktreePromptChange: (value: string) => void; + onWorktreeSetupScriptChange: (value: string) => void; onWorktreePromptCancel: () => void; onWorktreePromptConfirm: () => void; clonePrompt: ClonePromptState; @@ -57,6 +58,7 @@ export const AppModals = memo(function AppModals({ onRenamePromptConfirm, worktreePrompt, onWorktreePromptChange, + onWorktreeSetupScriptChange, onWorktreePromptCancel, onWorktreePromptConfirm, clonePrompt, @@ -90,9 +92,13 @@ export const AppModals = memo(function AppModals({ diff --git a/src/features/app/hooks/useWorktreeSetupScript.ts b/src/features/app/hooks/useWorktreeSetupScript.ts new file mode 100644 index 000000000..c65a4fe23 --- /dev/null +++ b/src/features/app/hooks/useWorktreeSetupScript.ts @@ -0,0 +1,84 @@ +import { useCallback, useRef } from "react"; +import type { DebugEntry, WorkspaceInfo } from "../../../types"; +import { buildErrorDebugEntry } from "../../../utils/debugEntries"; +import { + getWorktreeSetupStatus, + markWorktreeSetupRan, + openTerminalSession, + writeTerminalSession, +} from "../../../services/tauri"; + +const WORKTREE_SETUP_TERMINAL_ID = "worktree-setup"; +const WORKTREE_SETUP_TERMINAL_TITLE = "Setup"; +const DEFAULT_TERMINAL_COLS = 120; +const DEFAULT_TERMINAL_ROWS = 32; + +type UseWorktreeSetupScriptOptions = { + ensureTerminalWithTitle: (workspaceId: string, terminalId: string, title: string) => string; + restartTerminalSession: (workspaceId: string, terminalId: string) => Promise; + openTerminal: () => void; + onDebug?: (entry: DebugEntry) => void; +}; + +export type WorktreeSetupScriptState = { + maybeRunWorktreeSetupScript: (worktree: WorkspaceInfo) => Promise; +}; + +export function useWorktreeSetupScript({ + ensureTerminalWithTitle, + restartTerminalSession, + openTerminal, + onDebug, +}: UseWorktreeSetupScriptOptions): WorktreeSetupScriptState { + const runningRef = useRef>(new Set()); + + const maybeRunWorktreeSetupScript = useCallback( + async (worktree: WorkspaceInfo) => { + if ((worktree.kind ?? "main") !== "worktree") { + return; + } + if (runningRef.current.has(worktree.id)) { + return; + } + runningRef.current.add(worktree.id); + try { + const status = await getWorktreeSetupStatus(worktree.id); + const script = status.script?.trim() ? status.script : null; + if (!status.shouldRun || !script) { + return; + } + + openTerminal(); + const terminalId = ensureTerminalWithTitle( + worktree.id, + WORKTREE_SETUP_TERMINAL_ID, + WORKTREE_SETUP_TERMINAL_TITLE, + ); + + try { + await restartTerminalSession(worktree.id, terminalId); + } catch (error) { + onDebug?.(buildErrorDebugEntry("worktree setup restart error", error)); + } + + await openTerminalSession( + worktree.id, + terminalId, + DEFAULT_TERMINAL_COLS, + DEFAULT_TERMINAL_ROWS, + ); + await writeTerminalSession(worktree.id, terminalId, `${script}\n`); + await markWorktreeSetupRan(worktree.id); + } catch (error) { + onDebug?.(buildErrorDebugEntry("worktree setup script error", error)); + } finally { + runningRef.current.delete(worktree.id); + } + }, + [ensureTerminalWithTitle, onDebug, openTerminal, restartTerminalSession], + ); + + return { + maybeRunWorktreeSetupScript, + }; +} diff --git a/src/features/workspaces/components/WorktreePrompt.tsx b/src/features/workspaces/components/WorktreePrompt.tsx index c75dd67bc..d6ae8a304 100644 --- a/src/features/workspaces/components/WorktreePrompt.tsx +++ b/src/features/workspaces/components/WorktreePrompt.tsx @@ -3,21 +3,29 @@ import { useEffect, useRef } from "react"; type WorktreePromptProps = { workspaceName: string; branch: string; + setupScript: string; + scriptError?: string | null; error?: string | null; onChange: (value: string) => void; + onSetupScriptChange: (value: string) => void; onCancel: () => void; onConfirm: () => void; isBusy?: boolean; + isSavingScript?: boolean; }; export function WorktreePrompt({ workspaceName, branch, + setupScript, + scriptError = null, error = null, onChange, + onSetupScriptChange, onCancel, onConfirm, isBusy = false, + isSavingScript = false, }: WorktreePromptProps) { const inputRef = useRef(null); @@ -63,6 +71,21 @@ export function WorktreePrompt({ } }} /> +
+
Worktree setup script
+
+ Runs once in a dedicated terminal after each new worktree is created. +
+