Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 56 additions & 1 deletion app/src/launch_configs/launch_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use crate::app_state::{
AppState, LeafContents, PaneNodeSnapshot, SplitDirection as StateSplitDirection, TabSnapshot,
WindowSnapshot,
};
use crate::tab::SelectedTabColor;
use crate::themes::theme::AnsiColorIdentifier;

#[cfg(test)]
Expand Down Expand Up @@ -38,20 +39,63 @@ impl LaunchConfig {
pub struct WindowTemplate {
#[serde(skip_serializing_if = "Option::is_none", default)]
pub active_tab_index: Option<usize>,
/// Tab groups in this window. Tabs reference a group via
/// [`TabTemplate::group`], an index into this list (#13898).
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub tab_groups: Vec<TabGroupTemplate>,
pub tabs: Vec<TabTemplate>,
}

/// A tab group saved in a launch configuration.
///
/// Groups are referenced by member tabs through their index in
/// [`WindowTemplate::tab_groups`]; runtime [`TabGroupId`]s are not serialized
/// since they are per-session UUIDs — fresh ids are minted when the launch
/// config opens.
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)]
pub struct TabGroupTemplate {
#[serde(skip_serializing_if = "Option::is_none", default)]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub color: Option<AnsiColorIdentifier>,
#[serde(skip_serializing_if = "is_falsey", default)]
pub collapsed: Option<bool>,
}

