diff --git a/nebula_app/assets/icons/nebula-shield.svg b/nebula_app/assets/icons/nebula-shield.svg new file mode 100644 index 000000000..4b3e9aac8 --- /dev/null +++ b/nebula_app/assets/icons/nebula-shield.svg @@ -0,0 +1,3 @@ + + + diff --git a/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json index 2207b8e4e..9847188c3 100644 --- a/nebula_app/i18n/en-US.json +++ b/nebula_app/i18n/en-US.json @@ -1013,6 +1013,7 @@ "tab_color": "Tab color" }, "chrome": { + "local_administrator": "Local Pebrel process is running as Windows administrator", "new_terminal_ctrl_shift_t": "New terminal (Ctrl+Shift+T)", "more": "More", "scroll_tabs_left": "Scroll tabs left", diff --git a/nebula_app/i18n/zh-CN.json b/nebula_app/i18n/zh-CN.json index a4e32fd51..b4019ed7a 100644 --- a/nebula_app/i18n/zh-CN.json +++ b/nebula_app/i18n/zh-CN.json @@ -1013,6 +1013,7 @@ "tab_color": "标签颜色" }, "chrome": { + "local_administrator": "本机 Pebrel 进程正以 Windows 管理员身份运行", "new_terminal_ctrl_shift_t": "新建终端 (Ctrl+Shift+T)", "more": "更多", "scroll_tabs_left": "向左翻标签", diff --git a/nebula_app/src/gpui_shell/assets.rs b/nebula_app/src/gpui_shell/assets.rs index 3c4727dc4..e96e9756b 100644 --- a/nebula_app/src/gpui_shell/assets.rs +++ b/nebula_app/src/gpui_shell/assets.rs @@ -41,6 +41,7 @@ const NEBULA_ICONS: &[(&str, &[u8])] = icons![ "pin", "pencil", "clipboard-paste", + "shield", "trash-2", "refresh", "phone", @@ -109,6 +110,7 @@ pub mod nav { /// Lucide pencil;固定组件资产集未收录,命令行内编辑动作需要明确图形语义。 pub const PENCIL: &str = "icons/nebula-pencil.svg"; pub const CLIPBOARD_PASTE: &str = "icons/nebula-clipboard-paste.svg"; + pub const SHIELD: &str = "icons/nebula-shield.svg"; /// Lucide trash-2;删除保存命令不能借用表示 Backspace 的 `IconName::Delete`。 pub const TRASH: &str = "icons/nebula-trash-2.svg"; pub const REFRESH: &str = "icons/nebula-refresh.svg"; diff --git a/nebula_app/src/gpui_shell/terminal/view/startup_tests.rs b/nebula_app/src/gpui_shell/terminal/view/startup_tests.rs index ef3df6eea..13913ffa2 100644 --- a/nebula_app/src/gpui_shell/terminal/view/startup_tests.rs +++ b/nebula_app/src/gpui_shell/terminal/view/startup_tests.rs @@ -1248,3 +1248,36 @@ fn a_failed_codex_chooser_does_not_start_an_automatic_retry_loop(cx: &mut TestAp assert!(receiver.try_iter().all(|message| !matches!(message, Msg::Input(_)))); }); } + +#[gpui::test] +fn host_administrator_scope_excludes_remote_and_wsl_shells(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, _| { + let context = |program: &str| { + crate::runtime_exec::PaneExecContext::from_pty_options(&nebula_terminal::tty::Options { + shell: Some(nebula_terminal::tty::Shell::new(program.into(), vec![])), + ..Default::default() + }) + }; + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + view.exec_context = Some(context("pwsh.exe")); + assert!(view.inherits_windows_host_token()); + view.suggest.suggest_env = crate::display::SuggestEnv::Shell { + scope: crate::nebula_history::HistoryScope::Ssh("user@remote".into()), + }; + assert!(!view.inherits_windows_host_token()); + view.suggest.suggest_env = crate::display::SuggestEnv::Shell { + scope: crate::nebula_history::HistoryScope::Wsl("Ubuntu".into()), + }; + assert!(!view.inherits_windows_host_token()); + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + view.exec_context = Some(context("wsl.exe")); + assert!(!view.inherits_windows_host_token()); + view.exec_context = Some(context("pwsh.exe")); + view.ssh_destination = Some("user@remote".into()); + assert!(!view.inherits_windows_host_token()); + view.ssh_destination = None; + view.exec_context = None; + assert!(!view.inherits_windows_host_token()); + }); +} diff --git a/nebula_app/src/gpui_shell/terminal/view/tab_identity.rs b/nebula_app/src/gpui_shell/terminal/view/tab_identity.rs index b498f67e7..b47058dbb 100644 --- a/nebula_app/src/gpui_shell/terminal/view/tab_identity.rs +++ b/nebula_app/src/gpui_shell/terminal/view/tab_identity.rs @@ -11,6 +11,18 @@ fn ssh_label(destination: Option<&str>, directory: &std::path::Path) -> Option bool { + self.ssh_destination.is_none() + && self + .exec_context + .as_ref() + .is_some_and(|context| context.wsl_distribution().is_none()) + && matches!( + self.suggest.suggest_env.history_scope(), + crate::nebula_history::HistoryScope::Local + ) + } + /// 目录来自 shell 上报;不要从带 Powerline 图标的提示符或选区反推路径。 pub fn working_directory(&self) -> Option<&str> { let path = self.cwd.as_str(); diff --git a/nebula_app/src/gpui_shell/workspace/settings_navigation.rs b/nebula_app/src/gpui_shell/workspace/settings_navigation.rs index 06b30a72f..cb6718d0f 100644 --- a/nebula_app/src/gpui_shell/workspace/settings_navigation.rs +++ b/nebula_app/src/gpui_shell/workspace/settings_navigation.rs @@ -169,6 +169,7 @@ impl NebulaWorkspace { color: None, renaming: None, pane_count: 0, + local_administrator: false, } } diff --git a/nebula_app/src/gpui_shell/workspace/sidebar.rs b/nebula_app/src/gpui_shell/workspace/sidebar.rs index dd8ec82fb..3694786a9 100644 --- a/nebula_app/src/gpui_shell/workspace/sidebar.rs +++ b/nebula_app/src/gpui_shell/workspace/sidebar.rs @@ -241,6 +241,7 @@ impl NebulaWorkspace { color: tab_color, renaming, pane_count, + local_administrator, } = self.tab_presentation(ix, cx, dark); let hover_group: SharedString = format!("sidebar-tab-hover-{ix}").into(); let has_program_glyph = program_glyph.is_some(); @@ -423,6 +424,13 @@ impl NebulaWorkspace { .bg(color), ) }) + // 权限属于整个会话,先于程序身份呈现,且不被 AI 图标替换。 + .when(local_administrator, |row| { + row.child(tab_presentation::administrator_badge( + format!("sidebar-admin-{ix}").into(), + cx, + )) + }) // 行首图标的优先级:**先身份、后形态**。AI 品牌图 / 程序字位 // 表达「这个 tab 里在跑什么」,它必须跟随聚焦 pane;2×2 // 分屏标记只在没有身份可显示时补位。 @@ -966,8 +974,15 @@ impl NebulaWorkspace { let chrome_family = theme.mono_font_family.clone(); let symbol_family: SharedString = crate::font_install::REQUIRED_FONT_FAMILY.into(); let label_px = settings.map(|settings| settings.ui_font_size_px).unwrap_or(15.0); - let TabPresentation { title, logo_image, logo_pending, program_glyph, pane_count, .. } = - self.tab_presentation(self.active, cx, dark); + let TabPresentation { + title, + logo_image, + logo_pending, + program_glyph, + pane_count, + local_administrator, + .. + } = self.tab_presentation(self.active, cx, dark); slot.child( h_flex() .absolute() @@ -977,6 +992,12 @@ impl NebulaWorkspace { .gap_2() // 两侧工具靠 flex 天然让位,这点内缩只是别让长标题贴到按钮上。 .px_4() + .when(local_administrator, |row| { + row.child(tab_presentation::administrator_badge( + "collapsed-title-admin".into(), + cx, + )) + }) .when_some(logo_image, |row, image| { row.child( img(image) diff --git a/nebula_app/src/gpui_shell/workspace/tab_presentation.rs b/nebula_app/src/gpui_shell/workspace/tab_presentation.rs index 0f6d6dc22..ef405eb17 100644 --- a/nebula_app/src/gpui_shell/workspace/tab_presentation.rs +++ b/nebula_app/src/gpui_shell/workspace/tab_presentation.rs @@ -42,6 +42,7 @@ pub(super) struct TabPresentation { /// 本 tab 的分屏数(Terminal tab 才 > 0)。`> 1` 时行首图标换成 2×2 分屏 /// 标记、行尾挂一枚数量胶囊;见 [`pane_header::split_badge`]。 pub(super) pane_count: usize, + pub(super) local_administrator: bool, } impl NebulaWorkspace { @@ -77,6 +78,10 @@ impl NebulaWorkspace { let title = self.tab_title(ix, cx); let is_settings = self.tabs[ix].is_settings(); let is_terminal = self.tabs[ix].is_terminal(); + let local_administrator = crate::platform::elevation::is_elevated().unwrap_or(false) + && self.tabs[ix] + .focused_view() + .is_some_and(|view| view.read(cx).inherits_windows_host_token()); let pane_count = match &self.tabs[ix] { WorkspaceTab::Terminal { panes, .. } => panes.len(), _ => 0, @@ -142,6 +147,7 @@ impl NebulaWorkspace { color: meta.color, renaming, pane_count, + local_administrator, } } } @@ -152,3 +158,90 @@ pub(super) fn tooltip(text: SharedString, window: &mut Window, cx: &mut App) -> }) .build(window, cx) } + +pub(super) fn administrator_badge(selector: SharedString, cx: &App) -> gpui::Stateful { + let label = crate::gpui_shell::config::ui_language(cx) + .text(crate::i18n::Message::ChromeLocalAdministrator); + div() + .id(selector.clone()) + .debug_selector(move || selector.to_string()) + .role(gpui::accesskit::Role::Image) + .aria_label(label) + .flex_shrink_0() + .size(px(TAB_LABEL_ICON_SIZE)) + .flex() + .items_center() + .justify_center() + // 权限提示与任务图标同尺寸;矢量图避免 emoji 字体改变颜色和几何。 + .child( + Icon::new(Icon::empty()) + .path(crate::gpui_shell::assets::nav::SHIELD) + .size(px(TAB_LABEL_ICON_SIZE)) + .text_color(cx.theme().warning), + ) + .tooltip(move |window, cx| tooltip(label.into(), window, cx)) +} + +#[cfg(all(test, feature = "gpui-test-support"))] +mod tests { + use super::*; + use gpui::{AssetSource as _, TestAppContext}; + use gpui_component::Root; + + struct BadgeSurface; + + impl Render for BadgeSurface { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + h_flex() + .h(px(36.0)) + .items_center() + .child(administrator_badge("test-administrator".into(), cx)) + .child( + div() + .debug_selector(|| "test-task-icon".into()) + .size(px(TAB_LABEL_ICON_SIZE)) + .flex_shrink_0() + .child(Icon::new(IconName::SquareTerminal).size(px(TAB_LABEL_ICON_SIZE))), + ) + .child(div().debug_selector(|| "test-admin-title".into()).child("pwsh")) + } + } + + #[gpui::test] + fn administrator_badge_keeps_a_readable_slot_with_reduced_motion(cx: &mut TestAppContext) { + let asset = crate::gpui_shell::assets::NebulaAssets + .load(crate::gpui_shell::assets::nav::SHIELD) + .unwrap() + .expect("administrator shield must be embedded in the product"); + assert!(std::str::from_utf8(&asset).unwrap().contains("viewBox=\"0 0 24 24\"")); + cx.update(|cx| { + gpui_component::init(cx); + cx.set_global(crate::gpui_shell::config::Settings::load( + nebula_settings::ThemeName::Nord, + )); + }); + let (_, window) = cx.add_window_view(|window, cx| { + let surface = cx.new(|_| BadgeSurface); + Root::new(surface, window, cx) + }); + let mut previous = None; + for reduced_motion in [false, true] { + window.update(|window, cx| { + cx.set_reduce_motion(reduced_motion); + window.refresh(); + window.draw(cx).clear(cx); + }); + let badge = window.debug_bounds("test-administrator").unwrap(); + let task = window.debug_bounds("test-task-icon").unwrap(); + let title = window.debug_bounds("test-admin-title").unwrap(); + assert_eq!(badge.size, gpui::size(px(TAB_LABEL_ICON_SIZE), px(TAB_LABEL_ICON_SIZE))); + assert_eq!(badge.size, task.size); + assert!(badge.right() <= task.left()); + assert!(task.right() <= title.left()); + if let Some(previous) = previous { + assert_eq!(badge, previous); + } + previous = Some(badge); + } + } +} diff --git a/nebula_app/src/gpui_shell/workspace/top_tabs.rs b/nebula_app/src/gpui_shell/workspace/top_tabs.rs index 001b9dc2c..778aa22df 100644 --- a/nebula_app/src/gpui_shell/workspace/top_tabs.rs +++ b/nebula_app/src/gpui_shell/workspace/top_tabs.rs @@ -193,6 +193,7 @@ impl NebulaWorkspace { color, renaming, pane_count, + local_administrator, } = self.top_tab_presentation(ix, cx, dark); let hover_group: SharedString = format!("top-tab-hover-{ix}").into(); let cross_window_drag = self.cross_window_drag_payload(ix, cx); @@ -364,6 +365,12 @@ impl NebulaWorkspace { .bg(color), ) }) + .when(local_administrator, |row| { + row.child(super::tab_presentation::administrator_badge( + format!("top-tab-admin-{ix}").into(), + cx, + )) + }) // 图标优先级与侧栏同源:先身份(跟随聚焦 pane),分屏标记 // 只在没有身份图标时补位。理由见 sidebar.rs 同处注释。 .when(is_settings, |row| {