Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,315 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user