impl From<WindowSnapshot> for WindowTemplate {
fn from(snapshot: WindowSnapshot) -> Self {
let mut active_tab_index = None;
let mut num_valid_tabs = 0;

// Map each group's runtime id to its index in the serialized list so
// member tabs can reference it (#13898).
let group_index_by_id: std::collections::HashMap<_, _> = snapshot
.tab_groups
.iter()
.enumerate()
.map(|(index, group)| (group.id, index))
.collect();
let tab_groups = snapshot
.tab_groups
.iter()
.map(|group| TabGroupTemplate {
name: group.name.clone(),
color: match group.color {
SelectedTabColor::Color(color) => Some(color),
_ => None,
},
collapsed: group.collapsed.then_some(true),
})
.collect();

let tabs = snapshot
.tabs
.into_iter()
.enumerate()
.filter_map(|(i, tab)| {
let tab = tab.try_into().ok()?;
let group_id = tab.group_id;
let mut tab: TabTemplate = tab.try_into().ok()?;
tab.group = group_id.and_then(|id| group_index_by_id.get(&id)).copied();

if i == snapshot.active_tab_index {
active_tab_index = Some(num_valid_tabs);
Expand All @@ -65,6 +109,7 @@ impl From<WindowSnapshot> for WindowTemplate {

Self {
active_tab_index,
tab_groups,
tabs,
}
}
Expand Down Expand Up @@ -187,6 +232,10 @@ pub struct TabTemplate {
pub commands: Vec<CommandTemplate>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub color: Option<AnsiColorIdentifier>,
/// Index into [`WindowTemplate::tab_groups`] for the group this tab
/// belongs to, if any (#13898).
#[serde(skip_serializing_if = "Option::is_none", default)]
pub group: Option<usize>,
}

impl TabTemplate {
Expand Down Expand Up @@ -238,6 +287,9 @@ impl TryFrom<TabSnapshot> for TabTemplate {
layout: snapshot.root.try_into()?,
commands: Vec::new(),
color,
// Group membership is assigned by `WindowTemplate::from`, which owns
// the group-id → index mapping.
group: None,
})
}
}
Expand Down Expand Up @@ -278,6 +330,7 @@ pub fn make_mock_single_window_launch_config() -> LaunchConfig {
active_window_index: Some(0),
windows: vec![WindowTemplate {
active_tab_index: Some(0),
tab_groups: Vec::new(),
tabs: vec![
TabTemplate {
title: Some("First Tab".to_string()),
Expand All @@ -290,6 +343,7 @@ pub fn make_mock_single_window_launch_config() -> LaunchConfig {
},
commands: Vec::new(),
color: None,
group: None,
},
TabTemplate {
title: Some("Second Tab".to_string()),
Expand All @@ -302,6 +356,7 @@ pub fn make_mock_single_window_launch_config() -> LaunchConfig {
},
commands: Vec::new(),
color: None,
group: None,
},
],
}],
Expand Down
135 changes: 133 additions & 2 deletions app/src/launch_configs/launch_config_tests.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
use std::path::PathBuf;

use super::{CommandTemplate, LaunchConfig, PaneMode, PaneTemplateType};
use super::{CommandTemplate, LaunchConfig, PaneMode, PaneTemplateType, TabGroupTemplate};
use crate::app_state::{
AppState, BranchSnapshot, LeafContents, LeafSnapshot, NotebookPaneSnapshot, PaneFlex,
PaneNodeSnapshot, SplitDirection, TabSnapshot, TerminalPaneSnapshot, WindowSnapshot,
PaneNodeSnapshot, SplitDirection, TabGroupSnapshot, TabSnapshot, TerminalPaneSnapshot,
WindowSnapshot,
};
use crate::drive::OpenWarpDriveObjectSettings;
use crate::tab::SelectedTabColor;
use crate::themes::theme::AnsiColorIdentifier;
use crate::workspace::tab_group::TabGroupId;

fn single_tab_snapshot(root: PaneNodeSnapshot) -> AppState {
AppState {
Expand Down Expand Up @@ -526,3 +529,131 @@ fn test_config_with_active_tab_being_filtered() {
let template = LaunchConfig::from_snapshot("Test".into(), &state);
assert_eq!(template.windows[0].active_tab_index, None)
}

fn terminal_tab_snapshot(group_id: Option<TabGroupId>) -> TabSnapshot {
TabSnapshot {
custom_title: None,
default_directory_color: None,
selected_color: SelectedTabColor::default(),
root: PaneNodeSnapshot::Leaf(LeafSnapshot {
is_focused: true,
custom_vertical_tabs_title: None,
contents: LeafContents::Terminal(TerminalPaneSnapshot {
uuid: vec![],
cwd: Some("/some/dir".into()),
is_active: true,
is_read_only: false,
shell_launch_data: None,
input_config: None,
llm_model_override: None,
active_profile_id: None,
conversation_ids_to_restore: vec![],
active_conversation_id: None,
}),
}),
left_panel: None,
right_panel: None,
group_id,
pinned: false,
}
}

/// Regression test for #13898: saving a window with grouped tabs as a launch
/// configuration must persist the groups and each tab's group membership.
#[test]
fn test_config_from_snapshot_maps_tab_groups() {
let group_a = TabGroupId::new();
let group_b = TabGroupId::new();
let mut state = multi_tab_snapshot(
0,
vec![
terminal_tab_snapshot(Some(group_a)),
terminal_tab_snapshot(None),
terminal_tab_snapshot(Some(group_b)),
],
);
state.windows[0].tab_groups = vec![
TabGroupSnapshot {
id: group_a,
name: Some("Backend".to_string()),
color: SelectedTabColor::Color(AnsiColorIdentifier::Blue),
collapsed: true,
pinned: false,
},
TabGroupSnapshot {
id: group_b,
name: None,
color: SelectedTabColor::Unset,
collapsed: false,
pinned: false,
},
];

let template = LaunchConfig::from_snapshot("Test".into(), &state);
let window = &template.windows[0];

assert_eq!(
window.tab_groups,
vec![
TabGroupTemplate {
name: Some("Backend".to_string()),
color: Some(AnsiColorIdentifier::Blue),
collapsed: Some(true),
},
TabGroupTemplate {
name: None,
color: None,
collapsed: None,
},
],
);
assert_eq!(window.tabs[0].group, Some(0));
assert_eq!(window.tabs[1].group, None);
assert_eq!(window.tabs[2].group, Some(1));
}

/// Tab groups survive a YAML serialize → deserialize round trip (#13898).
#[test]
fn test_launch_config_yaml_round_trips_tab_groups() {
let group_a = TabGroupId::new();
let mut state = multi_tab_snapshot(
0,
vec![
terminal_tab_snapshot(Some(group_a)),
terminal_tab_snapshot(None),
],
);
state.windows[0].tab_groups = vec![TabGroupSnapshot {
id: group_a,
name: Some("Frontend".to_string()),
color: SelectedTabColor::Unset,
collapsed: false,
pinned: false,
}];

let template = LaunchConfig::from_snapshot("Round Trip".into(), &state);
let yaml = serde_yaml::to_string(&template).expect("launch config should serialize");
let parsed: LaunchConfig = serde_yaml::from_str(&yaml).expect("launch config should parse");

assert_eq!(parsed.windows[0].tab_groups, template.windows[0].tab_groups);
assert_eq!(parsed.windows[0].tabs[0].group, Some(0));
assert_eq!(parsed.windows[0].tabs[1].group, None);
}

/// Launch configs written before tab groups existed still parse (#13898).
#[test]
fn test_launch_config_yaml_without_groups_parses() {
let config: LaunchConfig = serde_yaml::from_str(
r#"
name: Legacy Config
windows:
- tabs:
- layout:
cwd: /tmp
"#,
)
.expect("legacy launch config should parse");

assert!(config.windows[0].tab_groups.is_empty());
assert_eq!(config.windows[0].tabs[0].group, None);
}
37 changes: 37 additions & 0 deletions app/src/workspace/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3853,6 +3853,37 @@ impl Workspace {
) {
let start_index = self.tabs.len();

// Recreate the config's tab groups with fresh runtime ids so member
// tabs below can be reattached by their template group index (#13898).
// Mirrors grouped-tab restoration in `configure_new_workspace`.
let group_ids: Vec<TabGroupId> = if FeatureFlag::GroupedTabs.is_enabled() {
window
.tab_groups
.iter()
.map(|group_template| {
let group = TabGroup {
id: TabGroupId::new(),
name: group_template.name.clone(),
color: group_template
.color
.map_or(SelectedTabColor::Unset, SelectedTabColor::Color),
collapsed: group_template.collapsed.unwrap_or_default(),
draggable_state: Default::default(),
// Launch configs don't persist pinned state (for tabs or
// groups): a config can open into a window that already
// has unpinned tabs, and restoring a pinned group there
// would break the pinned-region ordering invariant.
pinned: false,
};
let group_id = group.id;
self.tab_groups.insert(group_id, group);
group_id
})
.collect()
} else {
Vec::new()
};

window
.tabs
.iter()
Expand All @@ -3867,6 +3898,12 @@ impl Workspace {
self.tabs[start_index + tab_index].selected_color = tab_template
.color
.map_or(SelectedTabColor::Unset, SelectedTabColor::Color);
// Drop the group reference if its index doesn't resolve (e.g.
// hand-edited YAML or the GroupedTabs flag being disabled).
self.tabs[start_index + tab_index].group_id = tab_template
.group
.and_then(|group_index| group_ids.get(group_index))
.copied();
});

if !window.tabs.is_empty() {
Expand Down