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.
This commit is contained in:
kuddev committed 2026-08-12 19:26:46 +08:00
1 parent 433cf63571
commit 6d794f2f1c
9 files changed
+396 -11

No files matched your search

+30
View File
@@ -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).
+22 -2
View File
@@ -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");
}
+1
View File
@@ -6,5 +6,6 @@
pub mod colors;
pub mod element;
pub mod keymap;
pub mod panel;
pub mod session;
pub mod view;
+115
View File
@@ -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<TerminalView>,
_subscriptions: Vec<Subscription>,
}
impl TerminalPanel {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> 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<PanelEvent> for TerminalPanel {}
impl EventEmitter<TerminalPanelEvent> 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<SharedString> {
Some(self.tab_title(cx))
}
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
self.tab_title(cx)
}
fn title_suffix(
&mut self,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Option<impl IntoElement> {
// 根级 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<Self>) {
// Tab 被移除时立即回收会话;实体引用清零后 TerminalView::drop 会再兜底。
self.terminal.read(cx).shutdown();
}
}
impl Render for TerminalPanel {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(self.terminal.clone())
}
}
+42 -7
View File
@@ -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<TerminalSession>,
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<Self>) {
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<u8>) {
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<TerminalViewEvent> for TerminalView {}
impl Drop for TerminalView {
fn drop(&mut self) {
// 兜底清理:无论视图以何种路径销毁(关 Tab、关窗口、退出应用),
// 都保证 PTY 线程和子进程被回收。
self.shutdown();
}
}
impl Focusable for TerminalView {
fn focus_handle(&self, _: &App) -> FocusHandle {
+1
View File
@@ -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);
}
+2 -2
View File
@@ -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},
+1
View File
@@ -1 +1,2 @@
pub mod component_gallery;
pub mod workspace;
+182
View File
@@ -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<DockArea>,
tab_panel: Entity<TabPanel>,
_subscriptions: Vec<Subscription>,
}
impl NebulaWorkspace {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> 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<Self>) {
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<Self>) {
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<Self>) {
if self.tab_panel.read(cx).active_panel(cx).is_none() {
cx.quit();
}
}
fn on_terminal_event(
&mut self,
panel: &Entity<TerminalPanel>,
event: &TerminalPanelEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
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<TabPanel>,
event: &PanelEvent,
_window: &mut Window,
cx: &mut Context<Self>,
) {
// 覆盖用户直接点 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<Self>) -> 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()))
}
}