Files
galaxy/app/src/tab_configs/session_config.rs
T

316 lines
11 KiB
Rust

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<String> = 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<PathBuf> {
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<String>,
color: Option<AnsiColorIdentifier>,
) -> 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<TabConfigPaneNode>,
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<String> = 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;