use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; #[cfg(feature = "local_fs")] use anyhow::Result; use crate::app_state::{BranchSnapshot, LeafContents, LeafSnapshot, PaneNodeSnapshot}; use crate::launch_configs::launch_config::SplitDirection; use crate::terminal::cli_agent::CLIAgent; use crate::themes::theme::AnsiColorIdentifier; use crate::ui_components::icons::Icon; use super::tab_config::{ generated_worktree_path_string, TabConfig, TabConfigPaneNode, TabConfigPaneType, TabConfigParam, TabConfigParamType, AUTOGENERATED_BRANCH_NAME_PARAM, }; /// The type of session the user wants to start. /// /// Wraps the existing `CLIAgent` for third-party agents and adds /// Terminal and Oz as first-class variants. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum SessionType { Terminal, Oz, CliAgent(CLIAgent), } impl SessionType { /// The CLI command to auto-run for this session type, if any. /// Returns `None` for Terminal and Oz (Oz uses agent view, not a CLI command). fn command_prefix(&self) -> Option<&'static str> { match self { SessionType::Terminal | SessionType::Oz => None, SessionType::CliAgent(agent) => Some(agent.command_prefix()), } } /// The icon to display for this session type. pub(crate) fn icon(&self) -> Icon { match self { SessionType::Terminal => Icon::Terminal, SessionType::Oz => Icon::Oz, SessionType::CliAgent(agent) => agent.icon().unwrap_or(Icon::Terminal), } } /// Short label for the session type pill in the modal. pub(crate) fn pill_label(&self) -> &'static str { match self { SessionType::Terminal => "Terminal", SessionType::Oz => "Built in agent", SessionType::CliAgent(CLIAgent::Claude) => "Claude", SessionType::CliAgent(CLIAgent::Codex) => "Codex", SessionType::CliAgent(CLIAgent::Gemini) => "Gemini", SessionType::CliAgent(agent) => agent.display_name(), } } } /// The user's selections from the session config modal. /// /// This is the modal's output — the caller decides what to do with it. pub struct SessionConfigSelection { pub session_type: SessionType, pub directory: PathBuf, pub enable_worktree: bool, pub autogenerate_worktree_branch_name: bool, } const WORKTREE_BRANCH_PARAM: &str = "worktree_branch_name"; const WORKTREE_BRANCH_DEFAULT: &str = "my-feature-branch"; // Rust `format!` treats `{...}` as interpolation syntax, so we escape braces // to emit the literal Handlebars placeholder `{{name}}` for the later // tab-config render pass. In `format!("{{{{{name}}}}}")`, the outer doubled // braces become literal `{` / `}`, and `{name}` interpolates the helper arg. fn handlebars_placeholder(name: &str) -> String { format!("{{{{{name}}}}}") } /// Derives a human-readable config name from the directory and worktree setting. /// e.g. "Worktree: my-repo" or "New tab: my-repo". fn config_name(directory: &Path, enable_worktree: bool) -> String { let repo = directory .file_name() .and_then(|n| n.to_str()) .or_else(|| directory.to_str()) .unwrap_or("untitled"); let prefix = if enable_worktree { "Worktree" } else { "New tab" }; format!("{prefix}: {repo}") } /// Builds a `TabConfig` from the given session parameters. /// /// This is a pure function — it produces the canonical `TabConfig` /// that can be either rendered directly (via `render_tab_config`) or /// serialized to disk (via `write_tab_config`). pub fn build_tab_config( session_type: &SessionType, directory: &Path, enable_worktree: bool, autogenerate_worktree_branch_name: bool, ) -> TabConfig { let mut commands: Vec = Vec::new(); let mut params = HashMap::new(); let mut title = None; if enable_worktree { if autogenerate_worktree_branch_name { let autogenerated_branch_name = handlebars_placeholder(AUTOGENERATED_BRANCH_NAME_PARAM); let worktree_path = generated_worktree_path_string(directory, &autogenerated_branch_name); commands.push(format!( "git worktree add -b {autogenerated_branch_name} {worktree_path}" )); commands.push(format!("cd {worktree_path}")); } else { let worktree_branch_name = handlebars_placeholder(WORKTREE_BRANCH_PARAM); let worktree_path = generated_worktree_path_string(directory, &worktree_branch_name); commands.push(format!( "git worktree add -b {worktree_branch_name} {worktree_path}" )); commands.push(format!("cd {worktree_path}")); params.insert( WORKTREE_BRANCH_PARAM.to_string(), TabConfigParam { description: Some("New worktree branch name".to_string()), default: Some(WORKTREE_BRANCH_DEFAULT.to_string()), param_type: TabConfigParamType::Text, }, ); title = Some(worktree_branch_name); } } if let Some(prefix) = session_type.command_prefix() { commands.push(prefix.to_string()); } let pane_type = match session_type { SessionType::Oz => TabConfigPaneType::Agent, SessionType::Terminal | SessionType::CliAgent(_) => TabConfigPaneType::Terminal, }; TabConfig { name: config_name(directory, enable_worktree), title, color: None, panes: vec![TabConfigPaneNode { id: "main".to_string(), pane_type: Some(pane_type), split: None, children: None, is_focused: None, directory: Some(directory.to_string_lossy().into_owned()), commands: if commands.is_empty() { None } else { Some(commands) }, shell: None, }], params, source_path: None, } } /// Serializes a `TabConfig` to TOML and writes it to an unused path in `dir`. /// /// Creates `dir` if it doesn't exist. Returns the path of the written file. /// The filesystem watcher will automatically pick up the new file. #[cfg(feature = "local_fs")] pub fn write_tab_config(config: &TabConfig, dir: &Path, base_name: &str) -> Result { std::fs::create_dir_all(dir)?; let path = crate::user_config::find_unused_toml_path(dir, base_name); let toml_string = toml::to_string_pretty(config)?; std::fs::write(&path, toml_string)?; Ok(path) } /// Returns whether the given directory is inside a git repository. /// /// Checks for a `.git` directory at the path itself and walks up /// parent directories. pub fn is_git_repo(path: &Path) -> bool { let mut current = path; loop { if current.join(".git").is_dir() { return true; } match current.parent() { Some(parent) if parent != current => current = parent, _ => return false, } } } /// Builds a `TabConfig` from a live tab's pane tree snapshot. /// /// Walks the `PaneNodeSnapshot` recursively and produces a flat `[[panes]]` /// array. Non-terminal leaves (notebook, code, settings, etc.) are replaced /// with empty terminal panes to preserve the spatial layout. pub fn tab_config_from_pane_snapshot( snapshot: &PaneNodeSnapshot, custom_title: Option, color: Option, ) -> TabConfig { let mut panes = Vec::new(); let mut counter: usize = 0; snapshot_to_flat_panes(snapshot, &mut panes, &mut counter); TabConfig { name: "My Tab Config".to_string(), title: custom_title, color, panes, params: HashMap::new(), source_path: None, } } /// Recursively converts a `PaneNodeSnapshot` into flat `TabConfigPaneNode` entries. /// Returns the ID assigned to the root of this subtree. fn snapshot_to_flat_panes( snapshot: &PaneNodeSnapshot, panes: &mut Vec, counter: &mut usize, ) -> String { match snapshot { PaneNodeSnapshot::Branch(BranchSnapshot { direction, children, }) => { *counter += 1; let my_id = format!("p{counter}"); // Record where this subtree starts so we can insert the split node // before all of its descendants (root-first ordering). let insert_pos = panes.len(); // Recurse into children first to collect their IDs. let child_ids: Vec = children .iter() .map(|(_, child)| snapshot_to_flat_panes(child, panes, counter)) .collect(); let split_direction = match direction { crate::app_state::SplitDirection::Horizontal => SplitDirection::Horizontal, crate::app_state::SplitDirection::Vertical => SplitDirection::Vertical, }; panes.insert( insert_pos, TabConfigPaneNode { id: my_id.clone(), pane_type: None, split: Some(split_direction), children: Some(child_ids), is_focused: None, directory: None, commands: None, shell: None, }, ); my_id } PaneNodeSnapshot::Leaf(LeafSnapshot { is_focused, custom_vertical_tabs_title: _, contents, }) => { *counter += 1; let my_id = format!("p{counter}"); let (directory, pane_type) = match contents { LeafContents::Terminal(terminal) => { // If the agent view was open in fullscreen, treat as an Agent pane. let pane_type = if terminal.active_conversation_id.is_some() { TabConfigPaneType::Agent } else { TabConfigPaneType::Terminal }; (terminal.cwd.clone(), pane_type) } LeafContents::AmbientAgent(_) => (None, TabConfigPaneType::Cloud), // Non-terminal panes become empty terminal panes to preserve layout. _ => (None, TabConfigPaneType::Terminal), }; panes.push(TabConfigPaneNode { id: my_id.clone(), pane_type: Some(pane_type), split: None, children: None, is_focused: if *is_focused { Some(true) } else { None }, directory, commands: None, shell: None, }); my_id } } } #[cfg(test)] #[path = "session_config_tests.rs"] mod tests;