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:
1 parent
433cf63571
commit
6d794f2f1c
9 files changed
+396
-11
No files matched your search
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -6,5 +6,6 @@
|
||||
pub mod colors;
|
||||
pub mod element;
|
||||
pub mod keymap;
|
||||
pub mod panel;
|
||||
pub mod session;
|
||||
pub mod view;
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 +1,2 @@
|
||||
pub mod component_gallery;
|
||||
pub mod workspace;
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user