diff --git a/nebula_app/src/display/mod.rs b/nebula_app/src/display/mod.rs index 06aadc1..2c65806 100644 --- a/nebula_app/src/display/mod.rs +++ b/nebula_app/src/display/mod.rs @@ -132,8 +132,8 @@ pub(crate) use ui::theme::write_nebula_prompt_theme; pub(crate) fn caret_blink_on() -> bool { ui::caret::is_on() } -pub(crate) use settings::SettingsOpacityTarget; pub use settings::{NebulaSettingsSection, SettingsDropdown, SettingsHit, settings_hit}; +pub(crate) use settings::{NewTabPosition, SettingsOpacityTarget}; /// 按显示列宽贪心断行(确认框正文等 UI 段落用):CJK 逐字可断,行首空 /// 格吞掉;零宽字符跟随前一个字。不做拉丁连词回退——正文以中文为主, @@ -1450,6 +1450,9 @@ pub struct Display { /// 捕获态实时回显:当前按住的修饰键前缀("Ctrl+Shift+")。松开清空。 pub nebula_keymap_capture_preview: String, pub nebula_tab_reveal_motion: settings::TabRevealMotion, + /// 新标签插入策略。只在**真正创建标签**时生效;会话恢复与工作区导入 + /// 保持各自记录的顺序,不读这个值。 + pub nebula_new_tab_position: settings::NewTabPosition, pub nebula_font_family: String, nebula_font_families: Vec, nebula_font_notice: Option, @@ -1968,6 +1971,7 @@ impl Display { nebula_keymap_capture: None, nebula_keymap_capture_preview: String::new(), nebula_tab_reveal_motion: settings_init.tab_reveal, + nebula_new_tab_position: settings_init.new_tab_position, nebula_font_family: settings_init.font_family, nebula_font_families, nebula_font_notice: None, @@ -2992,6 +2996,7 @@ impl Display { panel_resize: self.nebula_panel_resize, cjk_bold_regular: self.nebula_cjk_bold_regular, tab_reveal: self.nebula_tab_reveal_motion, + new_tab_position: self.nebula_new_tab_position, preview_bg: self.preview_terminal_bg(), preview_fg: { let bg = self.preview_terminal_bg(); @@ -3234,6 +3239,15 @@ impl Display { self.pending_update.dirty = true; } + pub fn set_new_tab_position_option(&mut self, index: usize) { + if let Some(position) = settings::NEW_TAB_POSITION_OPTIONS.get(index) { + self.nebula_new_tab_position = *position; + self.persist_nebula_settings(); + } + self.nebula_settings_dropdown = None; + self.pending_update.dirty = true; + } + /// Returns true when the default cursor style changed (the caller then /// pushes the new default into every live terminal). pub fn set_cursor_shape_option(&mut self, index: usize) -> bool { @@ -4812,6 +4826,7 @@ impl Display { copy_on_select: self.nebula_copy_on_select, cjk_bold_regular: self.nebula_cjk_bold_regular, tab_reveal: self.nebula_tab_reveal_motion, + new_tab_position: self.nebula_new_tab_position, theme: self.nebula_theme_preference, follow_system_theme: self.nebula_follow_system_theme, pinned_hosts: self.nebula_pinned_hosts.clone(), @@ -4909,6 +4924,7 @@ impl Display { self.reset_glyph_cache(); } self.nebula_tab_reveal_motion = settings.tab_reveal; + self.nebula_new_tab_position = settings.new_tab_position; self.nebula_window_opacity = settings.opacity; self.nebula_background = if settings.follow_system_theme { Some(active_theme.palette().term_bg) diff --git a/nebula_app/src/display/settings.rs b/nebula_app/src/display/settings.rs index ef2a781..74a85d4 100644 --- a/nebula_app/src/display/settings.rs +++ b/nebula_app/src/display/settings.rs @@ -94,6 +94,7 @@ pub enum SettingsDropdown { Accept, CursorShape, TabReveal, + NewTabPosition, /// 背景色:色板网格 + 16 进制输入的专用浮层(不是通用行列表)。 BackgroundColor, } @@ -143,6 +144,9 @@ pub(super) const ACCEPT_OPTIONS: [AcceptKey; 3] = pub(super) const TAB_REVEAL_OPTIONS: [TabRevealMotion; 2] = [TabRevealMotion::Slide, TabRevealMotion::Instant]; +pub(super) const NEW_TAB_POSITION_OPTIONS: [NewTabPosition; 2] = + [NewTabPosition::AfterCurrent, NewTabPosition::End]; + /// Order mirrors the appearance page the user referenced. pub(super) const CURSOR_SHAPE_OPTIONS: [CursorShape; 4] = [CursorShape::Beam, CursorShape::Underline, CursorShape::Block, CursorShape::HollowBlock]; @@ -178,6 +182,39 @@ fn tab_reveal_label(motion: TabRevealMotion, language: UiLanguage) -> &'static s } } +/// 新标签插入策略:真正创建标签时,新标签在标签顺序中的落点。 +/// 上游兼容默认是紧邻当前标签之后。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) enum NewTabPosition { + #[default] + AfterCurrent, + End, +} + +impl NewTabPosition { + fn parse(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "after_current" => Some(Self::AfterCurrent), + "end" => Some(Self::End), + _ => None, + } + } + + fn settings_value(self) -> &'static str { + match self { + Self::AfterCurrent => "after_current", + Self::End => "end", + } + } +} + +fn new_tab_position_label(position: NewTabPosition, language: UiLanguage) -> &'static str { + match position { + NewTabPosition::AfterCurrent => language.pick("当前标签之后", "After current"), + NewTabPosition::End => language.pick("列表末尾", "End"), + } +} + pub(super) fn cursor_shape_label(shape: CursorShape, language: UiLanguage) -> &'static str { match shape { CursorShape::Beam => language.pick("条形(│)", "Bar (│)"), @@ -240,6 +277,8 @@ pub enum SettingsHit { CjkBoldToggle, TabRevealDropdown, TabRevealOption(usize), + NewTabPositionDropdown, + NewTabPositionOption(usize), /// Language combobox trigger (options resolve to [`SettingsHit::Language`]). LanguageDropdown, /// Expanded dropdown option rows for the cycle-style settings. @@ -309,6 +348,7 @@ pub(super) struct NebulaRuntimeSettings { /// 默认开:小字号下雅黑 Bold fallback 与 Regular 混排发闷(任务 #4)。 pub(super) cjk_bold_regular: bool, pub(super) tab_reveal: TabRevealMotion, + pub(super) new_tab_position: NewTabPosition, pub(super) fetch: bool, pub(super) powerline: bool, /// Window close keeps the PTYs alive in the resident process (detach / @@ -381,6 +421,7 @@ pub(super) fn nebula_settings_load(config: &UiConfig) -> NebulaRuntimeSettings { copy_on_select: true, cjk_bold_regular: true, tab_reveal: TabRevealMotion::Slide, + new_tab_position: NewTabPosition::AfterCurrent, // Off by default: the welcome screen pipes a whole script through the // fresh shell and repaints on resize — real startup-latency cost on // the critical path (user ruling: startup speed outranks the art). @@ -468,6 +509,9 @@ pub(super) fn nebula_settings_load(config: &UiConfig) -> NebulaRuntimeSettings { Some(("tab_reveal", v)) => { settings.tab_reveal = TabRevealMotion::parse(v).unwrap_or_default(); }, + Some(("new_tab_position", v)) => { + settings.new_tab_position = NewTabPosition::parse(v).unwrap_or_default(); + }, Some(("startup_directory", v)) => { let path = std::path::PathBuf::from(v.trim()); if path.is_dir() { @@ -681,7 +725,7 @@ pub(super) fn nebula_settings_write(settings: &NebulaRuntimeSettings) { path, format!( "language={}\ntheme={theme}\nfollow_system_theme={}\nghost={}\naccept={accept}\nshell={shell}\nstartup_directory={startup_directory}\nfont_family={}\nfont_size={font_size}\ncursor_shape={}\ncursor_blink={}\ncopy_on_select={}\ncjk_bold_regular={} -tab_reveal={}\nfetch={}\npowerline={}\nkeep_session={}\nrestore_session={}\nblur={}\nopacity={:.2}\nbackground={background}\nbackground_image={background_image}\nbackground_image_opacity={:.2}\nbackground_image_fit={}\nbackground_image_alignment={}\nbackground_image_cover_chrome={}\npanel_resize={}\nsidebar_w={:.0}\ndrawer_w={:.0}\nhosts_band={:.0}\npinned_hosts={pinned_hosts}\nsaved_hosts={saved_hosts}\nhidden_hosts={hidden_hosts}\nquick_terminal_hotkey={quick_terminal_hotkey}\n{keybinds}", +tab_reveal={}\nnew_tab_position={}\nfetch={}\npowerline={}\nkeep_session={}\nrestore_session={}\nblur={}\nopacity={:.2}\nbackground={background}\nbackground_image={background_image}\nbackground_image_opacity={:.2}\nbackground_image_fit={}\nbackground_image_alignment={}\nbackground_image_cover_chrome={}\npanel_resize={}\nsidebar_w={:.0}\ndrawer_w={:.0}\nhosts_band={:.0}\npinned_hosts={pinned_hosts}\nsaved_hosts={saved_hosts}\nhidden_hosts={hidden_hosts}\nquick_terminal_hotkey={quick_terminal_hotkey}\n{keybinds}", settings.language.as_str(), settings.follow_system_theme as u8, settings.ghost as u8, @@ -691,6 +735,7 @@ tab_reveal={}\nfetch={}\npowerline={}\nkeep_session={}\nrestore_session={}\nblur settings.copy_on_select as u8, settings.cjk_bold_regular as u8, settings.tab_reveal.settings_value(), + settings.new_tab_position.settings_value(), settings.fetch as u8, settings.powerline as u8, settings.keep_session as u8, @@ -760,6 +805,7 @@ struct SettingsGeometry { /// 交互: CJK 粗体策略 toggle row. cjk_bold: (f32, f32, f32, f32), tab_reveal: (f32, f32, f32, f32), + new_tab_position: (f32, f32, f32, f32), reset: (f32, f32, f32, f32), /// Top edge of the scrollable content viewport (just below the fixed /// header band); everything above it never scrolls. @@ -934,13 +980,15 @@ fn settings_geometry( }; let profiles_h = s(profiles_end + 32.0 - 72.0); - // 交互: 剪贴板行为、标签展开与拖拽调节一组,文本渲染(CJK 粗体策略)另一组。 + // 交互: 剪贴板行为、标签行为(展开、新标签位置)与拖拽调节一组, + // 文本渲染(CJK 粗体策略)另一组。 let interaction_y0 = 146.0; - let cjk_bold_y0 = interaction_y0 + ROW_H * 3.0 + GROUP_ADVANCE; + let cjk_bold_y0 = interaction_y0 + ROW_H * 4.0 + GROUP_ADVANCE; let interaction_h = s(cjk_bold_y0 + ROW_H + 32.0 - 72.0); let copy_on_select = (row_x, at(interaction_y0), row_w, row_h); let tab_reveal = (row_x, at(interaction_y0 + ROW_H), row_w, row_h); - let panel_resize = (row_x, at(interaction_y0 + ROW_H * 2.0), row_w, row_h); + let new_tab_position = (row_x, at(interaction_y0 + ROW_H * 2.0), row_w, row_h); + let panel_resize = (row_x, at(interaction_y0 + ROW_H * 3.0), row_w, row_h); let cjk_bold = (row_x, at(cjk_bold_y0), row_w, row_h); // Keymap: editable action rows, then a read-only extras group (spec 002). @@ -1021,6 +1069,7 @@ fn settings_geometry( panel_resize, cjk_bold, tab_reveal, + new_tab_position, reset: (popup_x + popup_w - s(170.0), popup_y + s(24.0), s(150.0), s(42.0)), content_top, appearance_h, @@ -1138,6 +1187,9 @@ fn dropdown_anchor( (Section::Interaction, SettingsDropdown::TabReveal) => { Some((anchor(geometry.tab_reveal), TAB_REVEAL_OPTIONS.len())) }, + (Section::Interaction, SettingsDropdown::NewTabPosition) => { + Some((anchor(geometry.new_tab_position), NEW_TAB_POSITION_OPTIONS.len())) + }, (Section::Appearance, SettingsDropdown::BackgroundFit) => { Some((anchor(geometry.background_image_fit), BACKGROUND_FIT_OPTIONS.len())) }, @@ -1226,6 +1278,7 @@ pub fn settings_hit( SettingsDropdown::Language => SettingsHit::Language(LANGUAGE_OPTIONS[index]), SettingsDropdown::Accept => SettingsHit::AcceptOption(index), SettingsDropdown::TabReveal => SettingsHit::TabRevealOption(index), + SettingsDropdown::NewTabPosition => SettingsHit::NewTabPositionOption(index), SettingsDropdown::CursorShape => SettingsHit::CursorShapeOption(index), // 背景色浮层在上方特判处理,走不到通用行列表。 SettingsDropdown::BackgroundColor => SettingsHit::Panel, @@ -1351,6 +1404,9 @@ pub fn settings_hit( if contains_rect(geometry.tab_reveal, x, y) { return SettingsHit::TabRevealDropdown; } + if contains_rect(geometry.new_tab_position, x, y) { + return SettingsHit::NewTabPositionDropdown; + } if contains_rect(geometry.panel_resize, x, y) { return SettingsHit::PanelResizeToggle; } @@ -1451,6 +1507,7 @@ pub(super) struct SettingsView { pub(super) panel_resize: bool, pub(super) cjk_bold_regular: bool, pub(super) tab_reveal: TabRevealMotion, + pub(super) new_tab_position: NewTabPosition, /// Live-preview colors: the ACTUAL terminal background/foreground the /// grid would use right now (custom background wins over the theme). pub(super) preview_bg: Rgb, @@ -1617,6 +1674,9 @@ fn dropdown_selected_index(view: &SettingsView, dropdown: SettingsDropdown) -> O SettingsDropdown::TabReveal => { TAB_REVEAL_OPTIONS.iter().position(|motion| *motion == view.tab_reveal) }, + SettingsDropdown::NewTabPosition => { + NEW_TAB_POSITION_OPTIONS.iter().position(|position| *position == view.new_tab_position) + }, SettingsDropdown::CursorShape => { CURSOR_SHAPE_OPTIONS.iter().position(|shape| *shape == view.cursor_shape) }, @@ -1637,6 +1697,7 @@ fn dropdown_hover_index(hover: SettingsHit, dropdown: SettingsDropdown) -> Optio }, (SettingsDropdown::Accept, SettingsHit::AcceptOption(index)) => Some(index), (SettingsDropdown::TabReveal, SettingsHit::TabRevealOption(index)) => Some(index), + (SettingsDropdown::NewTabPosition, SettingsHit::NewTabPositionOption(index)) => Some(index), (SettingsDropdown::CursorShape, SettingsHit::CursorShapeOption(index)) => Some(index), _ => None, } @@ -2207,6 +2268,18 @@ pub(super) fn push_quads( view.hover == SettingsHit::TabRevealDropdown, view.dropdown == Some(SettingsDropdown::TabReveal), ); + row_hover( + quads, + geometry.new_tab_position, + view.hover == SettingsHit::NewTabPositionDropdown, + ); + combobox( + quads, + &mut staged, + geometry.new_tab_position, + view.hover == SettingsHit::NewTabPositionDropdown, + view.dropdown == Some(SettingsDropdown::NewTabPosition), + ); row_hover( quads, geometry.panel_resize, @@ -2604,6 +2677,9 @@ pub(super) fn draw_popup_text( SettingsDropdown::TabReveal => { tab_reveal_label(TAB_REVEAL_OPTIONS[index], language).to_owned() }, + SettingsDropdown::NewTabPosition => { + new_tab_position_label(NEW_TAB_POSITION_OPTIONS[index], language).to_owned() + }, SettingsDropdown::CursorShape => { cursor_shape_label(CURSOR_SHAPE_OPTIONS[index], language).to_owned() }, @@ -3516,6 +3592,26 @@ pub(super) fn draw_text( sk.accent, ); } + if visible(geometry.new_tab_position.1, geometry.new_tab_position.3) { + row_label( + r, + gc, + size, + scale, + &sk, + geometry.new_tab_position, + language.pick("新标签位置", "New tab position"), + "", + sk.ink, + ); + combobox_value( + r, + gc, + geometry.new_tab_position, + new_tab_position_label(view.new_tab_position, language), + sk.accent, + ); + } if visible(geometry.panel_resize.1, geometry.panel_resize.3) { // 开关本体在 quads pass;开启前的性能告知走确认框,这里的 // label 只说清楚它管哪三条分界线。 @@ -3829,7 +3925,8 @@ pub(super) fn draw_text( #[cfg(test)] mod tests { use super::{ - SHOW_WEBDAV_SYNC_SETTINGS, TabRevealMotion, advanced_content_end, opacity_from_pointer, + NEW_TAB_POSITION_OPTIONS, NewTabPosition, SHOW_WEBDAV_SYNC_SETTINGS, TabRevealMotion, + UiLanguage, advanced_content_end, new_tab_position_label, opacity_from_pointer, }; #[test] @@ -3860,4 +3957,32 @@ mod tests { assert_eq!(TabRevealMotion::parse(value.settings_value()), Some(value)); } } + + #[test] + fn new_tab_position_defaults_compatibly_and_round_trips() { + assert_eq!(NewTabPosition::default(), NewTabPosition::AfterCurrent); + assert_eq!(NewTabPosition::parse("after_current"), Some(NewTabPosition::AfterCurrent)); + assert_eq!(NewTabPosition::parse("END"), Some(NewTabPosition::End)); + assert_eq!( + NewTabPosition::parse("unknown").unwrap_or_default(), + NewTabPosition::AfterCurrent + ); + for value in [NewTabPosition::AfterCurrent, NewTabPosition::End] { + assert_eq!(NewTabPosition::parse(value.settings_value()), Some(value)); + } + } + + #[test] + fn new_tab_position_dropdown_offers_both_choices_with_the_compatible_one_first() { + // 列表顺序即下拉顺序,也是持久化索引的来源。把兼容默认放在首位是 + // 合同的一部分——重排这个数组会让升级用户的选择悄悄改变。 + assert_eq!(NEW_TAB_POSITION_OPTIONS.len(), 2); + assert_eq!(NEW_TAB_POSITION_OPTIONS[0], NewTabPosition::default()); + assert_eq!(NEW_TAB_POSITION_OPTIONS[1], NewTabPosition::End); + // 下拉与标签共用同一张表:每个选项都要能渲染出各自的文案。 + for option in NEW_TAB_POSITION_OPTIONS { + assert!(!new_tab_position_label(option, UiLanguage::ZhCn).is_empty()); + assert!(!new_tab_position_label(option, UiLanguage::EnUs).is_empty()); + } + } } diff --git a/nebula_app/src/input/chrome.rs b/nebula_app/src/input/chrome.rs index 7297c10..4179db4 100644 --- a/nebula_app/src/input/chrome.rs +++ b/nebula_app/src/input/chrome.rs @@ -862,6 +862,18 @@ impl> Processor { self.ctx.mark_dirty(); return; }, + crate::display::SettingsHit::NewTabPositionDropdown => { + self.ctx.display().toggle_settings_dropdown( + crate::display::SettingsDropdown::NewTabPosition, + ); + self.ctx.mark_dirty(); + return; + }, + crate::display::SettingsHit::NewTabPositionOption(index) => { + self.ctx.display().set_new_tab_position_option(index); + self.ctx.mark_dirty(); + return; + }, crate::display::SettingsHit::FontSizeUp => { let ui_scale = self.ctx.display().window.scale_factor as f32; self.ctx.change_font_size(ui_scale); @@ -1292,6 +1304,8 @@ fn settings_dropdown_keeps_open(hit: crate::display::SettingsHit) -> bool { | Hit::AcceptOption(_) | Hit::TabRevealDropdown | Hit::TabRevealOption(_) + | Hit::NewTabPositionDropdown + | Hit::NewTabPositionOption(_) | Hit::CursorShapeDropdown | Hit::CursorShapeOption(_) | Hit::BackgroundColor diff --git a/nebula_app/src/input/mouse.rs b/nebula_app/src/input/mouse.rs index 67ad371..f8ddee3 100644 --- a/nebula_app/src/input/mouse.rs +++ b/nebula_app/src/input/mouse.rs @@ -331,6 +331,8 @@ impl> Processor { | crate::display::SettingsHit::KeymapRow(_) | crate::display::SettingsHit::TabRevealDropdown | crate::display::SettingsHit::TabRevealOption(_) + | crate::display::SettingsHit::NewTabPositionDropdown + | crate::display::SettingsHit::NewTabPositionOption(_) | crate::display::SettingsHit::FontSizeUp | crate::display::SettingsHit::FontSizeDown | crate::display::SettingsHit::BackgroundImageCoverChrome diff --git a/nebula_app/src/window_context.rs b/nebula_app/src/window_context.rs index 0d6be6a..3455566 100644 --- a/nebula_app/src/window_context.rs +++ b/nebula_app/src/window_context.rs @@ -95,6 +95,36 @@ fn valid_new_tab_directory(path: &std::path::Path) -> bool { path.is_dir() } +/// 一次标签插入的落点来源。由调用方声明意图,而不是让插入点自己猜: +/// 真正创建标签走 [`TabPlacement::Created`](读新标签插入策略),会话恢复与 +/// 工作区导入走 [`TabPlacement::AfterActive`](保持各自记录的顺序)。 +/// +/// 这个区别必须由类型承载。恢复路径复用 `spawn_tab_*` 创建函数,光靠注释 +/// 约定「恢复时别读策略」,下一个新增入口就会漏掉。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TabPlacement { + Created, + AfterActive, +} + +/// 新标签在标签顺序中的落点。所有插入点共用它,因此 +/// `(active_tab + 1).min(len)` 这条计算只存在一处。 +fn tab_insert_index( + placement: TabPlacement, + position: crate::display::NewTabPosition, + active_tab: usize, + tab_count: usize, +) -> usize { + let after_active = active_tab.saturating_add(1).min(tab_count); + match placement { + TabPlacement::AfterActive => after_active, + TabPlacement::Created => match position { + crate::display::NewTabPosition::AfterCurrent => after_active, + crate::display::NewTabPosition::End => tab_count, + }, + } +} + /// Resolve a fresh tab's directory without allowing the global setting to /// overwrite an explicit profile/command directory. fn preferred_tab_cwd( @@ -617,7 +647,7 @@ impl WindowContext { }, TabRequest::NewAtDirectory(path) => { if valid_new_tab_directory(&path) { - self.spawn_tab_at(Some(path)); + self.spawn_tab_at(Some(path), TabPlacement::Created); } else { warn!( "Refusing to open a terminal at a missing or non-directory tree root: \ @@ -631,11 +661,11 @@ impl WindowContext { false }, TabRequest::NewShell { name, shell } => { - self.spawn_tab_shell(name, shell); + self.spawn_tab_shell(name, shell, TabPlacement::Created); false }, TabRequest::NewSsh(host) => { - self.spawn_tab_ssh(host); + self.spawn_tab_ssh(host, TabPlacement::Created); false }, TabRequest::OpenDoc(path) => { @@ -850,15 +880,36 @@ impl WindowContext { self.active_tab } + /// 把一个新标签实体加入标签顺序并激活它,返回它的落点。 + /// + /// 这是标签实体进入顺序的唯一入口(拖拽重排的 `move_tab` 除外——那里的 + /// 落点由用户手势直接给出)。`placement` 由调用方声明意图:真正创建标签 + /// 传 [`TabPlacement::Created`],会话恢复与工作区导入传 + /// [`TabPlacement::AfterActive`]。 + fn insert_tab(&mut self, entry: TabEntry, placement: TabPlacement) -> usize { + let at = tab_insert_index( + placement, + self.display.nebula_new_tab_position, + self.active_tab, + self.tabs.len(), + ); + self.tabs.insert(at, entry); + self.active_tab = at; + at + } + /// Spawn and activate a new tab (a single-pane layout) using the default shell. fn spawn_tab(&mut self) { - self.spawn_tab_at(self.display.startup_directory().or_else(|| self.focused_cwd())); + self.spawn_tab_at( + self.display.startup_directory().or_else(|| self.focused_cwd()), + TabPlacement::Created, + ); } /// Spawn a default-shell tab at an already validated explicit directory, /// or inherit the caller-provided cwd. Keeping this as the only insertion /// path guarantees tree-created terminals behave exactly like Ctrl+Shift+T. - fn spawn_tab_at(&mut self, cwd: Option) { + fn spawn_tab_at(&mut self, cwd: Option, placement: TabPlacement) { // The default-shell setting (`shell=` in nebula_settings.txt) may // name a detected shell the PTY layer doesn't bootstrap itself (cmd, // pwsh, nushell, a WSL distro). `resolve_id` returns `None` for the two @@ -870,11 +921,7 @@ impl WindowContext { None => self.spawn_pane_detached(cwd, self.display.size_info), }; if let Some(id) = spawned { - // Insert right after the current tab (insert right next to the current tab) - // rather than at the end of the bar. - let at = (self.active_tab + 1).min(self.tabs.len()); - self.tabs.insert( - at, + self.insert_tab( TabEntry { layout: Layout::Leaf(id), active_pane: id, @@ -885,8 +932,8 @@ impl WindowContext { doc: None, settings: false, }, + placement, ); - self.active_tab = at; self.resize_active_layout(); self.dirty = true; self.run_fastfetch_intro(id); @@ -906,10 +953,10 @@ impl WindowContext { /// profile so an `ssh host` entry reads as its destination, not "ssh". fn spawn_tab_profile(&mut self, index: usize) { let Some(profile) = self.config.profiles.get(index).cloned() else { return }; - self.spawn_tab_profile_value(profile); + self.spawn_tab_profile_value(profile, TabPlacement::Created); } - fn spawn_tab_profile_value(&mut self, profile: Profile) { + fn spawn_tab_profile_value(&mut self, profile: Profile, placement: TabPlacement) { // Profile cwd wins when it exists; else inherit the focused pane's. let cwd = preferred_tab_cwd( profile.cwd.as_ref().filter(|p| p.is_dir()).cloned(), @@ -918,9 +965,7 @@ impl WindowContext { ); let shell = profile.shell(); if let Some(id) = self.spawn_pane_detached_with(cwd, self.display.size_info, Some(shell)) { - let at = (self.active_tab + 1).min(self.tabs.len()); - self.tabs.insert( - at, + self.insert_tab( TabEntry { layout: Layout::Leaf(id), active_pane: id, @@ -931,8 +976,8 @@ impl WindowContext { doc: None, settings: false, }, + placement, ); - self.active_tab = at; self.resize_active_layout(); self.dirty = true; } @@ -941,15 +986,18 @@ impl WindowContext { /// Open a new tab running a detected shell (the new-tab dropdown). Like /// `spawn_tab_profile` but the spec is passed in rather than looked up in /// the config, and the cwd inherits the focused pane's. - fn spawn_tab_shell(&mut self, name: String, shell: nebula_terminal::tty::Shell) { + fn spawn_tab_shell( + &mut self, + name: String, + shell: nebula_terminal::tty::Shell, + placement: TabPlacement, + ) { if let Some(id) = self.spawn_pane_detached_with( self.display.startup_directory().or_else(|| self.focused_cwd()), self.display.size_info, Some(shell.clone()), ) { - let at = (self.active_tab + 1).min(self.tabs.len()); - self.tabs.insert( - at, + self.insert_tab( TabEntry { layout: Layout::Leaf(id), active_pane: id, @@ -960,8 +1008,8 @@ impl WindowContext { doc: None, settings: false, }, + placement, ); - self.active_tab = at; self.resize_active_layout(); self.dirty = true; } @@ -970,7 +1018,7 @@ impl WindowContext { /// Open a saved SSH destination inside the configured default shell. /// `nebula ssh` is typed into that shell's PTY so OpenSSH remains inside /// Nebula's ConPTY instead of becoming the pane's GUI-subsystem root. - fn spawn_tab_ssh(&mut self, host: String) { + fn spawn_tab_ssh(&mut self, host: String, placement: TabPlacement) { #[cfg(windows)] { let pane_id = self.next_pane_id; @@ -985,9 +1033,7 @@ impl WindowContext { Ok(pane) => { self.next_pane_id += 1; self.panes.push(pane); - let at = (self.active_tab + 1).min(self.tabs.len()); - self.tabs.insert( - at, + self.insert_tab( TabEntry { layout: Layout::Leaf(pane_id), active_pane: pane_id, @@ -998,8 +1044,8 @@ impl WindowContext { doc: None, settings: false, }, + placement, ); - self.active_tab = at; self.resize_active_layout(); self.dirty = true; return; @@ -1046,9 +1092,7 @@ impl WindowContext { self.display.size_info, default_shell, ) { - let at = (self.active_tab + 1).min(self.tabs.len()); - self.tabs.insert( - at, + self.insert_tab( TabEntry { layout: Layout::Leaf(id), active_pane: id, @@ -1059,8 +1103,8 @@ impl WindowContext { doc: None, settings: false, }, + placement, ); - self.active_tab = at; self.resize_active_layout(); self.dirty = true; if let Some(pane) = self.panes.iter().find(|pane| pane.id == id) { @@ -1089,9 +1133,7 @@ impl WindowContext { // tab identity for doc tabs (no cwd to derive one from). Same codicon // as the file tree's markdown icon, so tab and tree read as one system. let label = format!("\u{eb1d} {}", doc.title); - let at = (self.active_tab + 1).min(self.tabs.len()); - self.tabs.insert( - at, + self.insert_tab( TabEntry { layout: Layout::Leaf(DOC_PANE_ID), active_pane: DOC_PANE_ID, @@ -1102,8 +1144,8 @@ impl WindowContext { doc: Some(doc), settings: false, }, + TabPlacement::Created, ); - self.active_tab = at; self.display.set_settings_tab_active(false); self.dirty = true; } @@ -1118,9 +1160,7 @@ impl WindowContext { return; } - let at = (self.active_tab + 1).min(self.tabs.len()); - self.tabs.insert( - at, + self.insert_tab( TabEntry { layout: Layout::Leaf(DOC_PANE_ID), active_pane: DOC_PANE_ID, @@ -1131,8 +1171,8 @@ impl WindowContext { doc: None, settings: true, }, + TabPlacement::Created, ); - self.active_tab = at; self.display.set_settings_tab_active(true); self.sync_chrome_tabs(); self.dirty = true; @@ -1151,16 +1191,18 @@ impl WindowContext { let before = self.tabs.len(); match launch { - TabLaunch::Default => self.spawn_tab_at(self.focused_cwd()), - TabLaunch::Profile(profile) => self.spawn_tab_profile_value(profile), - TabLaunch::Shell { name, shell } => self.spawn_tab_shell(name, shell), - TabLaunch::Ssh(host) => self.spawn_tab_ssh(host), + TabLaunch::Default => self.spawn_tab_at(self.focused_cwd(), TabPlacement::Created), + TabLaunch::Profile(profile) => { + self.spawn_tab_profile_value(profile, TabPlacement::Created) + }, + TabLaunch::Shell { name, shell } => { + self.spawn_tab_shell(name, shell, TabPlacement::Created) + }, + TabLaunch::Ssh(host) => self.spawn_tab_ssh(host, TabPlacement::Created), TabLaunch::Document(path) => { let doc = crate::display::markdown_view::DocView::open(path.clone()); let label = format!("\u{eb1d} {}", doc.title); - let at = (self.active_tab + 1).min(self.tabs.len()); - self.tabs.insert( - at, + self.insert_tab( TabEntry { layout: Layout::Leaf(DOC_PANE_ID), active_pane: DOC_PANE_ID, @@ -1171,8 +1213,8 @@ impl WindowContext { doc: Some(doc), settings: false, }, + TabPlacement::Created, ); - self.active_tab = at; self.dirty = true; }, TabLaunch::Settings => self.open_settings_tab(), @@ -1336,17 +1378,22 @@ impl WindowContext { }, session::LaunchSession::Shell { name, program, args } => { let shell = nebula_terminal::tty::Shell::new(program.clone(), args.clone()); - self.spawn_tab_shell(name.clone(), shell); + self.spawn_tab_shell(name.clone(), shell, TabPlacement::AfterActive); }, session::LaunchSession::Profile { name, command, args, cwd } => { - self.spawn_tab_profile_value(crate::config::ui_config::Profile { - name: name.clone(), - command: command.clone(), - args: args.clone(), - cwd: cwd.as_ref().map(std::path::PathBuf::from), - }); + self.spawn_tab_profile_value( + crate::config::ui_config::Profile { + name: name.clone(), + command: command.clone(), + args: args.clone(), + cwd: cwd.as_ref().map(std::path::PathBuf::from), + }, + TabPlacement::AfterActive, + ); + }, + session::LaunchSession::Ssh { host } => { + self.spawn_tab_ssh(host.clone(), TabPlacement::AfterActive) }, - session::LaunchSession::Ssh { host } => self.spawn_tab_ssh(host.clone()), } if self.tabs.len() == before { // Cross-platform degradation: a workspace made on another OS may @@ -1399,9 +1446,9 @@ impl WindowContext { let Some(id) = self.spawn_pane_detached(cwd, self.display.size_info) else { return false; }; - let at = (self.active_tab + 1).min(self.tabs.len()); - self.tabs.insert( - at, + // 恢复不读新标签插入策略:保存的顺序才是权威,逐个追加在活动标签 + // 之后即可复现它。 + self.insert_tab( TabEntry { layout: Layout::Leaf(id), active_pane: id, @@ -1412,8 +1459,8 @@ impl WindowContext { doc: None, settings: false, }, + TabPlacement::AfterActive, ); - self.active_tab = at; self.run_fastfetch_intro(id); true } @@ -2986,14 +3033,75 @@ mod startup_shell_tests { use nebula_terminal::tty::Shell; use super::{ - chrome_clock_interval, preferred_initial_cwd, preferred_tab_cwd, routed_input_pane, - select_initial_shell, valid_new_tab_directory, + TabPlacement, chrome_clock_interval, preferred_initial_cwd, preferred_tab_cwd, + routed_input_pane, select_initial_shell, tab_insert_index, valid_new_tab_directory, }; + use crate::display::NewTabPosition; fn shell(program: &str) -> Shell { Shell::new(program.to_owned(), Vec::new()) } + #[test] + fn created_tabs_land_after_the_active_tab_by_default() { + let at = NewTabPosition::AfterCurrent; + assert_eq!(tab_insert_index(TabPlacement::Created, at, 0, 3), 1); + assert_eq!(tab_insert_index(TabPlacement::Created, at, 1, 3), 2); + assert_eq!(tab_insert_index(TabPlacement::Created, at, 2, 3), 3); + } + + #[test] + fn created_tabs_land_at_the_end_when_the_user_chose_end() { + let end = NewTabPosition::End; + assert_eq!(tab_insert_index(TabPlacement::Created, end, 0, 3), 3); + assert_eq!(tab_insert_index(TabPlacement::Created, end, 1, 3), 3); + assert_eq!(tab_insert_index(TabPlacement::Created, end, 2, 3), 3); + } + + #[test] + fn restored_tabs_ignore_the_creation_strategy() { + // 会话恢复与工作区导入保持既有的「当前标签之后」行为,即使用户把 + // 新标签插入策略选成了列表末尾——否则恢复会重排保存的顺序。 + assert_eq!(tab_insert_index(TabPlacement::AfterActive, NewTabPosition::End, 1, 4), 2); + assert_eq!( + tab_insert_index(TabPlacement::AfterActive, NewTabPosition::AfterCurrent, 1, 4), + 2 + ); + } + + #[test] + fn insertion_never_runs_past_the_end_of_the_tab_list() { + // 活动下标可能短暂领先于实际长度(关闭标签后的过渡态);落点必须 + // 仍是合法的 Vec::insert 位置。 + assert_eq!(tab_insert_index(TabPlacement::Created, NewTabPosition::AfterCurrent, 9, 2), 2); + assert_eq!(tab_insert_index(TabPlacement::AfterActive, NewTabPosition::End, 9, 2), 2); + assert_eq!(tab_insert_index(TabPlacement::Created, NewTabPosition::AfterCurrent, 0, 0), 0); + assert_eq!(tab_insert_index(TabPlacement::Created, NewTabPosition::End, 0, 0), 0); + } + + /// 落点只能有一个来源。九处各自硬编码 `(active_tab + 1).min(len)` 是这个 + /// 功能存在的原因——只靠约定「新入口记得读策略」,下一个入口就会漏掉。 + /// + /// 允许的两处:`insert_tab`(唯一的创建/恢复插入口)与 `move_tab` + /// (拖拽重排,落点由用户手势直接给出,不经策略)。新增第三处会让本测试 + /// 变红——那说明它应该改走 `insert_tab`。 + #[test] + fn tab_insertion_has_exactly_two_homes() { + let source = std::fs::read_to_string( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/window_context.rs"), + ) + .expect("window_context.rs 必须可读,否则本约束静默放水"); + // 只扫产品代码。规则本身是用字符串字面量表达的,扫描自己会把规则 + // 连同它的失败信息一起算成违规。 + let production = source.split("#[cfg(test)]").next().unwrap_or_default(); + let insertions = production.matches("self.tabs.insert(").count(); + assert_eq!( + insertions, 2, + "找到 {insertions} 处 self.tabs.insert(,应为 2 处(insert_tab 与 move_tab)。\n\ + 新的标签插入请调用 insert_tab(entry, placement),由它决定落点。" + ); + } + #[test] fn chrome_clock_uses_fast_cadence_only_for_running_tabs() { assert_eq!(