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:
1 parent
f52b9a428c
commit
f1245cd2bf
5 files changed
+332
-67
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!(
|
||||
|
||||
Reference in new issue
Block a user