From 6d794f2f1cb35aa5e0e420241c10cf641e78b29b Mon Sep 17 00:00:00 2001 From: kuddev Date: Wed, 12 Aug 2026 19:26:46 +0800 Subject: [PATCH] feat: multi-tab terminal workspace as the nebula-gpui main window TerminalView now emits TitleChanged/Exited and owns session teardown (Msg::Shutdown on removal, Drop as backstop). TerminalPanel adapts the terminal to the dock Panel contract with a title_suffix close button, since a root-level TabPanel never renders its own. NebulaWorkspace wires TitleBar + tabs with ctrl-shift-t/w hotkeys (passed through the terminal key handler), closes tabs on shell exit, and quits when the last tab closes. The component gallery moves to a secondary window. --- nebula_gpui/ARCHITECTURE.md | 30 +++++ nebula_gpui/src/app.rs | 24 +++- nebula_gpui/src/terminal/mod.rs | 1 + nebula_gpui/src/terminal/panel.rs | 115 ++++++++++++++++++ nebula_gpui/src/terminal/view.rs | 49 ++++++-- nebula_gpui/src/ui/bootstrap.rs | 1 + nebula_gpui/src/ui/prelude.rs | 4 +- nebula_gpui/src/views/mod.rs | 1 + nebula_gpui/src/views/workspace.rs | 182 +++++++++++++++++++++++++++++ 9 files changed, 396 insertions(+), 11 deletions(-) create mode 100644 nebula_gpui/src/terminal/panel.rs create mode 100644 nebula_gpui/src/views/workspace.rs diff --git a/nebula_gpui/ARCHITECTURE.md b/nebula_gpui/ARCHITECTURE.md index ceb3447..5610750 100644 --- a/nebula_gpui/ARCHITECTURE.md +++ b/nebula_gpui/ARCHITECTURE.md @@ -93,3 +93,33 @@ inside GPUI as a custom `Element` without forking either side. The boundary rule stays symmetric: no `nebula_terminal` type escapes `src/terminal/`, and no GPUI type enters `nebula_terminal`. + +## Multi-Tab Terminal Workspace + +`views/workspace.rs` is the product shell: a custom `TitleBar` plus a center +`TabPanel` of terminal tabs. `terminal/panel.rs` adapts `TerminalView` to the +upstream `Panel` contract; the tab layer owns tab semantics only (title, +close, focus forwarding, lifecycle) and terminal behavior stays in +`TerminalView`. + +Lifecycle rules proven by manual acceptance: + +- New tab: title-bar button or `ctrl-shift-t`; the new tab activates and + focuses its terminal. +- Close: per-tab close button (`title_suffix`, because a root-level + `TabPanel` is treated as locked layout by the component library and never + renders its own close button), `ctrl-shift-w`, or shell `exit`. All three + paths converge on `TabPanel::remove_panel`. +- Session teardown: `Panel::on_removed` sends `Msg::Shutdown` immediately; + `TerminalView::drop` is the backstop. `DockArea` keeps the initial + `DockItem` snapshot alive, so Drop alone is not sufficient — do not remove + the `on_removed` cleanup. +- Last tab closed quits the app (`quit_if_empty`, also subscribed to + `PanelEvent::LayoutChanged` to cover the per-tab close button path). +- The center layout is intentionally a root-level `TabPanel` (no + `StackPanel` parent): tabs cannot be dragged out into splits, which keeps + `on_removed` equal to "really closing". Revisit teardown ownership before + introducing split layouts. + +App-level hotkeys must be let through by the terminal key handler +(`view.rs` passes `ctrl-shift-t/w` up instead of encoding them as C0 bytes). diff --git a/nebula_gpui/src/app.rs b/nebula_gpui/src/app.rs index 4ce8946..7606608 100644 --- a/nebula_gpui/src/app.rs +++ b/nebula_gpui/src/app.rs @@ -1,9 +1,29 @@ use gpui::{App, AppContext as _, Bounds, WindowBounds, WindowOptions, px, size}; -use gpui_component::Root; +use gpui_component::{Root, TitleBar}; use crate::views::component_gallery::ComponentGallery; +use crate::views::workspace::NebulaWorkspace; pub fn open_main_window(cx: &mut App) { + let bounds = Bounds::centered(None, size(px(1080.0), px(720.0)), cx); + let options = WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + window_min_size: Some(size(px(760.0), px(540.0))), + // 自绘 TitleBar 要求系统标题栏透明化,窗口控制按钮由组件库接管。 + titlebar: Some(TitleBar::title_bar_options()), + app_id: Some("nebula-gpui".to_owned()), + ..Default::default() + }; + + cx.open_window(options, |window, cx| { + let workspace = cx.new(|cx| NebulaWorkspace::new(window, cx)); + cx.new(|cx| Root::new(workspace, window, cx)) + }) + .expect("failed to open Nebula GPUI window"); +} + +/// 组件验收页降级为辅助窗口,从工作区标题栏进入,仍用于回归组件接入面。 +pub fn open_gallery_window(cx: &mut App) { let bounds = Bounds::centered(None, size(px(1080.0), px(720.0)), cx); let options = WindowOptions { window_bounds: Some(WindowBounds::Windowed(bounds)), @@ -16,5 +36,5 @@ pub fn open_main_window(cx: &mut App) { let gallery = cx.new(|cx| ComponentGallery::new(window, cx)); cx.new(|cx| Root::new(gallery, window, cx)) }) - .expect("failed to open Nebula GPUI window"); + .expect("failed to open Nebula gallery window"); } diff --git a/nebula_gpui/src/terminal/mod.rs b/nebula_gpui/src/terminal/mod.rs index 349e903..d20a0fb 100644 --- a/nebula_gpui/src/terminal/mod.rs +++ b/nebula_gpui/src/terminal/mod.rs @@ -6,5 +6,6 @@ pub mod colors; pub mod element; pub mod keymap; +pub mod panel; pub mod session; pub mod view; diff --git a/nebula_gpui/src/terminal/panel.rs b/nebula_gpui/src/terminal/panel.rs new file mode 100644 index 0000000..01e6ec3 --- /dev/null +++ b/nebula_gpui/src/terminal/panel.rs @@ -0,0 +1,115 @@ +//! 终端的 Dock 面板合同:把 `TerminalView` 包装成 `gpui_component::dock::Panel`。 +//! +//! 面板只负责 Tab 语义(标题、关闭、焦点转发、生命周期),终端行为全部留在 +//! `TerminalView`;workspace 通过 `TerminalPanelEvent` 得知标题变化与会话结束。 + +use gpui::{ + App, AppContext as _, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, + ParentElement as _, Render, SharedString, Styled as _, Subscription, Window, div, +}; +use gpui_component::button::{Button, ButtonVariants as _}; +use gpui_component::dock::{Panel, PanelEvent}; +use gpui_component::{IconName, Sizable as _}; + +use super::view::{TerminalView, TerminalViewEvent}; + +/// 面板对 workspace 暴露的事件。 +pub enum TerminalPanelEvent { + /// Tab 标题需要刷新(宿主 TabPanel 不订阅面板重绘,需要 workspace 转发)。 + TitleChanged, + /// 会话已结束,宿主应关闭本 Tab。 + Exited, + /// 用户点击了面板的关闭按钮,宿主应关闭本 Tab。 + CloseRequested, +} + +pub struct TerminalPanel { + terminal: Entity, + _subscriptions: Vec, +} + +impl TerminalPanel { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let terminal = cx.new(TerminalView::new); + let subscription = cx.subscribe_in( + &terminal, + window, + |_, _, event: &TerminalViewEvent, _, cx| match event { + TerminalViewEvent::TitleChanged => { + cx.emit(TerminalPanelEvent::TitleChanged); + cx.notify(); + }, + TerminalViewEvent::Exited => cx.emit(TerminalPanelEvent::Exited), + }, + ); + Self { terminal, _subscriptions: vec![subscription] } + } + + fn tab_title(&self, cx: &App) -> SharedString { + // PowerShell 的 OSC 标题常是完整路径,按字符截断避免撑爆 Tab 栏。 + const MAX_CHARS: usize = 24; + let title = &self.terminal.read(cx).title; + let mut chars = title.chars(); + let head: String = chars.by_ref().take(MAX_CHARS).collect(); + if chars.next().is_some() { format!("{head}…").into() } else { head.into() } + } +} + +impl EventEmitter for TerminalPanel {} +impl EventEmitter for TerminalPanel {} + +impl Focusable for TerminalPanel { + fn focus_handle(&self, cx: &App) -> FocusHandle { + // 转发终端的焦点句柄,TabPanel 切换 Tab 时输入焦点直达终端。 + self.terminal.read(cx).focus_handle.clone() + } +} + +impl Panel for TerminalPanel { + fn panel_name(&self) -> &'static str { + "NebulaTerminalPanel" + } + + fn tab_name(&self, cx: &App) -> Option { + Some(self.tab_title(cx)) + } + + fn title(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.tab_title(cx) + } + + fn title_suffix( + &mut self, + _window: &mut Window, + cx: &mut Context, + ) -> Option { + // 根级 TabPanel 被组件库判定为固定布局(closable 恒 false,不渲染 + // Tab 关闭按钮),所以由面板自己提供关闭入口,渲染在 Tab 栏右侧。 + Some( + Button::new("close-terminal") + .icon(IconName::Close) + .ghost() + .xsmall() + .tooltip("关闭当前终端 (Ctrl+Shift+W)") + .on_click( + cx.listener(|_, _, _, cx| cx.emit(TerminalPanelEvent::CloseRequested)), + ), + ) + } + + fn inner_padding(&self, _cx: &App) -> bool { + // 终端网格贴满面板,不要 Tab 内容区的默认内边距。 + false + } + + fn on_removed(&mut self, _window: &mut Window, cx: &mut Context) { + // Tab 被移除时立即回收会话;实体引用清零后 TerminalView::drop 会再兜底。 + self.terminal.read(cx).shutdown(); + } +} + +impl Render for TerminalPanel { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().size_full().child(self.terminal.clone()) + } +} diff --git a/nebula_gpui/src/terminal/view.rs b/nebula_gpui/src/terminal/view.rs index 3f172fb..1b4dcc1 100644 --- a/nebula_gpui/src/terminal/view.rs +++ b/nebula_gpui/src/terminal/view.rs @@ -8,6 +8,7 @@ use gpui::{ }; use gpui_component::PixelsExt as _; use nebula_terminal::event::{Event as TermEvent, Notify as _, OnResize as _, WindowSize}; +use nebula_terminal::event_loop::Msg; use nebula_terminal::grid::Scroll; use nebula_terminal::index::{Column, Point as TermPoint, Side}; use nebula_terminal::selection::{Selection, SelectionType}; @@ -21,6 +22,14 @@ use super::session::{self, GridSize, TerminalSession}; use futures::StreamExt as _; +/// 终端视图对宿主(Panel/Workspace)暴露的状态变化。 +pub enum TerminalViewEvent { + /// OSC 标题变化,宿主应刷新 Tab 标题。 + TitleChanged, + /// 会话结束(子进程退出或 PTY 故障),只发一次。 + Exited, +} + pub struct TerminalView { pub session: Option, pub focus_handle: FocusHandle, @@ -118,10 +127,12 @@ impl TerminalView { }, TermEvent::Title(title) => { self.title = title; + cx.emit(TerminalViewEvent::TitleChanged); cx.notify(); }, TermEvent::ResetTitle => { self.title = String::from("shell"); + cx.emit(TerminalViewEvent::TitleChanged); cx.notify(); }, TermEvent::PtyWrite(text) => self.write_bytes(text.into_bytes()), @@ -146,22 +157,35 @@ impl TerminalView { self.write_bytes(formatter(self.window_size).into_bytes()); }, TermEvent::ChildExit(code) => { - self.exited = Some(format!("进程已退出({code:?})")); - cx.notify(); + self.mark_exited(format!("进程已退出({code:?})"), cx); }, TermEvent::PtyFailure(reason) => { - self.exited = Some(format!("PTY 故障:{reason}")); - cx.notify(); + self.mark_exited(format!("PTY 故障:{reason}"), cx); }, TermEvent::Exit => { - self.exited.get_or_insert_with(|| String::from("会话已结束")); - cx.notify(); + self.mark_exited(String::from("会话已结束"), cx); }, // 垂直切片不处理:铃声、cwd 上报、内联图片、OSC133、AI hook 等。 _ => {}, } } + /// `Exited` 只对宿主发一次;重复的退出信号(ChildExit 之后必然跟 Exit)只更新文案。 + fn mark_exited(&mut self, message: String, cx: &mut Context) { + if self.exited.is_none() { + self.exited = Some(message); + cx.emit(TerminalViewEvent::Exited); + } + cx.notify(); + } + + /// 让 EventLoop 退出并回收 ConPTY/子进程。幂等:重复调用只会得到发送失败。 + pub fn shutdown(&self) { + if let Some(session) = &self.session { + let _ = session.notifier.0.send(Msg::Shutdown); + } + } + fn write_bytes(&self, bytes: Vec) { if let Some(session) = &self.session { session.notifier.notify(bytes); @@ -261,6 +285,9 @@ impl TerminalView { cx.stop_propagation(); return; }, + // 应用级快捷键(新建/关闭 Tab):不编码、不拦截, + // 让按键冒泡到 workspace 的 action 绑定。 + "t" | "w" => return, _ => {}, } } @@ -366,7 +393,15 @@ impl TerminalView { } } -impl EventEmitter<()> for TerminalView {} +impl EventEmitter for TerminalView {} + +impl Drop for TerminalView { + fn drop(&mut self) { + // 兜底清理:无论视图以何种路径销毁(关 Tab、关窗口、退出应用), + // 都保证 PTY 线程和子进程被回收。 + self.shutdown(); + } +} impl Focusable for TerminalView { fn focus_handle(&self, _: &App) -> FocusHandle { diff --git a/nebula_gpui/src/ui/bootstrap.rs b/nebula_gpui/src/ui/bootstrap.rs index 680a2dd..c0c8930 100644 --- a/nebula_gpui/src/ui/bootstrap.rs +++ b/nebula_gpui/src/ui/bootstrap.rs @@ -4,4 +4,5 @@ pub fn init(cx: &mut App) { // 组件库必须只初始化一次,否则全局 action、菜单和主题状态会重复注册。 gpui_component::init(cx); super::theme::apply_nebula_theme(cx); + crate::views::workspace::init(cx); } diff --git a/nebula_gpui/src/ui/prelude.rs b/nebula_gpui/src/ui/prelude.rs index 2a9c190..b9c6c34 100644 --- a/nebula_gpui/src/ui/prelude.rs +++ b/nebula_gpui/src/ui/prelude.rs @@ -6,11 +6,11 @@ // 重新从上游散落导入而绕过升级边界。 #[allow(unused_imports)] pub use gpui_component::{ - ActiveTheme as _, Icon, IconName, + ActiveTheme as _, Icon, IconName, Sizable as _, TitleBar, button::{Button, ButtonVariants as _}, checkbox::Checkbox, dialog::{Dialog, DialogButtonProps}, - dock::{DockArea, DockAreaState, DockItem, Panel, PanelControl, PanelEvent, PanelState}, + dock::{DockArea, DockAreaState, DockItem, Panel, PanelControl, PanelEvent, PanelState, TabPanel}, h_flex, input::{Input, InputState}, menu::{ContextMenuExt as _, PopupMenu}, diff --git a/nebula_gpui/src/views/mod.rs b/nebula_gpui/src/views/mod.rs index 7066585..55cdb16 100644 --- a/nebula_gpui/src/views/mod.rs +++ b/nebula_gpui/src/views/mod.rs @@ -1 +1,2 @@ pub mod component_gallery; +pub mod workspace; diff --git a/nebula_gpui/src/views/workspace.rs b/nebula_gpui/src/views/workspace.rs new file mode 100644 index 0000000..1d908bb --- /dev/null +++ b/nebula_gpui/src/views/workspace.rs @@ -0,0 +1,182 @@ +//! Nebula 主工作区:自绘 TitleBar + 多 Tab 终端。 +//! +//! 布局约束:中心是单个根级 TabPanel(`stack_panel` 为空 → Tab 不可拖出分屏), +//! 因此 `TerminalPanel::on_removed` 只会由真实关闭触发。引入分屏布局时, +//! 会话清理需要改为由 workspace 的显式关闭路径驱动。 + +use std::sync::Arc; + +use gpui::{ + App, AppContext as _, Context, Entity, Focusable as _, FontWeight, InteractiveElement as _, + IntoElement, KeyBinding, ParentElement as _, Render, Styled as _, Subscription, Window, div, +}; + +use crate::terminal::panel::{TerminalPanel, TerminalPanelEvent}; +use crate::ui::prelude::*; + +gpui::actions!(nebula_workspace, [NewTerminal, CloseActiveTerminal]); + +/// 注册工作区快捷键;在 `gpui_component::init` 之后调用一次。 +pub fn init(cx: &mut App) { + cx.bind_keys([ + KeyBinding::new("ctrl-shift-t", NewTerminal, None), + KeyBinding::new("ctrl-shift-w", CloseActiveTerminal, None), + ]); +} + +pub struct NebulaWorkspace { + dock_area: Entity, + tab_panel: Entity, + _subscriptions: Vec, +} + +impl NebulaWorkspace { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let dock_area = cx.new(|cx| DockArea::new("nebula-workspace", Some(1), window, cx)); + let weak_dock = dock_area.downgrade(); + + let panel = cx.new(|cx| TerminalPanel::new(window, cx)); + let tabs = DockItem::tabs(vec![Arc::new(panel.clone())], &weak_dock, window, cx); + let tab_panel = match &tabs { + DockItem::Tabs { view, .. } => view.clone(), + _ => unreachable!("DockItem::tabs 恒定返回 Tabs 变体"), + }; + dock_area.update(cx, |dock, cx| dock.set_center(tabs, window, cx)); + + let subscriptions = vec![ + cx.subscribe_in(&panel, window, Self::on_terminal_event), + cx.subscribe_in(&tab_panel, window, Self::on_tab_layout_event), + ]; + + // 首个 Tab 的 active_ix 保持 0 不变,TabPanel 不会触发激活回调, + // 手动把初始输入焦点交给终端。 + let focus = panel.read(cx).focus_handle(cx); + window.defer(cx, move |window, _| window.focus(&focus)); + + Self { dock_area, tab_panel, _subscriptions: subscriptions } + } + + fn add_terminal(&mut self, window: &mut Window, cx: &mut Context) { + let panel = cx.new(|cx| TerminalPanel::new(window, cx)); + self._subscriptions.push(cx.subscribe_in(&panel, window, Self::on_terminal_event)); + // add_panel 会激活新 Tab 并把焦点交给面板(即终端)。 + self.tab_panel.update(cx, |tabs, cx| tabs.add_panel(Arc::new(panel), window, cx)); + } + + fn close_active_terminal(&mut self, window: &mut Window, cx: &mut Context) { + let Some(panel) = self.tab_panel.read(cx).active_panel(cx) else { return }; + self.tab_panel.update(cx, |tabs, cx| tabs.remove_panel(panel, window, cx)); + self.quit_if_empty(cx); + } + + /// 终端应用惯例:最后一个 Tab 关闭即退出应用。 + fn quit_if_empty(&self, cx: &mut Context) { + if self.tab_panel.read(cx).active_panel(cx).is_none() { + cx.quit(); + } + } + + fn on_terminal_event( + &mut self, + panel: &Entity, + event: &TerminalPanelEvent, + window: &mut Window, + cx: &mut Context, + ) { + match event { + TerminalPanelEvent::TitleChanged => { + // Tab 标题渲染在 TabPanel 的渲染树里,面板自身的 notify + // 不会让 TabPanel 重绘,需要在这里转发。 + self.tab_panel.update(cx, |_, cx| cx.notify()); + }, + TerminalPanelEvent::Exited | TerminalPanelEvent::CloseRequested => { + let panel = panel.clone(); + self.tab_panel.update(cx, |tabs, cx| { + tabs.remove_panel(Arc::new(panel), window, cx); + }); + self.quit_if_empty(cx); + }, + } + } + + fn on_tab_layout_event( + &mut self, + _: &Entity, + event: &PanelEvent, + _window: &mut Window, + cx: &mut Context, + ) { + // 覆盖用户直接点 Tab 关闭按钮的路径(不经过 workspace)。 + if matches!(event, PanelEvent::LayoutChanged) { + self.quit_if_empty(cx); + } + } +} + +impl Render for NebulaWorkspace { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .size_full() + .flex() + .flex_col() + .bg(cx.theme().background) + .text_color(cx.theme().foreground) + .on_action(cx.listener(|this, _: &NewTerminal, window, cx| { + this.add_terminal(window, cx); + })) + .on_action(cx.listener(|this, _: &CloseActiveTerminal, window, cx| { + this.close_active_terminal(window, cx); + })) + .child( + TitleBar::new() + .child( + div() + .flex() + .items_center() + .gap_2() + .child( + div() + .text_sm() + .font_weight(FontWeight::SEMIBOLD) + .child("Nebula"), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("GPUI"), + ), + ) + .child( + div() + .flex() + .items_center() + .gap_1() + .pr_2() + // 阻止标题栏拖拽区吃掉按钮点击。 + .occlude() + .child( + Button::new("new-terminal") + .icon(IconName::Plus) + .ghost() + .small() + .tooltip("新建终端 (Ctrl+Shift+T)") + .on_click(cx.listener(|this, _, window, cx| { + this.add_terminal(window, cx); + })), + ) + .child( + Button::new("open-gallery") + .icon(IconName::LayoutDashboard) + .ghost() + .small() + .tooltip("组件验收页") + .on_click(|_, _, cx| { + crate::app::open_gallery_window(cx); + }), + ), + ), + ) + .child(div().flex_1().min_h_0().child(self.dock_area.clone())) + } +}