feat: configurable new-tab position

新增全局「新标签位置」偏好:当前标签之后(上游兼容默认)或列表末尾。

真正创建标签的入口——默认 shell、profile、SSH、文档、设置页、复制标签,
以及走 `TabRequest::NewShell` 的卡片式 shell 选择器——全部经 `handle_tab_request`
汇入,共享同一策略。

会话恢复与工作区导入绕过该漏斗直接调用 `spawn_tab_*`,因此插入位置改为沿调用
链显式传递:创建传 `TabPlacement::Created`(读策略),恢复与导入传
`TabPlacement::AfterActive`(保持既有顺序)。这个区别必须由类型承载——光靠
注释约定「恢复时别读策略」,下一个新增入口就会漏掉。

同时把散落在 9 处的 `(active_tab + 1).min(len)` 收敛到单一 `insert_tab`;
`move_tab` 的落点由用户手势直接给出,保持独立。`tab_insertion_has_exactly_two_homes`
把这个收敛锁成会失败的测试。

拖拽重排、分屏、停靠与切换到已有标签均不触及插入策略。
This commit is contained in:
Sakyvo committed 2026-08-03 14:53:15 +08:00
1 parent f52b9a428c
commit f1245cd2bf
5 files changed
+332 -67

No files matched your search

+17 -1
View File
@@ -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<String>,
nebula_font_notice: Option<String>,
@@ -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)
+130 -5
View File
@@ -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<Self> {
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());
}
}
}
+14
View File
@@ -862,6 +862,18 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
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
+2
View File
@@ -331,6 +331,8 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
| 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
+169 -61
View File
@@ -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<std::path::PathBuf>) {
fn spawn_tab_at(&mut self, cwd: Option<std::path::PathBuf>, placement: TabPlacement) {
// The default-shell setting (`shell=<id>` 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!(