316 lines
11 KiB
Rust
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;
|