diff --git a/docs/architecture-decisions.md b/docs/architecture-decisions.md index 2e0450f..a04dd5f 100644 --- a/docs/architecture-decisions.md +++ b/docs/architecture-decisions.md @@ -106,3 +106,43 @@ unrelated feature's growth. Remote approval/enforcement is not implied by this l 记录重大取舍而非每次小修复;事实与测试能推翻旧决定。规范误伤、安全修复与旧预算冲突时, 先记录问题和最小修订,维护者审查后更新合同;不能将“只减不增”变成拒绝纠正规范的理由。 + +## ADR-0005 — Editable documents, reusable layouts and pane drop targets + +- **Status:** Implemented in the working tree, 2026-09-08, for the requested editor, + recipe and quick-terminal workflows; pending normal maintainer review. +- **Context:** The code viewer accepted edits without a save operation. Markdown + had a separate read-only lifecycle. Dragging a tab into a split workspace always + split the root. The quick window used fixed monitor dimensions on creation. +- **Decision:** A GPUI file editor owns its input buffer, dirty state and scoped + asynchronous loads/saves. One renderer-independent document snapshot implements + UTF-8/BOM/newline handling, external-change checks and temporary-file replacement. + Partial/invalid previews cannot be saved. Preview image URL rewriting never + changes the source buffer. Markdown headings and source positions come from the + existing Markdown parser. Root blocks remain intact and receive reference + definitions when rendered as individually navigable preview blocks. +- **Layout authority:** Recipes wrap the existing session schema with a name and + format version, stored under the application settings directory. They reuse + `restore_tab` with AI resume disabled and exclude agent identity from stored + trees. They save terminal layout/launch identity, not command history or output. + Reads and writes belong to the recipe view's background tasks. Restoring creates + a separate regular window. Bounds, count and version validation precede restore. +- **Split authority:** `nebula_split::SplitTree::dock_at_leaf` owns subtree grafting. + Local/cross-window gestures carry a stable destination pane ID and direction; + preview rectangles use the same tree layout math as the resulting split. +- **Preferences:** `nebula_settings` owns the quick-window mode and optional logical + dimensions. The window registry samples only normal, settled window bounds, + saves changes through the existing preference writer and clamps restored sizes + to the current display. Animation positions do not become saved dimensions. + New built-in terminal/chrome palette data also lives in the shared settings crate. +- **Alternatives:** A second editor persistence implementation, Markdown source + modification for navigation, root-only docking, or replaying command history + from a layout would add inconsistent state or change the requested semantics. +- **Validation:** Regression coverage targets conditional saves, BOM/CRLF and + Unicode, truncation, heading positions/references, recipe round trips, malformed + preferences, palette contrast and grafting a fifth pane without resizing the + three unrelated panes. Native product checks and actual UI results are reported + separately; this record does not assert release or service-side enforcement. +- **Revisit condition:** Add command replay or externally imported recipes only + with a deliberate replay contract. Replace the preview block adapter when the + pinned TextView exposes an equivalent public heading/navigation API. diff --git a/nebula_app/assets/icons/nebula-command-manager.svg b/nebula_app/assets/icons/nebula-command-manager.svg index 702dc57..1c1dbc2 100644 --- a/nebula_app/assets/icons/nebula-command-manager.svg +++ b/nebula_app/assets/icons/nebula-command-manager.svg @@ -1,6 +1,4 @@ - - - - - + + + diff --git a/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json index d1a28df..b55ce81 100644 --- a/nebula_app/i18n/en-US.json +++ b/nebula_app/i18n/en-US.json @@ -74,7 +74,9 @@ "fallback_description": "The first family is preferred; missing glyphs fall back through the remaining families, with bundled Maple as the final safe fallback.", "group_description": "The first family is primary; the rest are used in fallback order.", "loading": "Enumerating system fonts...", - "no_matches": "No matching fonts" + "no_matches": "No matching fonts", + "expand": "Show font list", + "collapse": "Hide font list" }, "keymap": { "conflict": "{combo} is assigned to both \"{first}\" and \"{second}\"; only the earlier \"{first}\" action will run.", @@ -92,7 +94,14 @@ "hide_config_host": "Hide (config sources cannot be deleted)", "empty_hint": "Use \"+ Add host\" or maintain aliases in ~/.ssh/config", "shared_data_hint": "Uses the same data as the legacy shell. Deleting a config alias hides it and it can be restored." - } + }, + "quick_terminal": { + "mode": "Quick terminal behavior", + "description": "The dedicated window remembers its size. Showing an existing window does not create a tab.", + "dedicated": "Dedicated window", + "existing": "Show existing window" + }, + "search_empty": "No matching settings" }, "provider": { "test": { @@ -252,5 +261,62 @@ "yes": "Yes", "no": "No", "confirmation_expired": "The confirmation request changed or is no longer active." + }, + "editor": { + "loading": "Loading file…", + "save": "Save", + "saving": "Saving…", + "saved": "Saved", + "unsaved": "Unsaved", + "edit": "Edit", + "preview": "Preview", + "outline": "Outline", + "no_headings": "This document has no headings yet", + "copy_heading": "Copy heading", + "reload": "Reload file", + "copy_path": "Copy file path", + "read_only": "Read-only file", + "truncated": "File exceeds 8 MB. Only the beginning is shown; editing and saving are disabled.", + "encoding": "This file is not valid UTF-8 text. Preview only.", + "conflict": "The file changed on disk. Your edits are preserved. Copy any changes you need before reloading.", + "read_failed": "Could not read file: {error}", + "save_failed": "Could not save file: {error}", + "discard_title": "Discard unsaved changes?", + "close_title": "Save changes to this file?", + "discard": "Discard changes", + "cancel": "Cancel", + "lines": "{count} lines", + "save_shortcut": "Save (Ctrl+S / ⌘S)", + "info": "File info", + "type": "Type", + "size": "File size", + "modified": "Modified", + "characters": "Characters", + "line_count": "Lines", + "encoding_label": "Encoding", + "line_ending": "Line ending", + "status": "Status", + "copy_name": "Copy file name", + "reveal": "Reveal in file manager", + "open_external": "Open with default app" + }, + "recipe": { + "title": "Layout recipes", + "name": "Layout name", + "save": "Save layout", + "tab": "Current tab", + "window": "Current window", + "empty": "No saved layouts yet. Arrange your tabs and panes, then give the layout a name.", + "description": "Save tabs, split proportions, and working directories. Restore terminals in a new window.", + "open": "Restore", + "delete": "Delete", + "loading": "Loading layouts…", + "saved": "Layout saved", + "failed": "Operation failed: {error}", + "delete_title": "Delete this layout recipe?", + "open_failed": "Could not restore layout", + "count": "{tabs} tabs · {panes} panes", + "save_window": "Save current window layout…", + "open_library": "Open layout recipes…" } } diff --git a/nebula_app/i18n/zh-CN.json b/nebula_app/i18n/zh-CN.json index 75531f0..fde998c 100644 --- a/nebula_app/i18n/zh-CN.json +++ b/nebula_app/i18n/zh-CN.json @@ -74,7 +74,9 @@ "fallback_description": "第 1 项优先显示;缺失字形依次使用后续字体,内置 Maple 是最终安全兜底。", "group_description": "第 1 项是主字体,其余按顺序回退", "loading": "正在枚举系统字体…", - "no_matches": "没有匹配的字体" + "no_matches": "没有匹配的字体", + "expand": "展开字体列表", + "collapse": "收起字体列表" }, "keymap": { "conflict": "{combo} 同时绑定了「{first}」与「{second}」——只有排前面的「{first}」会触发", @@ -92,7 +94,14 @@ "hide_config_host": "隐藏(config 源不可删除)", "empty_hint": "「+ 添加主机」或在 ~/.ssh/config 里维护别名", "shared_data_hint": "与旧壳共用同一份数据;config 别名删除即隐藏,可恢复" - } + }, + "quick_terminal": { + "mode": "快速终端打开方式", + "description": "独立窗口会记住尺寸;唤起已有窗口不会新建标签页。", + "dedicated": "独立窗口", + "existing": "唤起已有窗口" + }, + "search_empty": "没有匹配的设置" }, "provider": { "test": { @@ -252,5 +261,62 @@ "yes": "是", "no": "否", "confirmation_expired": "确认请求已经变更或结束。" + }, + "editor": { + "loading": "正在读取文件…", + "save": "保存", + "saving": "正在保存…", + "saved": "已保存", + "unsaved": "未保存", + "edit": "编辑", + "preview": "预览", + "outline": "目录", + "no_headings": "此文档还没有标题", + "copy_heading": "复制标题", + "reload": "重新读取文件", + "copy_path": "复制文件路径", + "read_only": "只读文件", + "truncated": "文件超过 8 MB,仅预览开头部分,无法编辑保存", + "encoding": "此文件不是完整的 UTF-8 文本,仅支持预览", + "conflict": "文件已被外部修改。编辑内容已保留,请复制需要的内容后重新读取文件。", + "read_failed": "读取失败:{error}", + "save_failed": "保存失败:{error}", + "discard_title": "放弃未保存的修改?", + "close_title": "保存文件修改?", + "discard": "放弃修改", + "cancel": "取消", + "lines": "{count} 行", + "save_shortcut": "保存(Ctrl+S / ⌘S)", + "info": "文件信息", + "type": "类型", + "size": "文件大小", + "modified": "修改时间", + "characters": "字符数", + "line_count": "行数", + "encoding_label": "编码", + "line_ending": "换行符", + "status": "状态", + "copy_name": "复制文件名", + "reveal": "在文件管理器中显示", + "open_external": "用默认应用打开" + }, + "recipe": { + "title": "布局配方", + "name": "布局名称", + "save": "保存布局", + "tab": "当前标签", + "window": "当前窗口", + "empty": "还没有保存的布局。排列好标签和分屏后,为它起个名字。", + "description": "保存标签、分屏比例和工作目录。恢复时在新窗口创建终端。", + "open": "恢复", + "delete": "删除", + "loading": "正在读取布局…", + "saved": "布局已保存", + "failed": "操作失败:{error}", + "delete_title": "删除此布局配方?", + "open_failed": "布局恢复失败", + "count": "{tabs} 个标签 · {panes} 个分屏", + "save_window": "保存当前窗口布局…", + "open_library": "打开布局配方…" } } diff --git a/nebula_app/src/display/command_palette.rs b/nebula_app/src/display/command_palette.rs index f1cc605..dbc7349 100644 --- a/nebula_app/src/display/command_palette.rs +++ b/nebula_app/src/display/command_palette.rs @@ -1528,15 +1528,7 @@ fn localized_item_label(item: &PaletteItem, language: super::UiLanguage) -> &'st PickBackgroundImage => "Choose background image...", CycleBackground => "Cycle background color", ResetAppearance => "Restore appearance defaults", - SelectTheme(NebulaTheme::Nebula) => "Theme: Nebula", - SelectTheme(NebulaTheme::SilverLight) => "Theme: Silver Light", - SelectTheme(NebulaTheme::SteelDark) => "Theme: Steel Dark", - SelectTheme(NebulaTheme::LimestoneLight) => "Theme: Limestone", - SelectTheme(NebulaTheme::CoalDark) => "Theme: Coal Dark", - SelectTheme(NebulaTheme::LinenLight) => "Theme: Linen Light", - SelectTheme(NebulaTheme::MossDark) => "Theme: Moss Dark", - SelectTheme(NebulaTheme::Nord) => "Theme: Nord", - SelectTheme(NebulaTheme::Paper) => "Theme: Paper", + SelectTheme(theme) => theme.command_label(), ExportWorkspace => "Export workspace...", ImportWorkspace => "Open workspace...", SyncPush => "Sync: push settings", diff --git a/nebula_app/src/display/ui/theme.rs b/nebula_app/src/display/ui/theme.rs index 0fb2f2f..b54afd2 100644 --- a/nebula_app/src/display/ui/theme.rs +++ b/nebula_app/src/display/ui/theme.rs @@ -42,6 +42,10 @@ pub enum NebulaTheme { MossDark, Nord, Paper, + BreezeLight, + BreezeDark, + MintLight, + MintDark, } impl Default for NebulaTheme { @@ -77,6 +81,10 @@ impl NebulaTheme { (Self::LinenLight | Self::MossDark, false) => Self::MossDark, (Self::Paper | Self::Nord, true) => Self::Paper, (Self::Paper | Self::Nord, false) => Self::Nord, + (Self::BreezeLight | Self::BreezeDark, true) => Self::BreezeLight, + (Self::BreezeLight | Self::BreezeDark, false) => Self::BreezeDark, + (Self::MintLight | Self::MintDark, true) => Self::MintLight, + (Self::MintLight | Self::MintDark, false) => Self::MintDark, } } @@ -91,6 +99,29 @@ impl NebulaTheme { Self::MossDark => "Moss Dark", Self::Nord => "Nord", Self::Paper => "Paper", + Self::BreezeLight => "Breeze Light", + Self::BreezeDark => "Breeze Dark", + Self::MintLight => "Mint Light", + Self::MintDark => "Mint Dark", + } + } + + /// Static command-palette labels share theme metadata rather than a second exhaustive UI match. + pub(crate) fn command_label(self) -> &'static str { + match self { + Self::Nebula => "Theme: Nebula", + Self::SilverLight => "Theme: Silver Light", + Self::SteelDark => "Theme: Steel Dark", + Self::LimestoneLight => "Theme: Limestone", + Self::CoalDark => "Theme: Coal Dark", + Self::LinenLight => "Theme: Linen Light", + Self::MossDark => "Theme: Moss Dark", + Self::Nord => "Theme: Nord", + Self::Paper => "Theme: Paper", + Self::BreezeLight => "Theme: Breeze Light", + Self::BreezeDark => "Theme: Breeze Dark", + Self::MintLight => "Theme: Mint Light", + Self::MintDark => "Theme: Mint Dark", } } @@ -105,6 +136,10 @@ impl NebulaTheme { Self::MossDark => "MossDark", Self::Nord => "Nord", Self::Paper => "Paper", + Self::BreezeLight => "BreezeLight", + Self::BreezeDark => "BreezeDark", + Self::MintLight => "MintLight", + Self::MintDark => "MintDark", } } @@ -121,6 +156,11 @@ impl NebulaTheme { "MossDark" => Self::MossDark, "Nord" => Self::Nord, "Paper" => Self::Paper, + "BreezeLight" => Self::BreezeLight, + "BreezeDark" => Self::BreezeDark, + "MintLight" => Self::MintLight, + "MintDark" => Self::MintDark, + _ => return None, }) } @@ -148,6 +188,9 @@ impl NebulaTheme { /// themes a light one). pub(crate) fn accent(self) -> Rgb { match self { + Self::BreezeLight | Self::BreezeDark | Self::MintLight | Self::MintDark => { + rgb8(self.fresh_palette().unwrap().accent) + }, Self::Nebula => Rgb::new(82, 168, 255), Self::SilverLight => Rgb::new(73, 80, 87), Self::SteelDark => Rgb::new(148, 163, 184), @@ -170,6 +213,9 @@ impl NebulaTheme { /// at runtime — the cards use one fixed neutral stand-in so all dark cards /// preview alike regardless of the configured scheme. pub(crate) fn card_ink(self) -> CardInk { + if let Some(palette) = self.fresh_palette() { + return CardInk { fg: rgb8(palette.foreground) }; + } match self { Self::Nord => return CardInk { fg: Rgb::new(0xe5, 0xe9, 0xf0) }, Self::Paper => return CardInk { fg: Rgb::new(0x1a, 0x1a, 0x1a) }, @@ -187,11 +233,8 @@ impl NebulaTheme { /// Nord and Paper carry a complete terminal palette. Keeping the /// data in `nebula_settings` gives GPUI and the legacy renderer one source. pub(crate) fn exact_term_colors(self) -> Option { - match self { - Self::Nord => nebula_settings::ThemeName::Nord.term_theme().exact, - Self::Paper => nebula_settings::ThemeName::Paper.term_theme().exact, - _ => None, - } + nebula_settings::ThemeName::from_prompt_name(self.prompt_name()) + .and_then(|name| name.term_theme().exact) } /// Rebuild the terminal color table for this theme on top of the user's @@ -297,6 +340,13 @@ impl NebulaTheme { /// theme switch retroactively, which users read as "the prompt is stuck"). pub(crate) fn powerline_colors(self) -> [Rgb; 8] { match self { + Self::BreezeLight | Self::BreezeDark | Self::MintLight | Self::MintDark => { + nebula_settings::ThemeName::from_prompt_name(self.prompt_name()) + .unwrap() + .term_theme() + .powerline + .map(rgb8) + }, Self::Nebula => [ Rgb::new(57, 75, 112), Rgb::new(192, 202, 245), @@ -390,8 +440,32 @@ impl NebulaTheme { } } + fn fresh_palette(self) -> Option { + nebula_settings::ThemeName::from_prompt_name(self.prompt_name()) + .and_then(|name| name.fresh_palette()) + } + pub(crate) fn palette(self) -> NebulaPalette { match self { + Self::BreezeLight | Self::BreezeDark | Self::MintLight | Self::MintDark => { + let p = self.fresh_palette().unwrap(); + let rgba = |rgb: [u8; 3], a| Rgba::new(rgb[0], rgb[1], rgb[2], a); + NebulaPalette { + panel: rgba(p.shell, 255), + pill: rgba(p.surface, 255), + tab_stroke_l: rgba(p.muted, 32), + tab_bg_l: rgba(p.surface, 255), + tab_bg_r: rgba(p.surface, 255), + edge_l: rgba(p.accent, 255), + edge_r: rgba(p.accent, 255), + edge_glow_l: rgba(p.accent, 0), + glow_l: rgba(p.accent, 0), + glow_r: rgba(p.accent, 0), + is_light: p.is_light, + term_bg: rgb8(p.surface), + shell_bg: rgb8(p.shell), + } + }, Self::Nebula => NebulaPalette { panel: Rgba::new(34, 38, 48, 224), pill: Rgba::new(43, 48, 59, 218), diff --git a/nebula_app/src/gpui_shell/code_tab.rs b/nebula_app/src/gpui_shell/code_tab.rs index 22fbd50..9d6ef61 100644 --- a/nebula_app/src/gpui_shell/code_tab.rs +++ b/nebula_app/src/gpui_shell/code_tab.rs @@ -1,4 +1,4 @@ -//! 代码文件 Tab:普通文件只读查看;Git 冲突使用同一 Tab 体系打开三栏合并器。 +//! 代码文件 Tab:普通文件共用可保存的文本编辑器;Git 冲突使用三栏合并器。 //! //! 普通代码继续走组件库的虚拟化 code editor。冲突页遵循 IntelliJ 一类合并器 //! 的空间语义:左侧当前版本、中间可编辑结果、右侧传入版本;外侧两栏只读, @@ -25,6 +25,7 @@ const MAX_CODE_BYTES: usize = 8 * 1024 * 1024; fn language_for_extension(extension: &str) -> Option<&'static str> { Some(match extension.to_ascii_lowercase().as_str() { "rs" => "rust", + "md" | "markdown" => "markdown", "py" | "pyi" => "python", "js" | "mjs" | "cjs" => "javascript", "ts" | "mts" | "cts" => "typescript", @@ -63,7 +64,7 @@ fn language_for_extension(extension: &str) -> Option<&'static str> { }) } -fn language_for_path(path: &str) -> &'static str { +pub(super) fn language_for_path(path: &str) -> &'static str { Path::new(path) .extension() .and_then(|extension| extension.to_str()) @@ -80,6 +81,7 @@ pub fn viewable_file(path: &Path) -> bool { pub enum CodeTabViewEvent { GitConflictResolved, + Changed, } impl EventEmitter for CodeTabView {} @@ -134,11 +136,13 @@ struct ConflictDocument { pub struct CodeTabView { pub path: PathBuf, pub title: String, - /// 普通文件的正文;冲突页中它就是唯一可编辑的中栏合并结果。 + /// 冲突页的中栏合并结果;普通文件的缓冲区由 TextFileView 持有。 input: Entity, notice: Option, lines: usize, merge: Option, + file: Option>, + _file_subscription: Option, } impl CodeTabView { @@ -149,9 +153,37 @@ impl CodeTabView { .unwrap_or_else(|| path.display().to_string()); let language = language_for_path(&path.to_string_lossy()); let input = code_input(language, window, cx); - let mut this = Self { path, title, input, notice: None, lines: 0, merge: None }; - this.reload(window, cx); - this + let file = cx.new(|cx| super::file_editor::TextFileView::new(path.clone(), window, cx)); + let subscription = cx.subscribe(&file, |_, _, _, cx| { + cx.emit(CodeTabViewEvent::Changed); + cx.notify(); + }); + Self { + path, + title, + input, + notice: None, + lines: 0, + merge: None, + file: Some(file), + _file_subscription: Some(subscription), + } + } + + pub(super) fn file_editor(&self) -> Option> { + self.file.clone() + } + + pub(super) fn tab_title(&self, cx: &gpui::App) -> String { + self.file.as_ref().map_or_else(|| self.title.clone(), |file| file.read(cx).tab_title()) + } + + pub(super) fn focus_handle(&self, cx: &gpui::App) -> gpui::FocusHandle { + use gpui::Focusable as _; + self.file.as_ref().map_or_else( + || self.input.read(cx).focus_handle(cx), + |file| file.read(cx).focus_handle(cx), + ) } pub fn new_git_merge( @@ -176,6 +208,8 @@ impl CodeTabView { input, notice: None, lines: 0, + file: None, + _file_subscription: None, merge: Some(MergeEditor { key: MergeKey { location, relative_path }, ours, @@ -205,24 +239,9 @@ impl CodeTabView { self.reload_git_merge(window, cx); return; } - let (text, notice) = match std::fs::read(&self.path) { - Ok(bytes) => { - let truncated = bytes.len() > MAX_CODE_BYTES; - let slice = if truncated { &bytes[..MAX_CODE_BYTES] } else { &bytes[..] }; - let text = String::from_utf8_lossy(slice).into_owned(); - let notice = truncated.then(|| { - format!("文件超过 {} MB,仅显示开头部分", MAX_CODE_BYTES / 1024 / 1024) - }); - (text, notice) - }, - Err(error) => { - (String::new(), Some(format!("无法读取 {}: {error}", self.path.display()))) - }, - }; - self.lines = text.lines().count(); - self.notice = notice; - self.input.update(cx, |input, cx| input.set_value(text, window, cx)); - cx.notify(); + if let Some(file) = &self.file { + file.update(cx, |file, cx| file.reload(window, cx)); + } } pub fn reload_git_merge(&mut self, window: &mut Window, cx: &mut Context) { @@ -328,90 +347,8 @@ impl CodeTabView { cx.notify(); } - fn render_file(&self, cx: &mut Context) -> gpui::AnyElement { - let theme = cx.theme(); - let muted = theme.muted_foreground; - let border = theme.border; - let warning = theme.warning; - let mono_family = theme.mono_font_family.clone(); - let mono_size = theme.mono_font_size; - let path_label: SharedString = self.path.display().to_string().into(); - let copy_path = self.path.display().to_string(); - let meta: SharedString = format!("{} 行", self.lines).into(); - - v_flex() - .size_full() - .overflow_hidden() - .child( - h_flex() - .h(px(32.0)) - .flex_shrink_0() - .px(px(12.0)) - .items_center() - .gap_1() - .border_b_1() - .border_color(border) - .child( - div() - .flex_1() - .min_w_0() - .flex() - .items_center() - .gap_2() - .child(Icon::new(IconName::File).xsmall().text_color(muted)) - .child( - div() - .min_w_0() - .truncate() - .font_family(mono_family.clone()) - .text_size(px(12.0)) - .text_color(muted) - .child(path_label), - ), - ) - .child(div().text_size(px(11.0)).text_color(muted).child(meta)) - .child( - Button::new("code-copy-path") - .icon(IconName::Copy) - .ghost() - .xsmall() - .tooltip("复制文件路径") - .on_click(cx.listener(move |_, _, _, cx| { - cx.write_to_clipboard(ClipboardItem::new_string(copy_path.clone())); - })), - ) - .child( - Button::new("code-reload") - .icon(IconName::Redo2) - .ghost() - .xsmall() - .tooltip("重新读取文件") - .on_click(cx.listener(|this, _, window, cx| { - this.reload(window, cx); - })), - ), - ) - .when_some(self.notice.clone(), |root, notice| { - root.child( - h_flex() - .min_h(px(28.0)) - .flex_shrink_0() - .px(px(12.0)) - .py_1() - .border_b_1() - .border_color(border) - .text_size(px(11.0)) - .text_color(warning) - .child(notice), - ) - }) - .child(div().flex_1().min_h_0().child(editor( - &self.input, - false, - mono_family, - mono_size, - ))) - .into_any_element() + fn render_file(&self, _: &mut Context) -> gpui::AnyElement { + div().size_full().children(self.file.clone()).into_any_element() } fn render_merge(&self, cx: &mut Context) -> gpui::AnyElement { diff --git a/nebula_app/src/gpui_shell/doc_tabs.rs b/nebula_app/src/gpui_shell/doc_tabs.rs index b4319cf..824f22f 100644 --- a/nebula_app/src/gpui_shell/doc_tabs.rs +++ b/nebula_app/src/gpui_shell/doc_tabs.rs @@ -1,12 +1,5 @@ -//! 图片 / 文档(Markdown)Tab —— GPUI 壳的只读查看器。 -//! -//! 图片:缩放/平移/锚点数学**复用旧壳** `display::image_viewer::ImageView` -//! (单测锁定的几何状态机),渲染换成 GPUI `paint_image`(解码管线与壁纸 -//! 同款:RGBA → BGRA、后台线程解码)。 -//! -//! 文档:文件读入后交组件库 `TextView::markdown`(解析、代码高亮、选择、 -//! 滚动都在组件内)。入口判定与旧壳同合同(`markdown_view::viewable_file` -//! ∪ `image_viewer::viewable_file`,其余交系统处理器)。 +//! Image tab rendering, plus the shared editable Markdown tab entry point. +//! Image zoom/pan continues to use the existing tested ImageView geometry. use std::cell::RefCell; use std::path::{Path, PathBuf}; @@ -15,16 +8,14 @@ use std::sync::Arc; use gpui::prelude::FluentBuilder as _; use gpui::{ - AppContext as _, Bounds, ContentMask, Context, Corners, EventEmitter, InteractiveElement as _, - IntoElement, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, - Pixels, Point, Render, RenderImage, ScrollWheelEvent, SharedString, Styled as _, Window, div, - px, + Bounds, ContentMask, Context, Corners, InteractiveElement as _, IntoElement, MouseButton, + MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Render, RenderImage, + ScrollWheelEvent, Styled as _, Window, div, px, }; use image::Frame; use crate::display::image_viewer::ImageView; use crate::gpui_shell::prelude::*; -use gpui_component::text::{TextView, TextViewState, TextViewStyle}; /// 双击路由:应用内能读的开 tab(图片/文档/源码),其余交系统处理器。 /// 源码查看是 GPUI 壳新增能力,旧壳合同(`input/chrome.rs`)之上的超集。 @@ -34,10 +25,6 @@ pub fn openable_in_app(path: &Path) -> bool { || crate::gpui_shell::code_tab::viewable_file(path) } -/// 文档一次性读入的上限。组件 TextView 是全量解析(没有旧壳 DocView 的 -/// 行虚拟化),大日志全量喂进去会卡帧;超限截断并在文首说明。 -const MAX_DOC_BYTES: usize = 1024 * 1024; - pub struct ImageTabView { pub path: PathBuf, pub title: String, @@ -225,178 +212,4 @@ impl Render for ImageTabView { } } -pub struct DocTabView { - pub path: PathBuf, - pub title: String, - notice: Option, - /// 必须保留组件的 managed state:真实鼠标选区、跨块文本提取和 Ctrl+C - /// 都由 TextView 维护,宿主只在右键时读取已经成立的选区。 - text_view: gpui::Entity, -} - -pub enum DocTabViewEvent { - SelectionContextMenuRequested { position: Point, text: String }, -} - -impl DocTabView { - pub fn new(path: PathBuf, cx: &mut Context) -> Self { - let title = path - .file_name() - .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or_else(|| path.display().to_string()); - let (content, notice) = load_doc_content(&path); - let text_view = cx.new(|cx| TextViewState::markdown(content.as_ref(), cx)); - Self { path, title, notice, text_view } - } - - pub fn reload(&mut self, cx: &mut Context) { - let (content, notice) = load_doc_content(&self.path); - self.notice = notice; - self.text_view.update(cx, |state, cx| state.set_text(content.as_ref(), cx)); - } - - fn on_right_down(&mut self, event: &MouseDownEvent, _: &mut Window, cx: &mut Context) { - let text = self.text_view.read(cx).selected_text(); - if text.trim().is_empty() { - return; - } - cx.emit(DocTabViewEvent::SelectionContextMenuRequested { position: event.position, text }); - cx.stop_propagation(); - } -} - -impl EventEmitter for DocTabView {} - -fn load_doc_content(path: &Path) -> (SharedString, Option) { - match std::fs::read(path) { - Ok(bytes) => { - let truncated = bytes.len() > MAX_DOC_BYTES; - let slice = if truncated { &bytes[..MAX_DOC_BYTES] } else { &bytes[..] }; - let text = rewrite_doc_images(&String::from_utf8_lossy(slice), path.parent()); - let notice = - truncated.then(|| format!("文件超过 {} KB,仅显示开头部分", MAX_DOC_BYTES / 1024)); - (text.into(), notice) - }, - Err(error) => { - (SharedString::default(), Some(format!("无法读取 {}: {error}", path.display()))) - }, - } -} - -impl Render for DocTabView { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let theme = cx.theme(); - let muted = theme.muted_foreground; - let path_label: SharedString = self.path.display().to_string().into(); - - v_flex() - .size_full() - .p_3() - .gap_2() - .child( - h_flex() - .h(px(28.0)) - .items_center() - .gap_2() - .child( - div() - .flex_1() - .min_w_0() - .text_xs() - .text_color(muted) - .overflow_hidden() - .whitespace_nowrap() - .child(path_label), - ) - .child( - Button::new("doc-reload") - .icon(IconName::Redo2) - .ghost() - .xsmall() - .tooltip("重新读取文件") - .on_click(cx.listener(|this, _, _, cx| { - this.reload(cx); - cx.notify(); - })), - ), - ) - .when_some(self.notice.clone(), |root, notice| { - root.child(div().text_xs().text_color(theme.warning).child(notice)) - }) - .child( - div() - .flex_1() - .min_h_0() - .on_mouse_down(MouseButton::Right, cx.listener(Self::on_right_down)) - .child(TextView::new(&self.text_view) - .selectable(true) - .scrollable(true) - // image_base:相对图片路径(README 的 logo/截图)按 - // 文档所在目录解析;高亮主题跟当前壳主题走。 - .style(TextViewStyle { - image_base: self.path.parent().map(Arc::from), - highlight_theme: cx.theme().highlight_theme.clone(), - is_dark: cx.theme().is_dark(), - ..TextViewStyle::default() - })), - ) - } -} - -/// 把文档里的本地 GIF / 动画 WebP 换成缓存里的单帧 PNG。 -/// 网络图走 HTTP 客户端同一套压帧;本地图不经过 HTTP,不换的话 gpui -/// 仍会播 GIF,markdown 多图共用 element id 时越界 panic。 -fn rewrite_doc_images(text: &str, base: Option<&Path>) -> String { - use std::sync::LazyLock; - static MARKDOWN_IMG: LazyLock = - LazyLock::new(|| regex::Regex::new(r#"(!\[[^\]]*\]\()([^)\s]+)"#).unwrap()); - // `regex` crate 不支持反向引用 `\2`,双引号/单引号拆开写。 - static HTML_IMG_DQ: LazyLock = - LazyLock::new(|| regex::Regex::new(r#"(?i)(]*?\bsrc\s*=\s*")([^"]+)""#).unwrap()); - static HTML_IMG_SQ: LazyLock = - LazyLock::new(|| regex::Regex::new(r#"(?i)(]*?\bsrc\s*=\s*')([^']+)'"#).unwrap()); - let rewritten = MARKDOWN_IMG.replace_all(text, |caps: ®ex::Captures<'_>| { - format!("{}{}", &caps[1], flatten_local_image_url(&caps[2], base)) - }); - let rewritten = HTML_IMG_DQ.replace_all(&rewritten, |caps: ®ex::Captures<'_>| { - format!(r#"{}{}""#, &caps[1], flatten_local_image_url(&caps[2], base)) - }); - HTML_IMG_SQ - .replace_all(&rewritten, |caps: ®ex::Captures<'_>| { - format!("{}{}'", &caps[1], flatten_local_image_url(&caps[2], base)) - }) - .into_owned() -} - -fn flatten_local_image_url(url: &str, base: Option<&Path>) -> String { - let lower = url.to_ascii_lowercase(); - if lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("data:") { - return url.to_owned(); - } - let path = Path::new(url); - let resolved = if path.is_absolute() { - path.to_path_buf() - } else { - match base { - Some(base) => base.join(path), - None => return url.to_owned(), - } - }; - let Ok(bytes) = std::fs::read(&resolved) else { - return url.to_owned(); - }; - let Some(png) = crate::gpui_shell::http::flatten_animated_to_png(&bytes) else { - return url.to_owned(); - }; - let cache = std::env::temp_dir().join("nebula-md-img"); - if std::fs::create_dir_all(&cache).is_err() { - return url.to_owned(); - } - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - std::hash::Hash::hash(&bytes, &mut hasher); - let dest = cache.join(format!("{:016x}.png", std::hash::Hasher::finish(&hasher))); - if !dest.exists() && std::fs::write(&dest, png).is_err() { - return url.to_owned(); - } - dest.to_string_lossy().replace('\\', "/") -} +pub use super::file_editor::{TextFileEvent as DocTabViewEvent, TextFileView as DocTabView}; diff --git a/nebula_app/src/gpui_shell/file_editor/document.rs b/nebula_app/src/gpui_shell/file_editor/document.rs new file mode 100644 index 0000000..1786502 --- /dev/null +++ b/nebula_app/src/gpui_shell/file_editor/document.rs @@ -0,0 +1,176 @@ +//! File snapshots and conditional, atomic saves. No GPUI state or disk I/O in rendering. + +use std::fs; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +pub(super) const MAX_BYTES: usize = 8 * 1024 * 1024; + +#[derive(Clone, Debug)] +pub(super) struct Document { + pub(super) text: String, + pub(super) read_only: bool, + pub(super) truncated: bool, + pub(super) invalid_encoding: bool, + target: PathBuf, + pub(super) bytes: Arc<[u8]>, + pub(super) bom: bool, + pub(super) crlf: bool, + pub(super) modified: Option, +} + +#[derive(Debug)] +pub(super) enum SaveError { + Changed, + ReadOnly, + Io(std::io::Error), +} + +impl From for SaveError { + fn from(error: std::io::Error) -> Self { + Self::Io(error) + } +} + +impl Document { + pub(super) fn load(path: &Path) -> std::io::Result { + let target = path.canonicalize()?; + let file = fs::File::open(&target)?; + let metadata = file.metadata()?; + let mut bytes = Vec::new(); + file.take(MAX_BYTES as u64 + 1).read_to_end(&mut bytes)?; + let truncated = bytes.len() > MAX_BYTES; + bytes.truncate(MAX_BYTES); + let bom = bytes.starts_with(b"\xef\xbb\xbf"); + let body = if bom { &bytes[3..] } else { &bytes }; + let invalid_encoding = std::str::from_utf8(body).is_err() || body.contains(&0); + let text = String::from_utf8_lossy(body); + let crlf = text.contains("\r\n") && !text.replace("\r\n", "").contains('\n'); + let text = text.replace("\r\n", "\n"); + Ok(Self { + text, + read_only: metadata.permissions().readonly() || truncated || invalid_encoding, + truncated, + invalid_encoding, + target, + bytes: bytes.into(), + bom, + crlf, + modified: metadata.modified().ok(), + }) + } + + fn check_current(&self, path: &Path) -> Result<(), SaveError> { + if path.canonicalize()? != self.target { + return Err(SaveError::Changed); + } + let file = fs::File::open(&self.target)?; + let metadata = file.metadata()?; + if metadata.permissions().readonly() { + return Err(SaveError::ReadOnly); + } + if metadata.len() != self.bytes.len() as u64 { + return Err(SaveError::Changed); + } + let mut current = Vec::new(); + file.take(MAX_BYTES as u64 + 1).read_to_end(&mut current)?; + if current.as_slice() != self.bytes.as_ref() { + return Err(SaveError::Changed); + } + Ok(()) + } + + pub(super) fn save(&self, path: &Path, text: String) -> Result { + if self.read_only { + return Err(SaveError::ReadOnly); + } + self.check_current(path)?; + if text == self.text { + return Ok(self.clone()); + } + let mut bytes = if self.bom { b"\xef\xbb\xbf".to_vec() } else { Vec::new() }; + let encoded = if self.crlf { text.replace('\n', "\r\n") } else { text.clone() }; + bytes.extend_from_slice(encoded.as_bytes()); + let parent = self.target.parent().ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "file has no parent") + })?; + // The temporary file lives beside the target, so replacement cannot cross volumes. + // Resolve symlinks once and revalidate them before replacing the target, not the link. + let mut staged = tempfile::NamedTempFile::new_in(parent)?; + staged.as_file().set_permissions(fs::metadata(&self.target)?.permissions())?; + staged.write_all(&bytes)?; + staged.as_file().sync_all()?; + self.check_current(path)?; + staged.persist(&self.target).map_err(|error| SaveError::Io(error.error))?; + Ok(Self { + text, + bytes: bytes.into(), + modified: fs::metadata(&self.target).and_then(|meta| meta.modified()).ok(), + ..self.clone() + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn saving_preserves_bom_crlf_and_unicode() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("中文.md"); + fs::write(&path, b"\xef\xbb\xbf# title\r\nold\r\n").unwrap(); + let doc = Document::load(&path).unwrap(); + assert_eq!(doc.text, "# title\nold\n"); + let saved = doc.save(&path, "# 标题\n新内容\n".into()).unwrap(); + assert_eq!(fs::read(&path).unwrap(), "\u{feff}# 标题\r\n新内容\r\n".as_bytes()); + assert_eq!(saved.text, "# 标题\n新内容\n"); + saved.save(&path, "".into()).unwrap(); + assert_eq!(fs::read(&path).unwrap(), b"\xef\xbb\xbf"); + } + + #[test] + fn concurrent_edit_is_not_overwritten_even_if_length_matches() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("test.rs"); + fs::write(&path, "first").unwrap(); + let doc = Document::load(&path).unwrap(); + fs::write(&path, "other").unwrap(); + assert!(matches!(doc.save(&path, "draft".into()), Err(SaveError::Changed))); + assert_eq!(fs::read_to_string(&path).unwrap(), "other"); + fs::remove_file(&path).unwrap(); + assert!(doc.save(&path, "draft".into()).is_err()); + assert!(!path.exists()); + } + + #[test] + fn partial_or_lossy_previews_can_never_be_saved() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("large.txt"); + fs::write(&path, vec![b'x'; MAX_BYTES + 1]).unwrap(); + let doc = Document::load(&path).unwrap(); + assert!(doc.truncated && doc.read_only); + assert!(matches!(doc.save(&path, "short".into()), Err(SaveError::ReadOnly))); + fs::write(&path, [0xff, 0xfe, 0]).unwrap(); + let doc = Document::load(&path).unwrap(); + assert!(doc.invalid_encoding && doc.read_only); + assert!(matches!(doc.save(&path, "text".into()), Err(SaveError::ReadOnly))); + } + + #[cfg(unix)] + #[test] + fn save_through_symlink_preserves_link_and_target_permissions() { + use std::os::unix::fs::{PermissionsExt, symlink}; + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("script.sh"); + let link = dir.path().join("link.sh"); + fs::write(&target, "old").unwrap(); + fs::set_permissions(&target, fs::Permissions::from_mode(0o755)).unwrap(); + symlink(&target, &link).unwrap(); + Document::load(&link).unwrap().save(&link, "new".into()).unwrap(); + assert!(link.is_symlink()); + assert_eq!(fs::read_to_string(&target).unwrap(), "new"); + assert_eq!(fs::metadata(&target).unwrap().permissions().mode() & 0o777, 0o755); + } +} diff --git a/nebula_app/src/gpui_shell/file_editor/images.rs b/nebula_app/src/gpui_shell/file_editor/images.rs new file mode 100644 index 0000000..0de4329 --- /dev/null +++ b/nebula_app/src/gpui_shell/file_editor/images.rs @@ -0,0 +1,59 @@ +use std::path::Path; + +/// 把文档里的本地 GIF / 动画 WebP 换成缓存里的单帧 PNG。 +/// 网络图走 HTTP 客户端同一套压帧;本地图不经过 HTTP,不换的话 gpui +/// 仍会播 GIF,markdown 多图共用 element id 时越界 panic。 +pub(super) fn rewrite_doc_images(text: &str, base: Option<&Path>) -> String { + use std::sync::LazyLock; + static MARKDOWN_IMG: LazyLock = + LazyLock::new(|| regex::Regex::new(r#"(!\[[^\]]*\]\()([^)\s]+)"#).unwrap()); + // `regex` crate 不支持反向引用 `\2`,双引号/单引号拆开写。 + static HTML_IMG_DQ: LazyLock = + LazyLock::new(|| regex::Regex::new(r#"(?i)(]*?\bsrc\s*=\s*")([^"]+)""#).unwrap()); + static HTML_IMG_SQ: LazyLock = + LazyLock::new(|| regex::Regex::new(r#"(?i)(]*?\bsrc\s*=\s*')([^']+)'"#).unwrap()); + let rewritten = MARKDOWN_IMG.replace_all(text, |caps: ®ex::Captures<'_>| { + format!("{}{}", &caps[1], flatten_local_image_url(&caps[2], base)) + }); + let rewritten = HTML_IMG_DQ.replace_all(&rewritten, |caps: ®ex::Captures<'_>| { + format!(r#"{}{}""#, &caps[1], flatten_local_image_url(&caps[2], base)) + }); + HTML_IMG_SQ + .replace_all(&rewritten, |caps: ®ex::Captures<'_>| { + format!("{}{}'", &caps[1], flatten_local_image_url(&caps[2], base)) + }) + .into_owned() +} + +fn flatten_local_image_url(url: &str, base: Option<&Path>) -> String { + let lower = url.to_ascii_lowercase(); + if lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("data:") { + return url.to_owned(); + } + let path = Path::new(url); + let resolved = if path.is_absolute() { + path.to_path_buf() + } else { + match base { + Some(base) => base.join(path), + None => return url.to_owned(), + } + }; + let Ok(bytes) = std::fs::read(&resolved) else { + return url.to_owned(); + }; + let Some(png) = crate::gpui_shell::http::flatten_animated_to_png(&bytes) else { + return url.to_owned(); + }; + let cache = std::env::temp_dir().join("nebula-md-img"); + if std::fs::create_dir_all(&cache).is_err() { + return url.to_owned(); + } + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + std::hash::Hash::hash(&bytes, &mut hasher); + let dest = cache.join(format!("{:016x}.png", std::hash::Hasher::finish(&hasher))); + if !dest.exists() && std::fs::write(&dest, png).is_err() { + return url.to_owned(); + } + dest.to_string_lossy().replace('\\', "/") +} diff --git a/nebula_app/src/gpui_shell/file_editor/info.rs b/nebula_app/src/gpui_shell/file_editor/info.rs new file mode 100644 index 0000000..8f473da --- /dev/null +++ b/nebula_app/src/gpui_shell/file_editor/info.rs @@ -0,0 +1,118 @@ +use super::*; + +impl TextFileView { + pub(super) fn render_info(&self, cx: &mut Context) -> gpui::AnyElement { + let language = super::super::config::ui_language(cx); + let muted = cx.theme().muted_foreground; + let text = self.input.read(cx).value(); + let mut rows = vec![ + ( + Message::EditorType, + self.path.extension().unwrap_or_default().to_string_lossy().to_uppercase(), + ), + (Message::EditorLineCount, text.lines().count().to_string()), + (Message::EditorCharacters, text.chars().count().to_string()), + ( + Message::EditorStatus, + language + .text(if self.dirty { Message::EditorUnsaved } else { Message::EditorSaved }) + .to_owned(), + ), + ]; + if let Some(document) = &self.document { + rows.insert(1, (Message::EditorSize, format!("{} B", document.bytes.len()))); + if let Some(modified) = document.modified { + let date: chrono::DateTime = modified.into(); + rows.insert( + 2, + (Message::EditorModified, date.format("%Y-%m-%d %H:%M").to_string()), + ); + } + rows.push(( + Message::EditorEncodingLabel, + if document.invalid_encoding { + "—" + } else if document.bom { + "UTF-8 BOM" + } else { + "UTF-8" + } + .into(), + )); + rows.push(( + Message::EditorLineEnding, + if document.crlf { "CRLF" } else { "LF" }.into(), + )); + } + let path = self.path.clone(); + let name = self.title.clone(); + let reveal = self.path.clone(); + let external = self.path.clone(); + v_flex() + .id("file-info-panel") + .w(px(250.0)) + .max_w(gpui::relative(0.38)) + .h_full() + .flex_shrink_0() + .border_l_1() + .border_color(cx.theme().border) + .p_3() + .gap_3() + .overflow_y_scroll() + .child(div().text_xs().font_semibold().child(language.text(Message::EditorInfo))) + .child(div().text_sm().font_semibold().child(self.title.clone())) + .child(div().text_xs().text_color(muted).child(self.path.display().to_string())) + .children(rows.into_iter().map(|(label, value)| { + h_flex() + .w_full() + .items_start() + .gap_2() + .text_xs() + .child(div().flex_shrink_0().text_color(muted).child(language.text(label))) + .child(div().flex_1().min_w_0().text_right().child(value)) + })) + .child( + Button::new("info-copy-path") + .ghost() + .small() + .justify_start() + .label(language.text(Message::EditorCopyPath)) + .on_click(move |_, _, cx| { + cx.write_to_clipboard(gpui::ClipboardItem::new_string( + path.display().to_string(), + )) + }), + ) + .child( + Button::new("info-copy-name") + .ghost() + .small() + .justify_start() + .label(language.text(Message::EditorCopyName)) + .on_click(move |_, _, cx| { + cx.write_to_clipboard(gpui::ClipboardItem::new_string(name.clone())) + }), + ) + .child( + Button::new("info-reveal") + .ghost() + .small() + .justify_start() + .label(language.text(Message::EditorReveal)) + .on_click(move |_, _, _| { + super::super::workspace::reveal_in_file_manager(&reveal) + }), + ) + .child( + Button::new("info-open") + .ghost() + .small() + .justify_start() + .label(language.text(Message::EditorOpenExternal)) + .on_click(move |_, _, _| { + super::super::workspace::open_in_file_manager(&external) + }), + ) + .into_any_element() + } +} diff --git a/nebula_app/src/gpui_shell/file_editor/mod.rs b/nebula_app/src/gpui_shell/file_editor/mod.rs new file mode 100644 index 0000000..be33b49 --- /dev/null +++ b/nebula_app/src/gpui_shell/file_editor/mod.rs @@ -0,0 +1,630 @@ +//! Editable local text files shared by Markdown and code tabs. + +mod document; +mod images; +mod info; +mod outline; +#[cfg(all(test, feature = "gpui-test-support"))] +mod tests; + +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use gpui::prelude::*; +use gpui::{ + App, Context, Entity, EventEmitter, FocusHandle, Focusable, KeyBinding, ListAlignment, + ListState, MouseButton, Pixels, Point, PromptLevel, SharedString, Subscription, Task, Window, + actions, div, px, +}; +use gpui_component::WindowExt as _; +use gpui_component::text::{TextView, TextViewState, TextViewStyle}; + +use super::prelude::*; +use crate::i18n::Message; +use document::{Document, SaveError}; +use outline::Outline; + +actions!(file_editor, [SaveFile]); + +pub(super) fn init(cx: &mut App) { + cx.bind_keys([ + KeyBinding::new("ctrl-s", SaveFile, Some("FileEditor")), + KeyBinding::new("cmd-s", SaveFile, Some("FileEditor")), + KeyBinding::new("ctrl-a", gpui_component::input::SelectAll, Some("FileEditor")), + KeyBinding::new("cmd-a", gpui_component::input::SelectAll, Some("FileEditor")), + KeyBinding::new("ctrl-c", gpui_component::input::Copy, Some("FileEditor")), + KeyBinding::new("cmd-c", gpui_component::input::Copy, Some("FileEditor")), + ]); +} + +pub enum TextFileEvent { + Changed, + SelectionContextMenuRequested { position: Point, text: String }, +} + +pub struct TextFileView { + pub path: PathBuf, + pub title: String, + input: Entity, + focus: FocusHandle, + document: Option, + dirty: bool, + loading: bool, + saving: bool, + notice: Option<(Message, Option)>, + markdown: bool, + preview: bool, + show_details: bool, + info: bool, + outline: Outline, + blocks: Vec>, + scroll: ListState, + selected_heading: Option, + all_selected: bool, + revision: u64, + preview_task: Option>, + _input_subscription: Subscription, +} + +impl EventEmitter for TextFileView {} + +impl Focusable for TextFileView { + fn focus_handle(&self, cx: &App) -> FocusHandle { + if self.preview { self.focus.clone() } else { self.input.read(cx).focus_handle(cx) } + } +} + +impl TextFileView { + pub fn new(path: PathBuf, window: &mut Window, cx: &mut Context) -> Self { + let language = super::code_tab::language_for_path(&path.to_string_lossy()); + let markdown = matches!(language, "markdown"); + let input = cx.new(|cx| { + InputState::new(window, cx) + .code_editor(language) + .line_number(true) + .indent_guides(true) + .soft_wrap(false) + }); + let subscription = cx.subscribe_in(&input, window, |this, _, event, _, cx| { + if matches!(event, InputEvent::Change) { + this.dirty = this + .document + .as_ref() + .is_some_and(|doc| this.input.read(cx).value().as_ref() != doc.text); + if this.markdown { + this.schedule_preview(cx); + } + cx.emit(TextFileEvent::Changed); + cx.notify(); + } + }); + let title = path.file_name().unwrap_or_default().to_string_lossy().into_owned(); + let mut this = Self { + path, + title, + input, + focus: cx.focus_handle(), + document: None, + dirty: false, + loading: false, + saving: false, + notice: None, + markdown, + preview: markdown, + show_details: markdown, + info: false, + outline: Outline::default(), + blocks: vec![], + scroll: ListState::new(0, ListAlignment::Top, px(500.0)), + selected_heading: None, + all_selected: false, + revision: 0, + preview_task: None, + _input_subscription: subscription, + }; + this.reload(window, cx); + this + } + + pub(super) fn draft(&self, cx: &App) -> SharedString { + self.input.read(cx).value() + } + + pub fn is_dirty(&self) -> bool { + self.dirty + } + pub fn is_saving(&self) -> bool { + self.saving + } + + pub fn tab_title(&self) -> String { + if self.dirty { format!("{} •", self.title) } else { self.title.clone() } + } + + /// Reopening the same path must preserve a draft and its undo history. + pub fn reload(&mut self, window: &mut Window, cx: &mut Context) { + if self.dirty || self.saving || self.loading { + return; + } + self.load(window, cx); + } + + fn load(&mut self, window: &mut Window, cx: &mut Context) { + self.loading = true; + self.revision += 1; + self.preview_task = None; + let path = self.path.clone(); + let markdown = self.markdown; + let task = cx.background_executor().spawn(async move { + Document::load(&path).map(|doc| { + let outline = markdown + .then(|| Outline::parse(&images::rewrite_doc_images(&doc.text, path.parent()))); + (doc, outline) + }) + }); + let handle = window.window_handle(); + cx.spawn(async move |this, cx| { + let loaded = task.await; + let _ = handle.update(cx, |_, window, cx| { + let _ = this.update(cx, |view, cx| { + view.loading = false; + match loaded { + Ok((document, outline)) => { + view.input.update(cx, |input, cx| { + input.set_value(document.text.clone(), window, cx) + }); + view.document = Some(document); + view.dirty = false; + view.notice = None; + if let Some(outline) = outline { + view.apply_outline(outline, cx); + } + }, + Err(error) => { + view.notice = Some((Message::EditorReadFailed, Some(error.to_string()))) + }, + } + cx.emit(TextFileEvent::Changed); + cx.notify(); + }); + }); + }) + .detach(); + cx.notify(); + } + + fn request_reload(&mut self, window: &mut Window, cx: &mut Context) { + if self.saving || self.loading { + return; + } + if !self.dirty { + self.load(window, cx); + return; + } + let language = super::config::ui_language(cx); + let answer = window.prompt( + PromptLevel::Warning, + language.text(Message::EditorDiscardTitle), + Some(&self.path.display().to_string()), + &[language.text(Message::EditorCancel), language.text(Message::EditorDiscard)], + cx, + ); + let handle = window.window_handle(); + cx.spawn(async move |this, cx| { + if answer.await != Ok(1) { + return; + } + let _ = handle.update(cx, |_, window, cx| { + let _ = this.update(cx, |view, cx| view.load(window, cx)); + }); + }) + .detach(); + } + + /// A successful save only cleans the exact snapshot written. Edits made while + /// I/O is running remain dirty; a second write cannot race the first one. + pub fn save(&mut self, cx: &mut Context) -> Task { + if self.saving || self.loading { + return Task::ready(false); + } + if !self.dirty { + return Task::ready(true); + } + let Some(document) = self.document.clone() else { return Task::ready(false) }; + let text = self.input.read(cx).value().to_string(); + let path = self.path.clone(); + self.saving = true; + self.notice = None; + let task = cx.background_executor().spawn(async move { document.save(&path, text) }); + cx.notify(); + cx.spawn(async move |this, cx| { + let result = task.await; + this.update(cx, |view, cx| { + view.saving = false; + let success = match result { + Ok(document) => { + view.dirty = view.input.read(cx).value().as_ref() != document.text; + view.document = Some(document); + view.notice = Some((Message::EditorSaved, None)); + !view.dirty + }, + Err(error) => { + view.notice = Some(match error { + SaveError::Changed => (Message::EditorConflict, None), + SaveError::ReadOnly => (Message::EditorReadOnly, None), + SaveError::Io(error) => { + (Message::EditorSaveFailed, Some(error.to_string())) + }, + }); + false + }, + }; + cx.emit(TextFileEvent::Changed); + cx.notify(); + success + }) + .unwrap_or(false) + }) + } + + fn schedule_preview(&mut self, cx: &mut Context) { + self.revision += 1; + let revision = self.revision; + let executor = cx.background_executor().clone(); + self.preview_task = Some(cx.spawn(async move |this, cx| { + executor.timer(Duration::from_millis(250)).await; + let Ok((source, path)) = + this.update(cx, |view, cx| (view.input.read(cx).value(), view.path.clone())) + else { + return; + }; + let outline = executor + .spawn(async move { + Outline::parse(&images::rewrite_doc_images(&source, path.parent())) + }) + .await; + let _ = this.update(cx, |view, cx| { + if view.revision == revision { + view.apply_outline(outline, cx); + } + }); + })); + } + + fn apply_outline(&mut self, outline: Outline, cx: &mut Context) { + self.blocks = outline + .blocks + .iter() + .map(|text| cx.new(|cx| TextViewState::markdown(text, cx))) + .collect(); + self.scroll.reset(self.blocks.len()); + self.outline = outline; + self.selected_heading = None; + self.all_selected = false; + cx.notify(); + } + + fn jump_to_heading(&mut self, index: usize, window: &mut Window, cx: &mut Context) { + let Some(heading) = self.outline.headings.get(index) else { return }; + self.selected_heading = Some(index); + if self.preview { + self.scroll + .scroll_to(gpui::ListOffset { item_ix: heading.block, offset_in_item: px(0.0) }); + } else { + let row = heading.row; + self.input.update(cx, |input, cx| { + input.set_cursor_position( + gpui_component::input::Position { line: row, character: 0 }, + window, + cx, + ) + }); + } + cx.notify(); + } + + fn render_outline(&self, cx: &mut Context) -> gpui::AnyElement { + let language = super::config::ui_language(cx); + let muted = cx.theme().muted_foreground; + v_flex() + .id("markdown-outline") + .w(px(210.0)) + .min_w(px(110.0)) + .max_w(gpui::relative(0.32)) + .h_full() + .flex_shrink_0() + .border_l_1() + .border_color(cx.theme().border) + .child( + div().p_3().text_xs().font_semibold().child(language.text(Message::EditorOutline)), + ) + .child( + v_flex() + .id("markdown-headings") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .px_2() + .when(self.outline.headings.is_empty(), |list| { + list.child( + div() + .text_xs() + .text_color(muted) + .child(language.text(Message::EditorNoHeadings)), + ) + }) + .children(self.outline.headings.iter().enumerate().map(|(index, heading)| { + let copied = heading.label.clone(); + div() + .id(("markdown-heading", index)) + .min_h(px(28.0)) + .py_1() + .pr_2() + .pl(px(8.0 + heading.depth.saturating_sub(1) as f32 * 12.0)) + .rounded_md() + .text_xs() + .cursor_pointer() + .text_color(if self.selected_heading == Some(index) { + cx.theme().link + } else { + muted + }) + .hover(|style| style.bg(cx.theme().list_hover)) + .child(heading.label.clone()) + .on_click(cx.listener(move |this, _, window, cx| { + this.jump_to_heading(index, window, cx) + })) + .context_menu(move |menu, _, _| { + let copied = copied.clone(); + menu.item( + gpui_component::menu::PopupMenuItem::new( + language.text(Message::EditorCopyHeading), + ) + .icon(IconName::Copy) + .on_click( + move |_, _, cx| { + cx.write_to_clipboard(gpui::ClipboardItem::new_string( + copied.clone(), + )); + }, + ), + ) + }) + })), + ) + .into_any_element() + } +} + +impl Render for TextFileView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let language = super::config::ui_language(cx); + let muted = cx.theme().muted_foreground; + let editable = !self.loading && self.document.as_ref().is_some_and(|doc| !doc.read_only); + let path = self.path.display().to_string(); + let notice = if self.loading { + Some(language.text(Message::EditorLoading).to_owned()) + } else if let Some((message, error)) = &self.notice { + Some(language.format(*message, &[("error", error.as_deref().unwrap_or_default())])) + } else { + self.document.as_ref().and_then(|doc| { + let message = if doc.truncated { + Message::EditorTruncated + } else if doc.invalid_encoding { + Message::EditorEncoding + } else if doc.read_only { + Message::EditorReadOnly + } else { + return None; + }; + Some(language.text(message).to_owned()) + }) + }; + let content = if self.preview { + let blocks = self.blocks.clone(); + let style = TextViewStyle { + image_base: self.path.parent().map(Arc::from), + highlight_theme: cx.theme().highlight_theme.clone(), + is_dark: cx.theme().is_dark(), + ..Default::default() + }; + div() + .flex_1() + .min_w_0() + .h_full() + .px_4() + .on_mouse_down( + MouseButton::Right, + cx.listener(|_, event: &gpui::MouseDownEvent, window, cx| { + let text = window.selected_text(cx).to_string(); + if !text.trim().is_empty() { + cx.emit(TextFileEvent::SelectionContextMenuRequested { + position: event.position, + text, + }); + cx.stop_propagation(); + } + }), + ) + .child( + gpui::list(self.scroll.clone(), move |index, _, _| { + div() + .w_full() + .py_1() + .child( + TextView::new(&blocks[index]) + .selectable(true) + .scrollable(false) + .style(style.clone()), + ) + .into_any_element() + }) + .size_full(), + ) + .into_any_element() + } else { + div() + .flex_1() + .min_w_0() + .h_full() + .child( + Input::new(&self.input) + .h_full() + .disabled(!editable) + .bordered(false) + .focus_bordered(false) + .rounded(px(0.0)) + .font_family(cx.theme().mono_font_family.clone()) + .text_size(cx.theme().mono_font_size) + .line_height(gpui::relative(1.55)) + .px_3() + .py_2(), + ) + .into_any_element() + }; + v_flex() + .id("text-file-editor") + .key_context("FileEditor") + .track_focus(&self.focus) + .size_full() + .overflow_hidden() + .on_action(cx.listener(|this, _: &SaveFile, _, cx| { + this.save(cx).detach(); + })) + .when(self.preview, |root| { + root.capture_action(cx.listener( + |this, _: &gpui_component::input::SelectAll, _, cx| { + for block in &this.blocks { + block.update(cx, |state, cx| state.select_all(cx)); + } + this.all_selected = true; + cx.stop_propagation(); + cx.notify(); + }, + )) + .capture_action(cx.listener(|this, _: &gpui_component::input::Copy, _, cx| { + if this.all_selected { + let text = this + .blocks + .iter() + .map(|block| block.read(cx).selected_text()) + .collect::>() + .join("\n\n"); + cx.write_to_clipboard(gpui::ClipboardItem::new_string(text)); + cx.stop_propagation(); + } else { + cx.propagate(); + } + })) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _, cx| { + this.all_selected = false; + cx.notify(); + }), + ) + }) + .child( + h_flex() + .h(px(36.0)) + .flex_shrink_0() + .px_3() + .gap_2() + .border_b_1() + .border_color(cx.theme().border) + .child( + div().flex_1().min_w_0().truncate().text_xs().text_color(muted).child(path), + ) + .when(self.dirty, |bar| { + bar.child( + div() + .text_xs() + .text_color(cx.theme().warning) + .child(language.text(Message::EditorUnsaved)), + ) + }) + .when(self.markdown, |bar| { + bar.child( + Button::new("file-toggle-preview") + .ghost() + .xsmall() + .label(language.text(if self.preview { + Message::EditorEdit + } else { + Message::EditorPreview + })) + .on_click(cx.listener(|this, _, window, cx| { + this.preview = !this.preview; + if !this.preview { + this.input.update(cx, |input, cx| input.focus(window, cx)); + } + cx.notify(); + })), + ) + .child( + Button::new("file-toggle-outline") + .ghost() + .xsmall() + .selected(self.show_details) + .label(language.text(Message::EditorOutline)) + .on_click(cx.listener(|this, _, _, cx| { + this.show_details = !this.show_details || this.info; + this.info = false; + cx.notify(); + })), + ) + }) + .child( + Button::new("file-info") + .ghost() + .xsmall() + .label(language.text(Message::EditorInfo)) + .selected(self.show_details && self.info) + .on_click(cx.listener(|this, _, _, cx| { + this.show_details = !this.show_details || !this.info; + this.info = true; + cx.notify(); + })), + ) + .child( + Button::new("file-reload") + .ghost() + .xsmall() + .icon(IconName::Redo2) + .tooltip(language.text(Message::EditorReload)) + .disabled(self.saving || self.loading) + .on_click( + cx.listener(|this, _, window, cx| this.request_reload(window, cx)), + ), + ) + .child( + Button::new("file-save") + .small() + .label(language.text(if self.saving { + Message::EditorSaving + } else { + Message::EditorSave + })) + .tooltip(language.text(Message::EditorSaveShortcut)) + .disabled(!editable || !self.dirty || self.saving) + .on_click(cx.listener(|this, _, _, cx| { + this.save(cx).detach(); + })), + ), + ) + .when_some(notice, |root, notice| { + root.child(div().px_3().py_1().text_xs().text_color(muted).child(notice)) + }) + .child(h_flex().flex_1().min_h_0().w_full().items_start().child(content).when( + self.show_details, + |row| { + row.child(if self.info { + self.render_info(cx) + } else { + self.render_outline(cx) + }) + }, + )) + } +} diff --git a/nebula_app/src/gpui_shell/file_editor/outline.rs b/nebula_app/src/gpui_shell/file_editor/outline.rs new file mode 100644 index 0000000..9e2e110 --- /dev/null +++ b/nebula_app/src/gpui_shell/file_editor/outline.rs @@ -0,0 +1,125 @@ +//! The same parsed heading index drives source navigation and preview blocks. +//! Split only at root block boundaries; fenced code, lists and quotes remain intact. + +use markdown::mdast::Node; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct Heading { + pub(super) label: String, + pub(super) depth: u8, + pub(super) row: u32, + pub(super) block: usize, +} + +#[derive(Default)] +pub(super) struct Outline { + pub(super) headings: Vec, + pub(super) blocks: Vec, +} + +impl Outline { + pub(super) fn parse(source: &str) -> Self { + let mut options = markdown::ParseOptions::gfm(); + options.constructs.math_flow = true; + options.constructs.math_text = true; + let Ok(Node::Root(root)) = markdown::to_mdast(source, &options) else { + return Self { headings: vec![], blocks: vec![source.to_owned()] }; + }; + // Resolve reference links/images from any part of the original document. + let definitions = root + .children + .iter() + .filter_map(|node| { + if !matches!(node, Node::Definition(_) | Node::FootnoteDefinition(_)) { + return None; + } + let position = node.position()?; + source.get(position.start.offset..position.end.offset) + }) + .collect::>() + .join("\n\n"); + let mut result = Self::default(); + for node in &root.children { + if matches!(node, Node::Definition(_) | Node::FootnoteDefinition(_)) { + continue; + } + let Some(position) = node.position() else { continue }; + let Some(text) = source.get(position.start.offset..position.end.offset) else { + continue; + }; + collect_headings(node, result.blocks.len(), &mut result.headings); + result.blocks.push(if definitions.is_empty() { + text.to_owned() + } else { + format!("{text}\n\n{definitions}") + }); + } + result + } +} + +fn label(node: &Node, text: &mut String) { + match node { + Node::Text(node) => text.push_str(&node.value), + Node::InlineCode(node) => text.push_str(&node.value), + Node::InlineMath(node) => text.push_str(&node.value), + Node::Image(node) => text.push_str(&node.alt), + Node::ImageReference(node) => text.push_str(&node.alt), + Node::Break(_) => text.push(' '), + _ => { + if let Some(children) = node.children() { + for child in children { + label(child, text); + } + } + }, + } +} + +fn collect_headings(node: &Node, block: usize, headings: &mut Vec) { + if let Node::Heading(heading) = node { + let mut text = String::new(); + label(node, &mut text); + headings.push(Heading { + label: text, + depth: heading.depth, + row: heading.position.as_ref().map_or(0, |p| p.start.line.saturating_sub(1) as u32), + block, + }); + } + if let Some(children) = node.children() { + for child in children { + collect_headings(child, block, headings); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn headings_keep_hierarchy_source_rows_and_duplicate_targets() { + let outline = Outline::parse( + "# 标题 *one*\n\n```md\n# not a heading\n```\n\nTitle\n---\n\n## 标题 `two`\n\n## 标题 `two`\n", + ); + assert_eq!( + outline.headings.iter().map(|h| (h.label.as_str(), h.depth, h.row)).collect::>(), + [("标题 one", 1, 0), ("Title", 2, 6), ("标题 two", 2, 9), ("标题 two", 2, 11)] + ); + assert_ne!(outline.headings[2].block, outline.headings[3].block); + assert!(outline.blocks.iter().any(|text| text.starts_with("```md\n# not"))); + } + + #[test] + fn nested_headings_keep_their_container_and_references_resolve_across_blocks() { + let outline = Outline::parse( + "> ## [linked][ref]\n> body\n\n![image][img]\n\n[ref]: https://example.com\n[img]: images/test.png\n", + ); + assert_eq!(outline.headings[0].label, "linked"); + assert_eq!(outline.headings[0].block, 0); + assert!(outline.blocks[0].starts_with("> ##")); + assert!(outline.blocks[0].contains("[ref]: https://example.com")); + assert!(outline.blocks[1].contains("[img]: images/test.png")); + } +} diff --git a/nebula_app/src/gpui_shell/file_editor/tests.rs b/nebula_app/src/gpui_shell/file_editor/tests.rs new file mode 100644 index 0000000..4bc25dc --- /dev/null +++ b/nebula_app/src/gpui_shell/file_editor/tests.rs @@ -0,0 +1,87 @@ +use super::*; +use gpui::{TestAppContext, VisualTestContext}; +use gpui_component::Root; + +fn open(path: PathBuf, cx: &mut TestAppContext) -> (Entity, VisualTestContext) { + cx.update(|cx| { + gpui_component::init(cx); + init(cx); + }); + let mut file = None; + let (_, window) = cx.add_window_view(|window, cx| { + let view = cx.new(|cx| TextFileView::new(path, window, cx)); + file = Some(view.clone()); + Root::new(view, window, cx) + }); + window.run_until_parked(); + (file.unwrap(), window.clone()) +} + +#[gpui::test] +fn keyboard_save_writes_the_file_and_conflicts_preserve_the_draft(cx: &mut TestAppContext) { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("code.rs"); + std::fs::write(&path, "fn original() {}\n").unwrap(); + let (file, mut cx) = open(path.clone(), cx); + cx.update(|window, cx| { + file.update(cx, |view, cx| { + assert!(!view.loading); + view.input.update(cx, |input, cx| { + input.replace_all("fn edited() {}\n", window, cx); + input.focus(window, cx); + }); + }) + }); + cx.run_until_parked(); + assert!(file.read_with(&cx, |file, _| file.is_dirty())); + cx.simulate_keystrokes("ctrl-s"); + cx.run_until_parked(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "fn edited() {}\n"); + assert!(!file.read_with(&cx, |file, _| file.is_dirty())); + + cx.update(|window, cx| { + file.update(cx, |view, cx| { + view.input.update(cx, |input, cx| input.replace_all("my draft", window, cx)); + }) + }); + cx.run_until_parked(); + std::fs::write(&path, "external change").unwrap(); + cx.update(|window, cx| file.update(cx, |view, cx| view.reload(window, cx))); + assert_eq!(file.read_with(&cx, |view, cx| view.input.read(cx).value().to_string()), "my draft"); + cx.simulate_keystrokes("ctrl-s"); + cx.run_until_parked(); + assert_eq!(std::fs::read_to_string(path).unwrap(), "external change"); + assert!(file.read_with(&cx, |view, _| view.is_dirty() + && matches!(view.notice, Some((Message::EditorConflict, _))))); +} + +#[gpui::test] +fn editing_during_a_save_stays_dirty_and_markdown_jumps_to_source(cx: &mut TestAppContext) { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("notes.md"); + std::fs::write(&path, "# First\n\n## Second\ntext\n").unwrap(); + let (file, mut cx) = open(path.clone(), cx); + cx.update(|window, cx| { + file.update(cx, |view, cx| { + assert_eq!(view.outline.headings.len(), 2); + view.preview = false; + view.jump_to_heading(1, window, cx); + assert_eq!(view.input.read(cx).cursor_position().line, 2); + view.input.update(cx, |input, cx| input.replace_all("saved snapshot", window, cx)); + }) + }); + cx.run_until_parked(); + cx.update(|window, cx| { + file.update(cx, |view, cx| { + view.save(cx).detach(); + view.input.update(cx, |input, cx| input.replace_all("next draft", window, cx)); + }) + }); + cx.run_until_parked(); + assert_eq!(std::fs::read_to_string(path).unwrap(), "saved snapshot"); + assert!(file.read_with(&cx, |view, _| view.is_dirty())); + assert_eq!( + file.read_with(&cx, |view, cx| view.input.read(cx).value().to_string()), + "next draft" + ); +} diff --git a/nebula_app/src/gpui_shell/mod.rs b/nebula_app/src/gpui_shell/mod.rs index eee2574..6370edb 100644 --- a/nebula_app/src/gpui_shell/mod.rs +++ b/nebula_app/src/gpui_shell/mod.rs @@ -27,6 +27,7 @@ pub mod code_tab; pub mod config; pub mod doc_tabs; pub mod file_drop; +mod file_editor; pub mod http; pub mod math_view; pub mod network_settings; @@ -155,6 +156,7 @@ fn init(cx: &mut App) { // 组件库必须只初始化一次,否则全局 action、菜单和主题状态会重复注册。 gpui_component::init(cx); + file_editor::init(cx); // TextView 的公式渲染钩子:旧壳数学管线(compile → 栅格化)接入组件库 // 的 markdown 渲染;不注册时公式回退为源码文本。 math_view::register(cx); diff --git a/nebula_app/src/gpui_shell/settings_pane.rs b/nebula_app/src/gpui_shell/settings_pane.rs index 502f0cf..c07826e 100644 --- a/nebula_app/src/gpui_shell/settings_pane.rs +++ b/nebula_app/src/gpui_shell/settings_pane.rs @@ -96,7 +96,7 @@ pub struct SettingsPane { about_update_seq: u64, about_last_checked: Option, settings_search_input: Entity, - settings_search_trigger_bounds: Option>, + search_origin_section: Option, /// 每项还带着自己的 `values` 表:`SelectState` 只认索引,而从代码侧 /// 改设置(还原默认值、命令面板切换)时手里只有配置文件记号,没有 /// 这张表就没法把闭框的选中项拉回去。 @@ -1130,6 +1130,18 @@ impl SettingsPane { fn section_content(&mut self, window: &mut Window, cx: &mut Context) -> gpui::AnyElement { use gpui::IntoElement as _; + if self.matching_settings_sections(cx).is_empty() { + return div() + .id("settings-search-empty") + .py_5() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child( + crate::gpui_shell::config::ui_language(cx) + .text(crate::i18n::Message::SettingsSearchEmpty), + ) + .into_any_element(); + } match self.active_section { 0 => self.section_home(window, cx), 1 => self.section_appearance(window, cx), @@ -1189,7 +1201,7 @@ impl SettingsPane { .child(Icon::new(IconName::ArrowLeft).size(px(SETTINGS_NAV_ICON_SIZE))) .child(language.pick("返回工作区", "Back to workspace")), ); - for ix in visible_nav_sections() { + for ix in self.matching_settings_sections(cx) { let active = ix == self.active_section; nav = nav.child( div() diff --git a/nebula_app/src/gpui_shell/settings_pane/appearance_picker_tests.rs b/nebula_app/src/gpui_shell/settings_pane/appearance_picker_tests.rs index 23bf247..0e58584 100644 --- a/nebula_app/src/gpui_shell/settings_pane/appearance_picker_tests.rs +++ b/nebula_app/src/gpui_shell/settings_pane/appearance_picker_tests.rs @@ -12,10 +12,24 @@ fn appearance_theme_catalog_matches_the_approved_gallery_order() { .collect(); assert_eq!( labels, - ["Silver", "Pebrel", "Steel", "Nord", "Paper", "Moss", "Limestone", "Coal", "Linen"] + [ + "Breeze Light", + "Breeze Dark", + "Mint Light", + "Mint Dark", + "Silver", + "Pebrel", + "Steel", + "Nord", + "Paper", + "Moss", + "Limestone", + "Coal", + "Linen" + ] ); - assert_eq!(themes.choices(1).len(), 4); - assert_eq!(themes.choices(2).len(), 5); + assert_eq!(themes.choices(1).len(), 6); + assert_eq!(themes.choices(2).len(), 7); for choice in themes.choices(0) { assert_ne!(themes.choices(1).contains(&choice), themes.choices(2).contains(&choice)); } diff --git a/nebula_app/src/gpui_shell/settings_pane/font_picker.rs b/nebula_app/src/gpui_shell/settings_pane/font_picker.rs index 0202d88..2a9b42c 100644 --- a/nebula_app/src/gpui_shell/settings_pane/font_picker.rs +++ b/nebula_app/src/gpui_shell/settings_pane/font_picker.rs @@ -207,6 +207,18 @@ impl SettingsPane { .w_full() .when(self.active_section == 1, |input| input.h(px(36.0))) .cleanable(false) + .suffix(Button::new("font-picker-chevron") + .debug_selector(|| "font-picker-chevron".to_owned()).ghost().xsmall() + .icon(if self.font_picker_open { IconName::ChevronUp } else { IconName::ChevronDown }) + .tooltip(language.text(if self.font_picker_open { crate::i18n::Message::SettingsFontCollapse } else { crate::i18n::Message::SettingsFontExpand })) + .on_mouse_down(MouseButton::Left, |_, _, cx| { cx.stop_propagation(); }) + .on_click(cx.listener(|this, _, window, cx| { + let was_open = this.font_picker_open; + this.font_family_input.update(cx, |input, cx| input.focus(window, cx)); + if was_open { this.close_font_picker(window, false, cx); } else { this.open_font_picker(cx); } + cx.stop_propagation(); + cx.notify(); + }))) .aria_label(language.pick("终端字体", "Terminal font")), ) // 弹层仍须取输入框的真实窗口坐标,才能正确处理滚动、缩放与 DPI。 @@ -388,7 +400,7 @@ impl SettingsPane { families.iter().any(|family| family.eq_ignore_ascii_case(&entry.name)); h_flex() .id(SharedString::from(format!("font-available-row-{index}"))) - .h(px(36.0)) + .h(px(38.0)) .w_full() .min_w_0() .px_2() @@ -441,7 +453,7 @@ impl SettingsPane { // 与输入字段使用同一真实宽度,右缘和左右边界都不会跳变。 .w(width) .max_w_full() - .p_2() + .p_3() .gap_2() .popover_style(cx) .occlude() @@ -564,3 +576,66 @@ impl SettingsPane { ) } } + +#[cfg(all(test, feature = "gpui-test-support"))] +mod interaction_tests { + use super::*; + use gpui::{TestAppContext, point}; + use gpui_component::Root; + + #[gpui::test] + fn dropdown_arrow_toggles_and_search_filters_without_navigation_click(cx: &mut TestAppContext) { + cx.update(|cx| { + gpui_component::init(cx); + cx.set_global(crate::gpui_shell::config::Settings::load( + nebula_settings::ThemeName::Nord, + )); + }); + let mut pane = None; + let (_, mut cx) = cx.add_window_view(|window, cx| { + let view = cx.new(|cx| SettingsPane::new(window, cx)); + pane = Some(view.clone()); + Root::new(view, window, cx) + }); + let pane = pane.unwrap(); + cx.simulate_resize(gpui::size(px(1280.0), px(1600.0))); + cx.run_until_parked(); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + let bounds = + cx.debug_bounds("font-picker-chevron").expect("font field exposes a dropdown arrow"); + let center = point( + bounds.origin.x + bounds.size.width * 0.5, + bounds.origin.y + bounds.size.height * 0.5, + ); + cx.simulate_click(center, gpui::Modifiers::default()); + cx.run_until_parked(); + assert!(pane.read_with(cx, |pane, _| pane.font_picker_open)); + cx.simulate_click(center, gpui::Modifiers::default()); + cx.run_until_parked(); + assert!(!pane.read_with(cx, |pane, _| pane.font_picker_open)); + cx.update(|window, cx| { + pane.update(cx, |pane, cx| { + pane.settings_search_input + .update(cx, |input, cx| input.replace_all("quick terminal", window, cx)); + }) + }); + cx.run_until_parked(); + assert_eq!( + pane.read_with(cx, |pane, cx| ( + pane.active_section, + pane.matching_settings_sections(cx) + )), + (7, vec![7]) + ); + cx.update(|window, cx| { + pane.update(cx, |pane, cx| { + pane.settings_search_input + .update(cx, |input, cx| input.replace_all("", window, cx)); + }) + }); + cx.run_until_parked(); + assert_eq!(pane.read_with(cx, |pane, _| pane.active_section), 1); + } +} diff --git a/nebula_app/src/gpui_shell/settings_pane/initialization.rs b/nebula_app/src/gpui_shell/settings_pane/initialization.rs index c17126e..830b708 100644 --- a/nebula_app/src/gpui_shell/settings_pane/initialization.rs +++ b/nebula_app/src/gpui_shell/settings_pane/initialization.rs @@ -55,6 +55,13 @@ impl SettingsPane { window, cx, ); + add_select( + "quick_terminal_mode", + &["dedicated", "existing"], + runtime.quick_terminal_mode.settings_value(), + window, + cx, + ); add_select("theme", &THEME_VALUES, runtime.theme.prompt_name(), window, cx); // 选项顺序与文案照抄旧壳 `CURSOR_SHAPE_OPTIONS` / `cursor_shape_label`。 add_select( @@ -446,12 +453,15 @@ impl SettingsPane { subscriptions.push(cx.subscribe_in( &settings_search_input, window, - |_this: &mut Self, + |this: &mut Self, _: &Entity, event: &InputEvent, _: &mut Window, cx: &mut Context| { if matches!(event, InputEvent::Change | InputEvent::Focus | InputEvent::Blur) { + if matches!(event, InputEvent::Change) { + this.update_settings_search(cx); + } cx.notify(); } }, @@ -469,7 +479,7 @@ impl SettingsPane { about_update_seq: 0, about_last_checked: None, settings_search_input, - settings_search_trigger_bounds: None, + search_origin_section: None, selects, shell_select, bg_picker_open: false, diff --git a/nebula_app/src/gpui_shell/settings_pane/localization.rs b/nebula_app/src/gpui_shell/settings_pane/localization.rs index 978097d..4e0ec1a 100644 --- a/nebula_app/src/gpui_shell/settings_pane/localization.rs +++ b/nebula_app/src/gpui_shell/settings_pane/localization.rs @@ -18,6 +18,10 @@ pub(super) fn localized_select_labels( } }) .collect(), + "quick_terminal_mode" => vec![ + language.text(crate::i18n::Message::SettingsQuickTerminalDedicated), + language.text(crate::i18n::Message::SettingsQuickTerminalExisting), + ], "cursor_shape" => vec![ language.pick("条形(│)", "Bar (│)"), language.pick("下划线(_)", "Underscore (_)"), diff --git a/nebula_app/src/gpui_shell/settings_pane/navigation.rs b/nebula_app/src/gpui_shell/settings_pane/navigation.rs index 8b5b501..44c77fc 100644 --- a/nebula_app/src/gpui_shell/settings_pane/navigation.rs +++ b/nebula_app/src/gpui_shell/settings_pane/navigation.rs @@ -1,7 +1,7 @@ use super::*; /// 主题下拉(展示名 = 持久化名,与旧壳一致)。 -pub(super) const THEME_VALUES: [&str; 9] = [ +pub(super) const THEME_VALUES: [&str; 13] = [ "Nebula", "SilverLight", "SteelDark", @@ -11,6 +11,10 @@ pub(super) const THEME_VALUES: [&str; 9] = [ "MossDark", "Nord", "Paper", + "BreezeLight", + "BreezeDark", + "MintLight", + "MintDark", ]; pub(super) const REPOSITORY_URL: &str = "https://github.com/Kuddev/pebrel"; @@ -43,7 +47,7 @@ pub(super) const SECTION_SEARCH_TERMS: [&str; 10] = [ "ssh host 主机 remote 远程 connection 连接", "network 网络 proxy 代理 connectivity 连接", "interaction 交互 copy 复制 paste 粘贴 tab 标签 panel 面板", - "keymap key binding shortcut 按键映射 快捷键", + "keymap key binding shortcut quick terminal 快速终端 独立窗口 已有窗口 按键映射 快捷键", "advanced 高级 session 会话 tray 托盘 restore 恢复", "backup 备份 export 导出 restore 恢复", ]; @@ -142,6 +146,10 @@ pub(super) fn chrome_theme(theme: ThemeName) -> crate::display::NebulaTheme { ThemeName::MossDark => NebulaTheme::MossDark, ThemeName::Nord => NebulaTheme::Nord, ThemeName::Paper => NebulaTheme::Paper, + ThemeName::BreezeLight => NebulaTheme::BreezeLight, + ThemeName::BreezeDark => NebulaTheme::BreezeDark, + ThemeName::MintLight => NebulaTheme::MintLight, + ThemeName::MintDark => NebulaTheme::MintDark, } } diff --git a/nebula_app/src/gpui_shell/settings_pane/search_header.rs b/nebula_app/src/gpui_shell/settings_pane/search_header.rs index ddf5aa1..8f3f9f4 100644 --- a/nebula_app/src/gpui_shell/settings_pane/search_header.rs +++ b/nebula_app/src/gpui_shell/settings_pane/search_header.rs @@ -1,68 +1,37 @@ use super::*; impl SettingsPane { - pub(super) fn render_search_header( - &self, - window: &Window, - cx: &Context, - ) -> gpui::AnyElement { + pub(super) fn matching_settings_sections(&self, cx: &App) -> Vec { + let query = self.settings_search_input.read(cx).value(); + matching_sections(&query, crate::gpui_shell::config::ui_language(cx)) + } + + pub(super) fn update_settings_search(&mut self, cx: &mut Context) { + let searching = !self.settings_search_input.read(cx).value().trim().is_empty(); + if searching { + self.search_origin_section.get_or_insert(self.active_section); + let sections = self.matching_settings_sections(cx); + if !sections.contains(&self.active_section) { + if let Some(index) = sections.first() { + self.active_section = *index; + } + self.font_picker_open = false; + self.bg_picker_open = false; + } + } else if let Some(origin) = self.search_origin_section.take() { + self.active_section = origin; + } + cx.notify(); + } + + pub(super) fn render_search_header(&self, _: &Window, cx: &Context) -> gpui::AnyElement { let language = crate::gpui_shell::config::ui_language(cx); - let query = self.settings_search_input.read(cx).value().trim().to_lowercase(); - let results: Vec = if query.is_empty() { - Vec::new() - } else { - visible_nav_sections() - .filter(|index| { - SECTION_SEARCH_TERMS[*index].contains(&query) - || section_label(*index, language).to_lowercase().contains(&query) - }) - .take(6) - .collect() - }; - let bounds = self.settings_search_trigger_bounds; - let hairline = crate::gpui_shell::theme::settings_hairline(cx); - let hover = cx.theme().list_hover; - let focused = self.settings_search_input.read(cx).focus_handle(cx).is_focused(window); - let panel = (focused && !results.is_empty()).then(|| { - v_flex() - .w(bounds.map(|bounds| bounds.size.width).unwrap_or(px(680.0))) - .p_1() - .gap_1() - .rounded_md() - .border_1() - .border_color(hairline) - .bg(crate::gpui_shell::theme::settings_panel_bg(cx)) - .text_sm() - .line_height(px(20.0)) - .shadow_lg() - .occlude() - .children(results.into_iter().map(|index| { - h_flex() - .id(("settings-search-result", index)) - .h(px(SETTINGS_NAV_ROW_HEIGHT)) - .px_2() - .gap_2() - .rounded_md() - .cursor_pointer() - .hover(move |row| row.bg(hover)) - .on_click(cx.listener(move |this, _, window, cx| { - this.active_section = index; - this.settings_search_input - .update(cx, |input, cx| input.set_value("", window, cx)); - cx.notify(); - })) - .child( - Icon::default() - .path(section_icon(index)) - .size(px(SETTINGS_NAV_ICON_SIZE)), - ) - .child(section_label(index, language)) - })) - }); - let trigger = cx.entity().downgrade(); - let search = div() - .relative() + h_flex() .w_full() + .h(px(SETTINGS_HEADER_HEIGHT)) + .flex_shrink_0() + .px_5() + .items_center() .child( Input::new(&self.settings_search_input) .w_full() @@ -74,41 +43,36 @@ impl SettingsPane { ) .aria_label(language.pick("在全部设置中搜索", "Search all settings")), ) - .child( - gpui::canvas( - move |bounds, _, cx| { - let _ = trigger.update(cx, |pane, cx| { - if pane.settings_search_trigger_bounds != Some(bounds) { - pane.settings_search_trigger_bounds = Some(bounds); - cx.notify(); - } - }); - }, - |_, _, _, _| {}, - ) - .absolute() - .size_full(), - ) - .when_some(panel.zip(bounds), |search, (panel, bounds)| { - search.child( - deferred( - anchored() - .anchor(gpui::Anchor::TopLeft) - .position(bounds.bottom_left()) - .offset(gpui::point(px(0.0), px(6.0))) - .snap_to_window_with_margin(px(8.0)) - .child(panel), - ) - .with_priority(3), - ) - }); - h_flex() - .w_full() - .h(px(SETTINGS_HEADER_HEIGHT)) - .flex_shrink_0() - .px_5() - .items_center() - .child(search) .into_any_element() } } + +pub(super) fn matching_sections(query: &str, language: crate::display::UiLanguage) -> Vec { + let query = query.trim().to_lowercase(); + visible_nav_sections() + .filter(|index| { + let haystack = format!( + "{} {}", + SECTION_SEARCH_TERMS[*index], + section_label(*index, language).to_lowercase() + ); + query.split_whitespace().all(|word| haystack.contains(word)) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn search_filters_navigation_without_an_extra_click() { + let en = crate::display::UiLanguage::EnUs; + let zh = crate::display::UiLanguage::ZhCn; + assert_eq!(matching_sections(" FONT ", en), vec![1]); + assert_eq!(matching_sections("字体", zh), vec![1]); + assert_eq!(matching_sections("quick terminal", en), vec![7]); + assert!(matching_sections("no such setting", en).is_empty()); + assert_eq!(matching_sections("", en), visible_nav_sections().collect::>()); + assert!(!matching_sections("AI", en).contains(&3)); + } +} diff --git a/nebula_app/src/gpui_shell/settings_pane/theme_picker.rs b/nebula_app/src/gpui_shell/settings_pane/theme_picker.rs index b95d287..92fd7ac 100644 --- a/nebula_app/src/gpui_shell/settings_pane/theme_picker.rs +++ b/nebula_app/src/gpui_shell/settings_pane/theme_picker.rs @@ -1,7 +1,11 @@ use super::appearance_picker::{AppearanceColors, AppearanceSelection, picker_columns}; use super::*; -pub(super) const THEME_ORDER: [ThemeName; 9] = [ +pub(super) const THEME_ORDER: [ThemeName; 13] = [ + ThemeName::BreezeLight, + ThemeName::BreezeDark, + ThemeName::MintLight, + ThemeName::MintDark, ThemeName::SilverLight, ThemeName::Nebula, ThemeName::SteelDark, @@ -108,6 +112,9 @@ impl SettingsPane { .px(px(12.0)) .justify_between() .flex_shrink_0() + // GPUI's overflow mask is rectangular; each painted surface + // must carry the radius where it meets the outer border. + .rounded_t(px(8.0)) .bg(rgb_hsla(palette.shell_bg.r, palette.shell_bg.g, palette.shell_bg.b)) .child( h_flex() diff --git a/nebula_app/src/gpui_shell/theme.rs b/nebula_app/src/gpui_shell/theme.rs index c893298..e364c68 100644 --- a/nebula_app/src/gpui_shell/theme.rs +++ b/nebula_app/src/gpui_shell/theme.rs @@ -19,6 +19,10 @@ fn chrome_theme(name: ThemeName) -> NebulaTheme { ThemeName::MossDark => NebulaTheme::MossDark, ThemeName::Nord => NebulaTheme::Nord, ThemeName::Paper => NebulaTheme::Paper, + ThemeName::BreezeLight => NebulaTheme::BreezeLight, + ThemeName::BreezeDark => NebulaTheme::BreezeDark, + ThemeName::MintLight => NebulaTheme::MintLight, + ThemeName::MintDark => NebulaTheme::MintDark, } } @@ -34,6 +38,10 @@ fn settings_theme_name(theme: NebulaTheme) -> ThemeName { NebulaTheme::MossDark => ThemeName::MossDark, NebulaTheme::Nord => ThemeName::Nord, NebulaTheme::Paper => ThemeName::Paper, + NebulaTheme::BreezeLight => ThemeName::BreezeLight, + NebulaTheme::BreezeDark => ThemeName::BreezeDark, + NebulaTheme::MintLight => ThemeName::MintLight, + NebulaTheme::MintDark => ThemeName::MintDark, } } @@ -713,6 +721,10 @@ mod tests { resolve_theme_name(ThemeName::SilverLight, false, false), ThemeName::SilverLight ); + assert_eq!(resolve_theme_name(ThemeName::BreezeLight, true, false), ThemeName::BreezeDark); + assert_eq!(resolve_theme_name(ThemeName::BreezeDark, true, true), ThemeName::BreezeLight); + assert_eq!(resolve_theme_name(ThemeName::MintLight, true, false), ThemeName::MintDark); + assert_eq!(resolve_theme_name(ThemeName::MintDark, true, true), ThemeName::MintLight); assert_eq!(resolve_theme_name(ThemeName::Nord, true, true), ThemeName::Paper); assert_eq!(resolve_theme_name(ThemeName::Paper, true, false), ThemeName::Nord); } diff --git a/nebula_app/src/gpui_shell/workspace.rs b/nebula_app/src/gpui_shell/workspace.rs index 7395505..6ac31ad 100644 --- a/nebula_app/src/gpui_shell/workspace.rs +++ b/nebula_app/src/gpui_shell/workspace.rs @@ -47,6 +47,7 @@ use nebula_split::{DIVIDER_GAP, HIT_SLOP, RemoveOutcome, SplitDirection, SplitNa mod agents; mod command_manager; +mod documents; mod file_tree; mod key_actions; mod notifications; @@ -54,6 +55,7 @@ mod palette; mod pane_header; mod quick_jump; mod quick_terminal; +mod recipes; mod remote_files; mod residency; mod send_to_chat; @@ -74,7 +76,7 @@ pub(crate) mod windowing; // 调用点分散在设置页与窗口层,原样再导出以免拆分波及它们。 pub(crate) use update_dialog::{open_update_dialog, show_update_notification}; -use tab_drag::{TabDrag, TabDragAxis}; +use tab_drag::{DockTarget, TabDrag, TabDragAxis}; #[cfg(test)] use agents::ai_hook_target_pane; @@ -163,7 +165,7 @@ const STATIC_DEFAULT_COMBOS: &[&str] = &[ "ctrl-shift-f", "escape", "ctrl-shift-d", - "ctrl-shift-s", + "ctrl-alt-shift-s", "f2", "ctrl-shift-enter", "ctrl-alt-left", @@ -191,6 +193,10 @@ const STATIC_DEFAULT_COMBOS: &[&str] = &[ /// `keybind=` 自定义表(两壳共读)中 config::Action → GPUI 工作区动作的 /// 映射。仍跳过未接线的动作(prompt 跳转、搜索):编辑器可读写,这里不 /// 注入。`CreateNewWindow` 复用同一个 GPUI App 和进程级 hook/runtime。 + +/// 存储格式 combo(`ctrl+shift+t`)→ gpui 绑定串(`ctrl-shift-t`)。键名 +/// 两套体系同构(小写命名键 + 单字符);digitN 折回数字,plus/minus 折回 +/// `+`/`-`(`+` 是存储分隔符,必须先占位再替换)。 fn custom_workspace_binding(combo: &str, action: &crate::config::Action) -> Option { use crate::config::Action; let combo = gpui_binding_combo(combo); @@ -233,9 +239,6 @@ fn custom_workspace_binding(combo: &str, action: &crate::config::Action) -> Opti } } -/// 存储格式 combo(`ctrl+shift+t`)→ gpui 绑定串(`ctrl-shift-t`)。键名 -/// 两套体系同构(小写命名键 + 单字符);digitN 折回数字,plus/minus 折回 -/// `+`/`-`(`+` 是存储分隔符,必须先占位再替换)。 fn gpui_binding_combo(combo: &str) -> String { combo .replace("plus", "\u{1}") @@ -257,6 +260,7 @@ pub fn init(cx: &mut App) { fn default_workspace_bindings() -> Vec { [ KeyBinding::new("ctrl-shift-t", NewTerminal, None), + KeyBinding::new("ctrl-alt-shift-s", recipes::OpenLayoutRecipes, None), KeyBinding::new("ctrl-shift-e", NewWindow, None), KeyBinding::new("ctrl-shift-w", CloseActiveTerminal, None), KeyBinding::new("ctrl-shift-b", ToggleSidebar, None), @@ -556,21 +560,7 @@ fn pane_card_divider_bounds( /// 纯树手术(dock 的核心):把 `source` 整树挂到 `target` 树的 `nav` 侧, /// 根级 50/50 分割(旧壳 `dock_tab_into_active` 的布局公式)。 fn dock_tree(target: SplitTree, source: SplitTree, nav: SplitNav) -> SplitTree { - let (direction, src_first) = match nav { - SplitNav::Left => (SplitDirection::LeftRight, true), - SplitNav::Right => (SplitDirection::LeftRight, false), - SplitNav::Up => (SplitDirection::TopBottom, true), - SplitNav::Down => (SplitDirection::TopBottom, false), - }; - let (first, second) = if src_first { (source, target) } else { (target, source) }; - SplitTree::Split { - direction, - ratio: 0.5, - preview_ratio: None, - dragging: false, - first: Box::new(first), - second: Box::new(second), - } + target.joined(source, nav) } /// 侧栏 tab 行高与行距(与 `render_sidebar` 的 `h(px(TAB_ROW_H))`、 @@ -633,6 +623,7 @@ enum WorkspacePaletteAction { Shared(crate::display::command_palette::PaletteAction), /// 聚焦已经存在的工作区标签,不创建副本。 FocusTab(usize), + LayoutRecipes, /// 先激活标签,再把焦点交给其中的明确 pane。 FocusPane { tab: usize, @@ -761,7 +752,7 @@ struct WorkspacePaletteRow { icon_path: Option, } -fn open_in_file_manager(path: &Path) { +pub(super) fn open_in_file_manager(path: &Path) { log_file_manager_spawn("open", path, crate::platform::file_manager::open(path)); } @@ -783,7 +774,7 @@ fn log_file_manager_spawn(kind: &str, path: &Path, result: std::io::Result<()>) /// “在文件管理器中显示”与单纯打开路径不是一个动作。Windows 的 /// `/select,` 必须和路径组成同一个 argv,避免空格与 Unicode 被二次解析。 -fn reveal_in_file_manager(path: &Path) { +pub(super) fn reveal_in_file_manager(path: &Path) { log_file_manager_spawn("reveal", path, crate::platform::file_manager::reveal(path)); } @@ -1011,7 +1002,7 @@ pub struct NebulaWorkspace { /// 见 [`pane_header::PaneDrag`]。 pane_drag: Option, /// 其它窗口的标签悬停在本窗口终端区时的跨窗 dock 预览。 - cross_window_dock: Option, + cross_window_dock: Option, /// 进行中的分隔条拖拽;预览比例写进树节点,松手提交(吸附整格)。 split_drag: Option, /// 进行中的侧栏拖宽(设置「面板拖拽调节」开启时才有入口);宽度实时 @@ -1172,7 +1163,8 @@ impl NebulaWorkspace { #[cfg(windows)] { let mut icon_scale = window.scale_factor(); - cx.observe_window_bounds(window, move |_, window, _| { + cx.observe_window_bounds(window, move |_, window, cx| { + windowing::quick_terminal_bounds_changed(runtime_window_id, window, cx); if icon_scale != window.scale_factor() { icon_scale = window.scale_factor(); crate::gpui_shell::set_native_window_icon(window); @@ -2001,10 +1993,24 @@ impl NebulaWorkspace { /// GPUI 的 should-close 回调必须同步返回:无繁忙进程时直接允许系统关闭; /// 有繁忙进程时先返回 false,再由对话框确认回调显式移除窗口。 fn should_close_window(&mut self, window: &mut Window, cx: &mut Context) -> bool { + #[cfg(windows)] + windowing::quick_terminal_bounds_changed(self.runtime_window_id, window, cx); let persist_session = self.window_role == windowing::WindowRole::Regular; if persist_session && self.keep_session_on_close(window, cx) { return false; } + if self.guard_file_window_close(window, cx) { + return false; + } + self.close_window_after_documents(window, cx) + } + + fn close_window_after_documents( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let persist_session = self.window_role == windowing::WindowRole::Regular; let Some(process) = self.busy_process_in_window(cx) else { if persist_session { self.save_clean_window_session(cx); @@ -2422,146 +2428,6 @@ impl NebulaWorkspace { } } - /// 调试/验收后门:`NEBULA_GPUI_OPEN_DOC=路径` 时启动即打开该文档, - /// 与文件树双击同一条路由(公式渲染等文档 UI 的免点击验收)。 - pub fn open_document_at_startup( - &mut self, - path: std::path::PathBuf, - window: &mut Window, - cx: &mut Context, - ) { - self.open_document_path(path, window, cx); - } - - /// 文件路由(旧壳 `input/chrome.rs` 双击合同):图片 → 图片 tab; - /// Markdown → 文档 tab(TextView 富渲染);其余可读文本(txt/log/json - /// 与源码)→ 代码 tab(行号 + 行级虚拟化,用户裁定 txt 同代码一样); - /// 都不认的交系统处理器。 - fn open_document_path( - &mut self, - path: std::path::PathBuf, - window: &mut Window, - cx: &mut Context, - ) { - let is_markdown = - path.extension().and_then(|extension| extension.to_str()).is_some_and(|extension| { - matches!(extension.to_ascii_lowercase().as_str(), "md" | "markdown") - }); - if crate::display::image_viewer::viewable_file(&path) { - self.open_image_tab(path, window, cx); - } else if is_markdown { - self.open_doc_tab(path, window, cx); - } else if crate::gpui_shell::code_tab::viewable_file(&path) - || crate::display::markdown_view::viewable_file(&path) - { - self.open_code_tab(path, window, cx); - } else { - open_in_file_manager(&path); - } - } - - /// 同一路径复用已开 tab(激活 + 重读盘,旧壳 open_image_tab 同语义)。 - fn open_image_tab( - &mut self, - path: std::path::PathBuf, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(ix) = self.tabs.iter().position( - |tab| matches!(tab, WorkspaceTab::Image { view } if view.read(cx).path == path), - ) { - if let Some(WorkspaceTab::Image { view }) = self.tabs.get(ix) { - view.clone().update(cx, |view, cx| view.reload(cx)); - } - self.activate_tab(ix, window, cx); - return; - } - let view = cx.new(|cx| crate::gpui_shell::doc_tabs::ImageTabView::new(path, cx)); - self.insert_new_tab(WorkspaceTab::Image { view }); - self.focus_active(window, cx); - cx.notify(); - } - - fn open_doc_tab( - &mut self, - path: std::path::PathBuf, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(ix) = self.tabs.iter().position( - |tab| matches!(tab, WorkspaceTab::Document { view, .. } if view.read(cx).path == path), - ) { - if let Some(WorkspaceTab::Document { view, .. }) = self.tabs.get(ix) { - view.clone().update(cx, |view, cx| { - view.reload(cx); - cx.notify(); - }); - } - self.activate_tab(ix, window, cx); - return; - } - let view = cx.new(|cx| crate::gpui_shell::doc_tabs::DocTabView::new(path, cx)); - let subscription = cx.subscribe_in(&view, window, Self::on_document_event); - self.insert_new_tab(WorkspaceTab::Document { view, _subscription: subscription }); - self.focus_active(window, cx); - cx.notify(); - } - - fn open_code_tab( - &mut self, - path: std::path::PathBuf, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(ix) = self.tabs.iter().position( - |tab| matches!(tab, WorkspaceTab::Code { view, .. } if view.read(cx).is_regular_path(&path)), - ) { - if let Some(WorkspaceTab::Code { view, .. }) = self.tabs.get(ix) { - view.clone().update(cx, |view, cx| view.reload(window, cx)); - } - self.activate_tab(ix, window, cx); - return; - } - let view = cx.new(|cx| crate::gpui_shell::code_tab::CodeTabView::new(path, window, cx)); - let subscription = cx.subscribe(&view, Self::on_code_tab_event); - self.insert_new_tab(WorkspaceTab::Code { view, _subscription: subscription }); - self.focus_active(window, cx); - cx.notify(); - } - - /// 冲突文件仍属于代码 Tab,但以三栏合并形态打开;同一仓库、同一路径复用 - /// 已有 Tab,避免用户从冲突列表连续点击后堆出多个独立结果缓冲区。 - fn open_git_merge_tab( - &mut self, - relative_path: String, - window: &mut Window, - cx: &mut Context, - ) { - let Some(location) = self.side_panel.git_location() else { return }; - if let Some(ix) = self.tabs.iter().position(|tab| { - matches!(tab, WorkspaceTab::Code { view, .. } - if view.read(cx).matches_git_merge(&location, &relative_path)) - }) { - if let Some(WorkspaceTab::Code { view, .. }) = self.tabs.get(ix) { - view.clone().update(cx, |view, cx| view.reload_git_merge(window, cx)); - } - self.activate_tab(ix, window, cx); - return; - } - let view = cx.new(|cx| { - crate::gpui_shell::code_tab::CodeTabView::new_git_merge( - location, - relative_path, - window, - cx, - ) - }); - let subscription = cx.subscribe(&view, Self::on_code_tab_event); - self.insert_new_tab(WorkspaceTab::Code { view, _subscription: subscription }); - self.focus_active(window, cx); - cx.notify(); - } - /// 按视图实体反查 (tab 下标, pane id)。 fn locate_pane(&self, entity_id: gpui::EntityId) -> Option<(usize, u64)> { self.tabs.iter().enumerate().find_map(|(ix, tab)| match tab { @@ -2684,34 +2550,6 @@ impl NebulaWorkspace { } } - fn on_document_event( - &mut self, - _: &Entity, - event: &DocTabViewEvent, - window: &mut Window, - cx: &mut Context, - ) { - match event { - DocTabViewEvent::SelectionContextMenuRequested { position, text } => { - self.open_document_selection_context_menu(*position, text.clone(), window, cx); - }, - } - } - - fn on_code_tab_event( - &mut self, - _: Entity, - event: &CodeTabViewEvent, - cx: &mut Context, - ) { - match event { - CodeTabViewEvent::GitConflictResolved => { - self.side_panel.request_refresh(); - cx.notify(); - }, - } - } - /// 热应用设置页变更,并把 SSH 连接请求转为新标签。 fn on_settings_event( &mut self, @@ -2737,6 +2575,13 @@ impl NebulaWorkspace { /// 终端应用惯例:最后一个 Tab 关闭即退出应用。整 tab 关闭(侧栏 ×) /// 逐 pane 回收会话;实体引用清零后 `TerminalView::drop` 再兜底。 fn close_tab(&mut self, ix: usize, window: &mut Window, cx: &mut Context) { + if self.guard_file_tab_close(ix, window, cx) { + return; + } + self.finish_close_tab(ix, window, cx); + } + + fn finish_close_tab(&mut self, ix: usize, window: &mut Window, cx: &mut Context) { let Some((tab, _meta)) = self.remove_tab_at(ix) else { return }; if let WorkspaceTab::Terminal { panes, .. } = &tab { let mut bounds = self.pane_bounds.borrow_mut(); @@ -2818,13 +2663,9 @@ impl NebulaWorkspace { None => return, }, Some(WorkspaceTab::Settings { view, .. }) => view.read(cx).focus_handle(cx), - // 图片/文档/代码查看 tab 没有键盘焦点语义(滚轮/拖拽直达元素)。 - Some( - WorkspaceTab::Image { .. } - | WorkspaceTab::Document { .. } - | WorkspaceTab::Code { .. }, - ) - | None => return, + Some(WorkspaceTab::Document { view, .. }) => view.read(cx).focus_handle(cx), + Some(WorkspaceTab::Code { view, .. }) => view.read(cx).focus_handle(cx), + Some(WorkspaceTab::Image { .. }) | None => return, }; window.defer(cx, move |window, cx| window.focus(&focus, cx)); } @@ -3036,6 +2877,7 @@ impl NebulaWorkspace { } }) .collect(); + rows.push(recipes::palette_row(language)); // 启动器混排(旧壳 ⌘K 裁定):SSH 主机与命令同列,置顶/隐藏 // 次序由共享 merge 权威裁定。 let ssh_icons = ssh_host_icon_ids(&crate::display::nebula_data_dir()); @@ -3193,6 +3035,10 @@ impl NebulaWorkspace { let Some(action) = action else { return }; match action { WorkspacePaletteAction::Shared(action) => self.run_palette_action(action, window, cx), + WorkspacePaletteAction::LayoutRecipes => { + self.dismiss_palette_state(); + self.open_layout_recipes(window, cx); + }, WorkspacePaletteAction::FocusTab(tab) => { self.dismiss_palette_state(); self.activate_tab(tab, window, cx); @@ -3510,8 +3356,8 @@ impl NebulaWorkspace { match &self.tabs[ix] { WorkspaceTab::Settings { .. } => "设置".into(), WorkspaceTab::Image { view } => view.read(cx).title.clone().into(), - WorkspaceTab::Document { view, .. } => view.read(cx).title.clone().into(), - WorkspaceTab::Code { view, .. } => view.read(cx).title.clone().into(), + WorkspaceTab::Document { view, .. } => view.read(cx).tab_title().into(), + WorkspaceTab::Code { view, .. } => view.read(cx).tab_title(cx).into(), tab @ WorkspaceTab::Terminal { .. } => { // 标签 = 聚焦 pane 的 cwd 末级目录名(旧壳 chrome_tab_label // 规则)。分屏计数**不拼在这里**:这份字符串还要喂给 runtime @@ -4260,14 +4106,8 @@ impl Render for NebulaWorkspace { .filter(|drag| drag.active) .and_then(|drag| drag.dock) .or(self.cross_window_dock) - .and_then(|nav| { - self.active_terminal_area().map(|area| match nav { - SplitNav::Left => (area.x, area.y, area.w * 0.5, area.h), - SplitNav::Right => (area.x + area.w * 0.5, area.y, area.w * 0.5, area.h), - SplitNav::Up => (area.x, area.y, area.w, area.h * 0.5), - SplitNav::Down => (area.x, area.y + area.h * 0.5, area.w, area.h * 0.5), - }) - }); + .and_then(|target| self.dock_preview_area(target)) + .map(|area| (area.x, area.y, area.w, area.h)); div() .size_full() @@ -4352,6 +4192,9 @@ impl Render for NebulaWorkspace { this.sidebar_fold_armed = true; cx.notify(); })) + .on_action(cx.listener(|this, _: &recipes::OpenLayoutRecipes, window, cx| { + this.open_layout_recipes(window, cx); + })) .on_action(cx.listener(|this, _: &OpenSettings, window, cx| { this.toggle_settings(window, cx); })) diff --git a/nebula_app/src/gpui_shell/workspace/documents.rs b/nebula_app/src/gpui_shell/workspace/documents.rs new file mode 100644 index 0000000..1cfbb31 --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/documents.rs @@ -0,0 +1,320 @@ +//! File routing and draft protection for tab/window lifecycles. + +use super::super::file_editor::TextFileView; +use super::*; +use crate::i18n::Message; + +impl WorkspaceTab { + fn file_editor(&self, cx: &App) -> Option> { + match self { + Self::Document { view, .. } => Some(view.clone()), + Self::Code { view, .. } => view.read(cx).file_editor(), + _ => None, + } + } +} + +impl NebulaWorkspace { + pub(super) fn guard_file_tab_close( + &mut self, + index: usize, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let Some(file) = self.tabs.get(index).and_then(|tab| tab.file_editor(cx)) else { + return false; + }; + if file.read(cx).is_saving() { + return true; + } + if !file.read(cx).is_dirty() { + return false; + } + self.confirm_files_close(vec![file], false, window, cx); + true + } + + pub(super) fn guard_file_window_close( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let files: Vec<_> = self + .tabs + .iter() + .filter_map(|tab| tab.file_editor(cx)) + .filter(|file| file.read(cx).is_dirty() || file.read(cx).is_saving()) + .collect(); + if files.is_empty() { + return false; + } + if !files.iter().any(|file| file.read(cx).is_saving()) { + self.confirm_files_close(files, true, window, cx); + } + true + } + + fn confirm_files_close( + &mut self, + files: Vec>, + whole_window: bool, + window: &mut Window, + cx: &mut Context, + ) { + if self.window_close_confirm_open { + return; + } + self.window_close_confirm_open = true; + let handle = window.window_handle(); + cx.spawn(async move |this, cx| { + let mut accepted = true; + let mut discarded = Vec::new(); + for file in &files { + let prompt = handle.update(cx, |_, window, cx| { + let language = crate::gpui_shell::config::ui_language(cx); + let draft = file.read(cx).draft(cx); + let prompt = window.prompt( + gpui::PromptLevel::Warning, + language.text(Message::EditorCloseTitle), + Some(&file.read(cx).path.display().to_string()), + &[ + language.text(Message::EditorSave), + language.text(Message::EditorDiscard), + language.text(Message::EditorCancel), + ], + cx, + ); + (prompt, draft) + }); + let Ok((prompt, draft)) = prompt else { + accepted = false; + break; + }; + match prompt.await { + Ok(0) => { + let task = file.update(cx, |file, cx| file.save(cx)); + if !task.await { + accepted = false; + break; + } + }, + Ok(1) => discarded.push((file.clone(), draft)), + _ => { + accepted = false; + break; + }, + } + } + let _ = handle.update(cx, |_, window, cx| { + let _ = + this.update(cx, |workspace, cx| { + workspace.window_close_confirm_open = false; + if !accepted { + cx.notify(); + return; + } + let relevant = if whole_window { + workspace + .tabs + .iter() + .filter_map(|tab| tab.file_editor(cx)) + .collect::>() + } else { + files.clone() + }; + if relevant.iter().any(|file| { + let view = file.read(cx); + view.is_saving() + || (view.is_dirty() + && !discarded.iter().any(|(approved, draft)| { + approved == file && *draft == view.draft(cx) + })) + }) { + cx.notify(); + return; + } + if whole_window { + if workspace.close_window_after_documents(window, cx) { + window.remove_window(); + } + } else if let Some(index) = workspace.tabs.iter().position(|tab| { + tab.file_editor(cx).is_some_and(|file| file == files[0]) + }) { + workspace.finish_close_tab(index, window, cx); + } + }); + }); + }) + .detach(); + } + + /// 调试/验收后门:`NEBULA_GPUI_OPEN_DOC=路径` 时启动即打开该文档, + /// 与文件树双击同一条路由(公式渲染等文档 UI 的免点击验收)。 + pub fn open_document_at_startup( + &mut self, + path: std::path::PathBuf, + window: &mut Window, + cx: &mut Context, + ) { + self.open_document_path(path, window, cx); + } + + /// 文件路由(旧壳 `input/chrome.rs` 双击合同):图片 → 图片 tab; + /// Markdown → 文档 tab(TextView 富渲染);其余可读文本(txt/log/json + /// 与源码)→ 代码 tab(行号 + 行级虚拟化,用户裁定 txt 同代码一样); + /// 都不认的交系统处理器。 + pub(super) fn open_document_path( + &mut self, + path: std::path::PathBuf, + window: &mut Window, + cx: &mut Context, + ) { + let is_markdown = + path.extension().and_then(|extension| extension.to_str()).is_some_and(|extension| { + matches!(extension.to_ascii_lowercase().as_str(), "md" | "markdown") + }); + if crate::display::image_viewer::viewable_file(&path) { + self.open_image_tab(path, window, cx); + } else if is_markdown { + self.open_doc_tab(path, window, cx); + } else if crate::gpui_shell::code_tab::viewable_file(&path) + || crate::display::markdown_view::viewable_file(&path) + { + self.open_code_tab(path, window, cx); + } else { + open_in_file_manager(&path); + } + } + + /// 同一路径复用已开 tab(激活 + 重读盘,旧壳 open_image_tab 同语义)。 + fn open_image_tab( + &mut self, + path: std::path::PathBuf, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(ix) = self.tabs.iter().position( + |tab| matches!(tab, WorkspaceTab::Image { view } if view.read(cx).path == path), + ) { + if let Some(WorkspaceTab::Image { view }) = self.tabs.get(ix) { + view.clone().update(cx, |view, cx| view.reload(cx)); + } + self.activate_tab(ix, window, cx); + return; + } + let view = cx.new(|cx| crate::gpui_shell::doc_tabs::ImageTabView::new(path, cx)); + self.insert_new_tab(WorkspaceTab::Image { view }); + self.focus_active(window, cx); + cx.notify(); + } + + fn open_doc_tab( + &mut self, + path: std::path::PathBuf, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(ix) = self.tabs.iter().position( + |tab| matches!(tab, WorkspaceTab::Document { view, .. } if view.read(cx).path == path), + ) { + if let Some(WorkspaceTab::Document { view, .. }) = self.tabs.get(ix) { + view.clone().update(cx, |view, cx| { + view.reload(window, cx); + cx.notify(); + }); + } + self.activate_tab(ix, window, cx); + return; + } + let view = cx.new(|cx| crate::gpui_shell::doc_tabs::DocTabView::new(path, window, cx)); + let subscription = cx.subscribe_in(&view, window, Self::on_document_event); + self.insert_new_tab(WorkspaceTab::Document { view, _subscription: subscription }); + self.focus_active(window, cx); + cx.notify(); + } + + fn open_code_tab( + &mut self, + path: std::path::PathBuf, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(ix) = self.tabs.iter().position( + |tab| matches!(tab, WorkspaceTab::Code { view, .. } if view.read(cx).is_regular_path(&path)), + ) { + if let Some(WorkspaceTab::Code { view, .. }) = self.tabs.get(ix) { + view.clone().update(cx, |view, cx| view.reload(window, cx)); + } + self.activate_tab(ix, window, cx); + return; + } + let view = cx.new(|cx| crate::gpui_shell::code_tab::CodeTabView::new(path, window, cx)); + let subscription = cx.subscribe(&view, Self::on_code_tab_event); + self.insert_new_tab(WorkspaceTab::Code { view, _subscription: subscription }); + self.focus_active(window, cx); + cx.notify(); + } + + /// 冲突文件仍属于代码 Tab,但以三栏合并形态打开;同一仓库、同一路径复用 + /// 已有 Tab,避免用户从冲突列表连续点击后堆出多个独立结果缓冲区。 + pub(super) fn open_git_merge_tab( + &mut self, + relative_path: String, + window: &mut Window, + cx: &mut Context, + ) { + let Some(location) = self.side_panel.git_location() else { return }; + if let Some(ix) = self.tabs.iter().position(|tab| { + matches!(tab, WorkspaceTab::Code { view, .. } + if view.read(cx).matches_git_merge(&location, &relative_path)) + }) { + if let Some(WorkspaceTab::Code { view, .. }) = self.tabs.get(ix) { + view.clone().update(cx, |view, cx| view.reload_git_merge(window, cx)); + } + self.activate_tab(ix, window, cx); + return; + } + let view = cx.new(|cx| { + crate::gpui_shell::code_tab::CodeTabView::new_git_merge( + location, + relative_path, + window, + cx, + ) + }); + let subscription = cx.subscribe(&view, Self::on_code_tab_event); + self.insert_new_tab(WorkspaceTab::Code { view, _subscription: subscription }); + self.focus_active(window, cx); + cx.notify(); + } + + fn on_document_event( + &mut self, + _: &Entity, + event: &DocTabViewEvent, + window: &mut Window, + cx: &mut Context, + ) { + match event { + DocTabViewEvent::Changed => cx.notify(), + DocTabViewEvent::SelectionContextMenuRequested { position, text } => { + self.open_document_selection_context_menu(*position, text.clone(), window, cx); + }, + } + } + + fn on_code_tab_event( + &mut self, + _: Entity, + event: &CodeTabViewEvent, + cx: &mut Context, + ) { + match event { + CodeTabViewEvent::Changed => cx.notify(), + CodeTabViewEvent::GitConflictResolved => { + self.side_panel.request_refresh(); + cx.notify(); + }, + } + } +} diff --git a/nebula_app/src/gpui_shell/workspace/palette.rs b/nebula_app/src/gpui_shell/workspace/palette.rs index 80d1109..79547c6 100644 --- a/nebula_app/src/gpui_shell/workspace/palette.rs +++ b/nebula_app/src/gpui_shell/workspace/palette.rs @@ -225,6 +225,10 @@ impl NebulaWorkspace { WorkspacePaletteAction::Shared(action) => { this.run_palette_action(action, window, cx); }, + WorkspacePaletteAction::LayoutRecipes => { + this.dismiss_palette_state(); + this.open_layout_recipes(window, cx); + }, WorkspacePaletteAction::FocusTab(tab) => { this.dismiss_palette_state(); this.activate_tab(tab, window, cx); diff --git a/nebula_app/src/gpui_shell/workspace/quick_terminal.rs b/nebula_app/src/gpui_shell/workspace/quick_terminal.rs index 2b2a3d9..821b7c7 100644 --- a/nebula_app/src/gpui_shell/workspace/quick_terminal.rs +++ b/nebula_app/src/gpui_shell/workspace/quick_terminal.rs @@ -42,7 +42,10 @@ impl QuickTerminalGeometry { /// 以普通工作区 HWND 所在显示器为目标;没有锚点时 Win32 回退主显示器。 #[cfg(windows)] -pub(super) fn native_geometry(anchor_hwnd: isize) -> Option { +pub(super) fn native_geometry( + anchor_hwnd: isize, + remembered: Option, +) -> Option { use windows_sys::Win32::Foundation::RECT; use windows_sys::Win32::Graphics::Gdi::{ GetMonitorInfoW, MONITOR_DEFAULTTONEAREST, MONITOR_DEFAULTTOPRIMARY, MONITORINFO, @@ -72,7 +75,22 @@ pub(super) fn native_geometry(anchor_hwnd: isize) -> Option String { - let stored = nebula_settings::RuntimeSettings::load().quick_terminal_hotkey; - if stored.trim().is_empty() { - crate::display::keymap::DEFAULT_QUICK_TERMINAL_HOTKEY.to_owned() - } else { - stored - } + nebula_settings::RuntimeSettings::load().quick_terminal_hotkey } /// 注册一个组合键。返回 `None` 表示解析或注册失败——两者都不影响其余功能。 fn register(manager: &GlobalHotKeyManager, combo: &str) -> Option { + if combo.trim().is_empty() { + return None; + } let hotkey = match combo.parse::() { Ok(hotkey) => hotkey, Err(err) => { @@ -257,10 +273,21 @@ fn register(manager: &GlobalHotKeyManager, combo: &str) -> Option { fn resync_combo(cx: &mut App) { let latest = current_combo(); let state = cx.global::(); - if state.combo == latest && state.hotkey.is_some() { + if state.combo == latest && (state.hotkey.is_some() || latest.trim().is_empty()) { return; } let state = cx.global_mut::(); + if latest.trim().is_empty() { + if let Some(previous) = state.hotkey { + if let Err(error) = state.manager.unregister(previous) { + log::warn!("could not clear quick terminal shortcut: {error}"); + return; + } + } + state.hotkey = None; + state.combo = latest; + return; + } let replacement = register(&state.manager, &latest); if replacement.is_some() { if let Some(previous) = state.hotkey.take() { diff --git a/nebula_app/src/gpui_shell/workspace/recipes/mod.rs b/nebula_app/src/gpui_shell/workspace/recipes/mod.rs new file mode 100644 index 0000000..5e17aea --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/recipes/mod.rs @@ -0,0 +1,313 @@ +//! Save and restore named window/tab layouts, using the existing session restore authority. + +mod store; + +use super::*; +use crate::i18n::Message; +use crate::session::Session; +use gpui::EventEmitter; + +gpui::actions!(layout_recipes, [OpenLayoutRecipes]); + +pub(super) fn palette_row(language: crate::display::UiLanguage) -> WorkspacePaletteRow { + WorkspacePaletteRow { + group_order: 2, + group: language.text(Message::RecipeTitle).to_owned(), + label: language.text(Message::RecipeOpenLibrary).to_owned(), + hint: "Ctrl+Alt+Shift+S".into(), + hint_style: WorkspacePaletteHintStyle::Shortcut, + search: "recipe layout save restore 布局 配方 保存 恢复".into(), + action: WorkspacePaletteAction::LayoutRecipes, + icon: None, + icon_glyph: None, + icon_path: None, + } +} + +impl NebulaWorkspace { + pub(super) fn open_layout_recipes(&mut self, window: &mut Window, cx: &mut Context) { + let snapshot = self.snapshot_session(cx); + let tab = self.tabs.get(self.active).filter(|tab| tab.is_terminal()).and_then(|_| { + let index = self.tabs.iter().take(self.active).filter(|tab| tab.is_terminal()).count(); + snapshot.tabs.get(index).cloned().map(|tab| Session::new(0, vec![tab])) + }); + let library = cx.new(|cx| RecipeLibrary::new(snapshot, tab, window, cx)); + cx.subscribe_in(&library, window, |_, _, session: &Session, window, cx| { + window.close_dialog(cx); + windowing::open_recipe_window(session.clone(), cx); + }) + .detach(); + let language = crate::gpui_shell::config::ui_language(cx); + window.open_dialog(cx, move |dialog, _, _| { + dialog.title(language.text(Message::RecipeTitle)).w(px(600.0)).child(library.clone()) + }); + } +} + +struct RecipeLibrary { + name: Entity, + window_snapshot: Session, + tab_snapshot: Option, + current_tab: bool, + entries: Vec, + busy: bool, + error: Option, +} + +impl EventEmitter for RecipeLibrary {} + +impl RecipeLibrary { + fn new( + window_snapshot: Session, + tab_snapshot: Option, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let language = crate::gpui_shell::config::ui_language(cx); + let name = cx + .new(|cx| InputState::new(window, cx).placeholder(language.text(Message::RecipeName))); + let mut this = Self { + name, + window_snapshot, + tab_snapshot, + current_tab: false, + entries: vec![], + busy: false, + error: None, + }; + this.refresh(cx); + this + } + + fn refresh(&mut self, cx: &mut Context) { + self.busy = true; + let task = cx.background_executor().spawn(async { store::list_at(&store::directory()) }); + cx.spawn(async move |this, cx| { + let result = task.await; + let _ = this.update(cx, |this, cx| { + this.busy = false; + match result { + Ok(entries) => this.entries = entries, + Err(error) => this.error = Some(error), + } + cx.notify(); + }); + }) + .detach(); + } + + fn save(&mut self, cx: &mut Context) { + if self.busy { + return; + } + let session = if self.current_tab { + self.tab_snapshot.clone() + } else { + Some(self.window_snapshot.clone()) + }; + let Some(session) = session else { return }; + let recipe = match store::Recipe::new(self.name.read(cx).value().to_string(), session) { + Ok(recipe) => recipe, + Err(error) => { + self.error = Some(error); + cx.notify(); + return; + }, + }; + self.busy = true; + self.error = None; + let task = cx + .background_executor() + .spawn(async move { store::save_at(&store::directory(), &recipe) }); + cx.spawn(async move |this, cx| { + let result = task.await; + let _ = this.update(cx, |this, cx| { + this.busy = false; + match result { + Ok(()) => this.refresh(cx), + Err(error) => this.error = Some(error), + } + cx.notify(); + }); + }) + .detach(); + cx.notify(); + } + + fn delete(&mut self, recipe: store::Recipe, window: &mut Window, cx: &mut Context) { + if self.busy { + return; + } + let language = crate::gpui_shell::config::ui_language(cx); + let answer = window.prompt( + gpui::PromptLevel::Warning, + language.text(Message::RecipeDeleteTitle), + Some(&recipe.name), + &[language.text(Message::EditorCancel), language.text(Message::RecipeDelete)], + cx, + ); + let executor = cx.background_executor().clone(); + cx.spawn(async move |this, cx| { + if answer.await != Ok(1) { + return; + } + let result = executor.spawn(async move { std::fs::remove_file(recipe.path) }).await; + let _ = this.update(cx, |this, cx| { + match result { + Ok(()) => this.refresh(cx), + Err(error) => this.error = Some(error.to_string()), + } + cx.notify(); + }); + }) + .detach(); + } +} + +impl Render for RecipeLibrary { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let language = crate::gpui_shell::config::ui_language(cx); + let muted = cx.theme().muted_foreground; + let entries = self.entries.clone(); + v_flex() + .w_full() + .gap_3() + .child( + div().text_sm().text_color(muted).child(language.text(Message::RecipeDescription)), + ) + .child( + h_flex() + .gap_2() + .child( + Button::new("recipe-scope-window") + .small() + .ghost() + .selected(!self.current_tab) + .label(language.text(Message::RecipeWindow)) + .on_click(cx.listener(|this, _, _, cx| { + this.current_tab = false; + cx.notify(); + })), + ) + .child( + Button::new("recipe-scope-tab") + .small() + .ghost() + .selected(self.current_tab) + .disabled(self.tab_snapshot.is_none()) + .label(language.text(Message::RecipeTab)) + .on_click(cx.listener(|this, _, _, cx| { + this.current_tab = true; + cx.notify(); + })), + ), + ) + .child( + h_flex() + .w_full() + .gap_2() + .child(div().flex_1().child(Input::new(&self.name).w_full())) + .child( + Button::new("recipe-save") + .label(language.text(Message::RecipeSave)) + .disabled(self.busy || self.window_snapshot.tabs.is_empty()) + .on_click(cx.listener(|this, _, _, cx| this.save(cx))), + ), + ) + .when_some(self.error.clone(), |root, error| { + root.child( + div() + .text_xs() + .text_color(cx.theme().danger) + .child(language.format(Message::RecipeFailed, &[("error", &error)])), + ) + }) + .child( + v_flex() + .id("recipe-list") + .w_full() + .h(px(300.0)) + .overflow_y_scroll() + .gap_2() + .when(self.busy, |list| { + list.child( + div() + .text_sm() + .text_color(muted) + .child(language.text(Message::RecipeLoading)), + ) + }) + .when(!self.busy && entries.is_empty(), |list| { + list.child( + div() + .py_5() + .text_sm() + .text_color(muted) + .child(language.text(Message::RecipeEmpty)), + ) + }) + .children(entries.into_iter().enumerate().map(|(index, recipe)| { + let open = recipe.session.clone(); + h_flex() + .w_full() + .gap_2() + .p_2() + .rounded_md() + .border_1() + .border_color(cx.theme().border) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_1() + .child(div().text_sm().truncate().child(recipe.name.clone())) + .child(div().text_xs().text_color(muted).child( + language.format( + Message::RecipeCount, + &[ + ("tabs", &recipe.session.tabs.len().to_string()), + ("panes", &recipe.panes().to_string()), + ], + ), + )), + ) + .child( + Button::new(("recipe-open", index)) + .small() + .label(language.text(Message::RecipeOpen)) + .disabled(self.busy) + .on_click( + cx.listener(move |_, _, _, cx| cx.emit(open.clone())), + ), + ) + .child( + Button::new(("recipe-delete", index)) + .xsmall() + .ghost() + .icon(IconName::Delete) + .disabled(self.busy) + .tooltip(language.text(Message::RecipeDelete)) + .on_click(cx.listener(move |this, _, window, cx| { + this.delete(recipe.clone(), window, cx) + })), + ) + })), + ) + } +} + +#[cfg(test)] +mod shortcut_tests { + use super::*; + #[test] + fn layout_shortcut_preserves_the_existing_split_shortcut() { + let keymap = gpui::Keymap::new(super::super::default_workspace_bindings()); + let contexts = [gpui::KeyContext::parse("Root").unwrap()]; + let (split, _) = keymap + .bindings_for_input(&[gpui::Keystroke::parse("ctrl-shift-s").unwrap()], &contexts); + assert!(split[0].action().as_any().is::()); + let (recipe, _) = keymap + .bindings_for_input(&[gpui::Keystroke::parse("ctrl-alt-shift-s").unwrap()], &contexts); + assert!(recipe[0].action().as_any().is::()); + } +} diff --git a/nebula_app/src/gpui_shell/workspace/recipes/store.rs b/nebula_app/src/gpui_shell/workspace/recipes/store.rs new file mode 100644 index 0000000..652ef5a --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/recipes/store.rs @@ -0,0 +1,182 @@ +//! Named layouts reuse the session schema and restore path. No command history or AI resume. + +use crate::session::{LayoutSession, Session}; +use serde::{Deserialize, Serialize}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; + +const MAX_BYTES: u64 = 2 * 1024 * 1024; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub(super) struct Recipe { + version: u32, + pub(super) name: String, + pub(super) session: Session, + #[serde(skip)] + pub(super) path: PathBuf, +} + +pub(super) fn directory() -> PathBuf { + nebula_settings::settings_dir().join("recipes") +} + +fn clear_agents(layout: &mut LayoutSession) { + match layout { + LayoutSession::Pane { agent, .. } => *agent = None, + LayoutSession::Split { first, second, .. } => { + clear_agents(first); + clear_agents(second); + }, + } +} + +fn valid_layout(layout: &LayoutSession, depth: usize) -> bool { + if depth > 16 { + return false; + } + match layout { + LayoutSession::Pane { cwd, agent } => { + !cwd.contains('\0') && cwd.len() <= 32768 && agent.is_none() + }, + LayoutSession::Split { ratio_permille, first, second, .. } => { + (1..1000).contains(ratio_permille) + && valid_layout(first, depth + 1) + && valid_layout(second, depth + 1) + }, + } +} + +impl Recipe { + pub(super) fn new(name: String, mut session: Session) -> Result { + for tab in &mut session.tabs { + if let Some(layout) = &mut tab.layout { + clear_agents(layout); + } + } + session.window = None; + let recipe = + Self { version: 1, name: name.trim().to_owned(), session, path: PathBuf::new() }; + recipe.validate()?; + Ok(recipe) + } + + pub(super) fn panes(&self) -> usize { + self.session + .tabs + .iter() + .map(|tab| tab.layout.as_ref().map_or(1, LayoutSession::pane_count)) + .sum() + } + + fn validate(&self) -> Result<(), String> { + if self.version != 1 || self.session.version > Session::new(0, vec![]).version { + return Err("Unsupported recipe version".into()); + } + if self.name.is_empty() + || self.name.chars().count() > 80 + || self.name.chars().any(char::is_control) + { + return Err("Layout names must contain 1–80 visible characters".into()); + } + if self.session.tabs.is_empty() || self.session.tabs.len() > 32 || self.panes() > 64 { + return Err("A recipe needs 1–32 tabs and at most 64 panes".into()); + } + if self.session.tabs.iter().any(|tab| { + tab.cwd.contains('\0') + || tab.layout.as_ref().is_some_and(|layout| !valid_layout(layout, 0)) + }) { + return Err("Invalid layout tree".into()); + } + Ok(()) + } +} + +pub(super) fn save_at(directory: &Path, recipe: &Recipe) -> Result<(), String> { + recipe.validate()?; + std::fs::create_dir_all(directory).map_err(|error| error.to_string())?; + let bytes = serde_json::to_vec_pretty(recipe).map_err(|error| error.to_string())?; + if bytes.len() as u64 > MAX_BYTES { + return Err("Recipe is too large".into()); + } + let mut file = tempfile::Builder::new() + .prefix("layout-") + .suffix(".tmp") + .tempfile_in(directory) + .map_err(|error| error.to_string())?; + file.write_all(&bytes) + .and_then(|_| file.as_file().sync_all()) + .map_err(|error| error.to_string())?; + let destination = file.path().with_extension("json"); + file.persist_noclobber(destination).map_err(|error| error.error.to_string())?; + Ok(()) +} + +pub(super) fn list_at(directory: &Path) -> Result, String> { + if !directory.exists() { + return Ok(vec![]); + } + let mut entries = Vec::new(); + for entry in std::fs::read_dir(directory).map_err(|error| error.to_string())? { + let path = entry.map_err(|error| error.to_string())?.path(); + if path.extension().is_none_or(|ext| ext != "json") { + continue; + } + let mut bytes = Vec::new(); + std::fs::File::open(&path) + .and_then(|file| file.take(MAX_BYTES + 1).read_to_end(&mut bytes)) + .map_err(|error| error.to_string())?; + if bytes.len() as u64 > MAX_BYTES { + return Err("Recipe is too large".into()); + } + let mut recipe: Recipe = serde_json::from_slice(&bytes) + .map_err(|error| format!("{}: {error}", path.display()))?; + recipe.validate()?; + recipe.path = path; + entries.push(recipe); + } + entries.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + Ok(entries) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::session::{AgentSession, SplitAxis, TabSession}; + + #[test] + fn named_layout_round_trip_preserves_splits_and_discards_agent_replay() { + let mut tab = TabSession::single("D:/api".into(), Some("API".into()), None); + tab.layout = Some(LayoutSession::Split { + axis: SplitAxis::TopBottom, + ratio_permille: 370, + first: Box::new(LayoutSession::Pane { + cwd: "D:/api".into(), + agent: Some(AgentSession { + source: "claude".into(), + session_id: Some("private-id".into()), + }), + }), + second: Box::new(LayoutSession::Pane { cwd: "D:/web".into(), agent: None }), + }); + let recipe = Recipe::new("开发环境".into(), Session::new(0, vec![tab])).unwrap(); + let directory = tempfile::tempdir().unwrap(); + save_at(directory.path(), &recipe).unwrap(); + let entries = list_at(directory.path()).unwrap(); + assert_eq!(entries[0].name, "开发环境"); + assert_eq!(entries[0].session.tabs, recipe.session.tabs); + assert_eq!(entries[0].panes(), 2); + assert!(!std::fs::read_to_string(&entries[0].path).unwrap().contains("private-id")); + } + + #[test] + fn names_do_not_control_paths_and_unknown_versions_are_rejected() { + let directory = tempfile::tempdir().unwrap(); + let tab = TabSession::single("/tmp".into(), None, None); + let mut recipe = Recipe::new("../project".into(), Session::new(0, vec![tab])).unwrap(); + save_at(directory.path(), &recipe).unwrap(); + assert_eq!(list_at(directory.path()).unwrap()[0].path.parent(), Some(directory.path())); + recipe.version = 999; + assert!(save_at(directory.path(), &recipe).is_err()); + assert!(Recipe::new("".into(), Session::new(0, vec![])).is_err()); + } +} diff --git a/nebula_app/src/gpui_shell/workspace/tab_drag.rs b/nebula_app/src/gpui_shell/workspace/tab_drag.rs index 8e2095c..2fa0a1a 100644 --- a/nebula_app/src/gpui_shell/workspace/tab_drag.rs +++ b/nebula_app/src/gpui_shell/workspace/tab_drag.rs @@ -7,7 +7,7 @@ use gpui::{Context, MouseButton, Pixels, Point, Window, px}; use nebula_split::{SplitNav, SplitTree}; use super::{ - NebulaWorkspace, TAB_DRAG_THRESHOLD, WorkspaceTab, dock_tree, + NebulaWorkspace, TAB_DRAG_THRESHOLD, WorkspaceTab, windowing::{self, CrossWindowDropDestination, CrossWindowTabDrag}, }; @@ -31,7 +31,7 @@ pub(super) struct TabDrag { pub(super) pitch: f32, pub(super) offset: f32, pub(super) active: bool, - pub(super) dock: Option, + pub(super) dock: Option, } impl NebulaWorkspace { @@ -251,34 +251,47 @@ impl NebulaWorkspace { (x1 > x0 && y1 > y0).then(|| nebula_split::Rect::new(x0, y0, x1 - x0, y1 - y0)) } - pub(crate) fn dock_nav_at(&self, x: f32, y: f32) -> Option { - let area = self.active_terminal_area()?; - if !area.contains(x, y) { - return None; + pub(crate) fn dock_nav_at(&self, x: f32, y: f32) -> Option { + let areas = self.dock_pane_areas(); + dock_target_at(self.active_terminal_area()?, &areas, x, y) + } + + fn dock_pane_areas(&self) -> Vec<(u64, nebula_split::Rect)> { + let Some(area) = self.active_terminal_area() else { return vec![] }; + let Some(WorkspaceTab::Terminal { tree, zoomed, .. }) = self.tabs.get(self.active) else { + return vec![]; + }; + // A zoomed tree must be restored before adding a split, otherwise its post-drop + // geometry cannot match the visible full-pane preview. + if *zoomed && !tree.is_leaf() { + return vec![]; } - let nx = (x - area.x) / area.w; - let ny = (y - area.y) / area.h; - let (dl, dr, dt, db) = (nx, 1.0 - nx, ny, 1.0 - ny); - let min = dl.min(dr).min(dt).min(db); - Some(if min == dl { - SplitNav::Left - } else if min == dr { - SplitNav::Right - } else if min == dt { - SplitNav::Up + tree.layout(area, 1.0, 1.0, nebula_split::DIVIDER_GAP, false).panes + } + + pub(super) fn dock_preview_area(&self, target: DockTarget) -> Option { + let area = if let Some(pane) = target.pane { + self.dock_pane_areas().into_iter().find(|(id, _)| *id == pane)?.1 } else { - SplitNav::Down - }) + self.active_terminal_area()? + }; + SplitTree::leaf(0) + .joined(SplitTree::leaf(1), target.nav) + .layout(area, 1.0, 1.0, nebula_split::DIVIDER_GAP, false) + .panes + .into_iter() + .find(|(pane, _)| *pane == 1) + .map(|(_, area)| area) } fn dock_tab_into_active( &mut self, source: usize, - nav: SplitNav, + target: DockTarget, window: &mut Window, cx: &mut Context, ) { - if !self.dock_allowed(source) { + if !self.dock_allowed(source) || !self.tabs.get(self.active).is_some_and(|tab| matches!(tab, WorkspaceTab::Terminal { tree, .. } if target.pane.is_none_or(|pane| tree.contains(pane)))) { return; } let Some(( @@ -298,8 +311,13 @@ impl NebulaWorkspace { else { unreachable!("dock_allowed 已保证 active 是 Terminal"); }; - let old = std::mem::replace(tree, SplitTree::leaf(src_focused)); - *tree = dock_tree(old, src_tree, nav); + if let Some(pane) = target.pane { + tree.dock_at_leaf(pane, src_tree, target.nav) + .expect("destination checked before detaching source"); + } else { + let previous = std::mem::replace(tree, SplitTree::leaf(src_focused)); + *tree = previous.joined(src_tree, target.nav); + } panes.extend(src_panes); *focused = src_focused; *zoomed = false; @@ -338,3 +356,81 @@ impl NebulaWorkspace { cx.notify(); } } + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct DockTarget { + pub(crate) pane: Option, + pub(crate) nav: SplitNav, +} + +fn dock_target_at( + area: nebula_split::Rect, + panes: &[(u64, nebula_split::Rect)], + x: f32, + y: f32, +) -> Option { + if !area.contains(x, y) || panes.is_empty() { + return None; + } + // The outer rim retains full-height/full-width insertion; inside it, preview + // and insertion belong exclusively to the hovered leaf. + let rim = 14.0_f32.min(area.w * 0.05).min(area.h * 0.05); + if panes.len() > 1 + && (x - area.x < rim + || area.x + area.w - x < rim + || y - area.y < rim + || area.y + area.h - y < rim) + { + return Some(DockTarget { pane: None, nav: nearest_edge(area, x, y) }); + } + let (pane, area) = panes.iter().find(|(_, area)| area.contains(x, y))?; + Some(DockTarget { pane: Some(*pane), nav: nearest_edge(*area, x, y) }) +} + +fn nearest_edge(area: nebula_split::Rect, x: f32, y: f32) -> SplitNav { + let nx = (x - area.x) / area.w.max(1.0); + let ny = (y - area.y) / area.h.max(1.0); + [ + (nx, SplitNav::Left), + (1.0 - nx, SplitNav::Right), + (ny, SplitNav::Up), + (1.0 - ny, SplitNav::Down), + ] + .into_iter() + .min_by(|a, b| a.0.total_cmp(&b.0)) + .unwrap() + .1 +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn outer_rim_and_quadrant_drops_have_distinct_preview_targets() { + let area = nebula_split::Rect::new(0.0, 0.0, 1200.0, 800.0); + let panes = [ + (1, nebula_split::Rect::new(0.0, 0.0, 599.0, 399.0)), + (2, nebula_split::Rect::new(601.0, 0.0, 599.0, 399.0)), + (3, nebula_split::Rect::new(0.0, 401.0, 599.0, 399.0)), + (4, nebula_split::Rect::new(601.0, 401.0, 599.0, 399.0)), + ]; + assert_eq!( + dock_target_at(area, &panes, 610.0, 600.0), + Some(DockTarget { pane: Some(4), nav: SplitNav::Left }) + ); + assert_eq!( + dock_target_at(area, &panes, 1195.0, 600.0), + Some(DockTarget { pane: None, nav: SplitNav::Right }) + ); + assert_eq!(dock_target_at(area, &panes, 1250.0, 600.0), None); + } + + #[test] + fn direction_is_relative_to_the_hovered_pane() { + let pane = nebula_split::Rect::new(600.0, 400.0, 600.0, 400.0); + assert_eq!(nearest_edge(pane, 610.0, 600.0), SplitNav::Left); + assert_eq!(nearest_edge(pane, 900.0, 410.0), SplitNav::Up); + assert_eq!(nearest_edge(pane, 1190.0, 600.0), SplitNav::Right); + assert_eq!(nearest_edge(pane, 900.0, 790.0), SplitNav::Down); + } +} diff --git a/nebula_app/src/gpui_shell/workspace/windowing.rs b/nebula_app/src/gpui_shell/workspace/windowing.rs index 7e5fe02..b6f2d87 100644 --- a/nebula_app/src/gpui_shell/workspace/windowing.rs +++ b/nebula_app/src/gpui_shell/workspace/windowing.rs @@ -4,19 +4,29 @@ //! `NebulaWorkspace`。所有外部启动和 runtime 命令先在这里选择窗口,再把 //! 变更投递到对应 workspace,避免多个 receiver 竞争消费同一事件流。 +#[cfg(windows)] +mod quick_window; +#[cfg(windows)] +use quick_window::quick_terminal_anchor_display; +#[cfg(windows)] +pub(super) use quick_window::quick_terminal_bounds_changed; +#[cfg(windows)] +pub(crate) use quick_window::toggle_quick_terminal_window; + use std::cell::RefCell; use std::path::PathBuf; use std::rc::Rc; use std::sync::Arc; use std::time::Duration; +use super::DockTarget; use gpui::{ AnyWindowHandle, App, AppContext as _, Bounds, Context, Entity, Global, IntoElement, ParentElement as _, Render, SharedString, Styled as _, Subscription, WeakEntity, Window, WindowBounds, WindowOptions, div, point, px, size, }; use gpui_component::{ActiveTheme as _, Root, TitleBar}; -use nebula_split::{SplitNav, SplitTree}; +use nebula_split::SplitTree; use serde_json::json; use super::session_persistence::{SaveReason, SessionPersistence, combine_sessions}; @@ -121,6 +131,8 @@ pub(crate) struct WindowRegistry { session_persistence: SessionPersistence, #[cfg(windows)] quick_terminal: Option, + #[cfg(windows)] + quick_size_dirty: Option, _subscriptions: Vec, } @@ -145,7 +157,7 @@ impl CrossWindowTabDrag { #[derive(Clone, Copy, Debug, PartialEq)] pub(crate) struct CrossWindowDropDestination { pub(crate) window_id: u64, - pub(crate) dock: Option, + pub(crate) dock: Option, } #[cfg(windows)] @@ -194,10 +206,14 @@ pub(crate) fn initialize(cx: &mut App, runtime_hub: crate::runtime_api::RuntimeH session_persistence: SessionPersistence::default(), #[cfg(windows)] quick_terminal: None, + #[cfg(windows)] + quick_size_dirty: None, _subscriptions: Vec::new(), }); let quit_subscription = cx.on_app_quit(|cx| { + #[cfg(windows)] + quick_window::persist_quick_size(cx); save_combined_session(cx, true); async {} }); @@ -248,6 +264,32 @@ pub(crate) fn open_initial_window( .expect("failed to open Pebrel GPUI window"); } +pub(super) fn open_recipe_window(session: crate::session::Session, cx: &mut App) { + let result = + open_workspace_window(cx, WorkspaceStartup::Empty, None, None, true, WindowRole::Regular); + let Ok((id, _)) = result else { + log::warn!("recipe window creation failed"); + return; + }; + let entry = cx + .global::() + .entries + .iter() + .find(|entry| entry.runtime_window_id == id) + .cloned(); + let Some(entry) = entry else { return }; + let _ = entry.handle.update(cx, |_, window, cx| { + let _ = entry.workspace.update(cx, |workspace, cx| { + for tab in &session.tabs { + workspace.restore_tab(tab, false, window, cx); + } + workspace.active = session.active_tab.min(workspace.tabs.len().saturating_sub(1)); + workspace.focus_active(window, cx); + cx.notify(); + }); + }); +} + fn workspace_window_options(cx: &mut App, focus: bool, role: WindowRole) -> WindowOptions { match role { WindowRole::Regular => { @@ -270,10 +312,20 @@ fn workspace_window_options(cx: &mut App, focus: bool, role: WindowRole) -> Wind cx.primary_display().map(|display| (Some(display.id()), display.bounds())) }) .unwrap_or_else(|| (None, Bounds::centered(None, size(px(1080.0), px(720.0)), cx))); - let height = px((f32::from(visible.size.height) * 0.4).round().max(1.0)); + let remembered = nebula_settings::RuntimeSettings::load() + .quick_terminal_size + .map(|size| size.fit(visible.size.width.into(), visible.size.height.into())); + let width = remembered.map_or(visible.size.width, |size| px(size.width)); + let height = remembered.map_or_else( + || px((f32::from(visible.size.height) * 0.4).round().max(1.0)), + |size| px(size.height), + ); let bounds = Bounds { - origin: point(visible.origin.x, visible.origin.y - height), - size: size(visible.size.width, height), + origin: point( + visible.origin.x + (visible.size.width - width) * 0.5, + visible.origin.y - height, + ), + size: size(width, height), }; WindowOptions { window_bounds: Some(WindowBounds::Windowed(bounds)), @@ -920,190 +972,6 @@ fn focus_workspace_window(workspace: &mut NebulaWorkspace, window: &mut Window) window.activate_window(); } -/// 快速终端是独立窗口,不占用普通工作区的 MRU、runtime 路由或 session 恢复槽。 -/// 三态仍与旧壳一致:隐藏时显示并聚焦;可见但在后台时只聚焦;只有可见且 -/// 已在前台时才向上收起。 -#[cfg(windows)] -pub(crate) fn toggle_quick_terminal_window(cx: &mut App) { - prune_entries(cx); - let quick = cx.global::().quick_terminal.as_ref().map(|quick| { - ( - quick.handle, - quick.native_hwnd, - quick.geometry, - quick.target_visible, - quick.motion.is_active(), - ) - }); - let Some((handle, hwnd, geometry, target_visible, motion_active)) = quick else { - open_quick_terminal_window(cx); - return; - }; - - let is_active = handle.update(cx, |_, window, _| window.is_window_active()).unwrap_or(false); - if target_visible && !is_active { - // 用户从其它应用按热键时是召回,不是把仍可见的快速终端反向隐藏。 - let _ = handle.update(cx, |_, window, _| window.activate_window()); - return; - } - - if target_visible { - let registry = cx.global_mut::(); - let Some(quick) = registry.quick_terminal.as_mut() else { return }; - quick.motion_clock.reset(); - quick.target_visible = false; - quick.motion.animate_role(1.0, MotionRole::Exit, MotionPolicy::Full); - } else { - // 对齐旧壳:完整隐藏后从屏幕外的 1.0 位置重新开始;若用户在退场 - // 中反向切换,则保留当前进度,不跳回起点。 - if !motion_active { - if !super::quick_terminal::slide_native_window(hwnd, geometry, 1.0) { - log::warn!("quick terminal could not prepare its hidden position"); - let _ = handle.update(cx, |_, window, _| window.remove_window()); - cx.global_mut::().quick_terminal = None; - return; - } - super::quick_terminal::show_native_window(hwnd); - } - // 显式热键显示与旧壳 `focus_window` 相同,激活发生在动画开始前。 - let _ = handle.update(cx, |_, window, _| window.activate_window()); - let registry = cx.global_mut::(); - let Some(quick) = registry.quick_terminal.as_mut() else { return }; - quick.motion_clock.reset(); - if !motion_active { - quick.motion.snap_to(1.0); - } - quick.target_visible = true; - quick.motion.animate_role(0.0, MotionRole::Enter, MotionPolicy::Full); - } - start_quick_terminal_animation(cx); -} - -#[cfg(windows)] -fn quick_terminal_anchor_hwnd(cx: &mut App) -> isize { - entries_by_mru(cx).into_iter().next().map_or(0, |entry| entry.native_hwnd) -} - -#[cfg(windows)] -fn quick_terminal_anchor_display(cx: &mut App) -> Option<(gpui::DisplayId, Bounds)> { - let entry = entries_by_mru(cx).into_iter().next()?; - entry - .handle - .update(cx, |_, window, cx| { - window.display(cx).map(|display| (display.id(), display.bounds())) - }) - .ok() - .flatten() -} - -#[cfg(windows)] -fn open_quick_terminal_window(cx: &mut App) { - let anchor_hwnd = quick_terminal_anchor_hwnd(cx); - let Some(geometry) = super::quick_terminal::native_geometry(anchor_hwnd) else { - log::warn!("quick terminal could not resolve a target monitor"); - return; - }; - let opened = open_workspace_window( - cx, - WorkspaceStartup::NewTerminal { cwd: None }, - None, - None, - false, - WindowRole::QuickTerminal, - ); - let Ok((runtime_window_id, _)) = opened else { - log::warn!("quick terminal window creation failed: {}", opened.unwrap_err()); - return; - }; - let Some(entry) = cx - .global::() - .entries - .iter() - .find(|entry| entry.runtime_window_id == runtime_window_id) - .cloned() - else { - log::warn!("quick terminal window was not registered after creation"); - return; - }; - - // 首次显示前在屏幕外完成唯一一次 native 尺寸同步。窗口此时已经使用 - // QuickTerminal 的 GPUI bounds 构造,且 workspace 不再排队普通网格 - // resize;WM_SIZE/ResizeBuffers 会在用户看见任何像素之前完成。 - if !super::quick_terminal::configure_native_window(entry.native_hwnd, geometry, 1.0, true) { - log::warn!("quick terminal initial native configuration failed"); - let _ = entry.handle.update(cx, |_, window, _| window.remove_window()); - return; - } - - let mut motion = Tween::new(1.0); - motion.animate_role(0.0, MotionRole::Enter, MotionPolicy::Full); - cx.global_mut::().quick_terminal = Some(QuickTerminalWindow { - runtime_window_id, - handle: entry.handle, - native_hwnd: entry.native_hwnd, - geometry, - target_visible: true, - motion, - motion_clock: MotionClock::default(), - animation_generation: 0, - }); - let _ = entry.handle.update(cx, |_, window, _| window.activate_window()); - start_quick_terminal_animation(cx); -} - -#[cfg(windows)] -fn start_quick_terminal_animation(cx: &mut App) { - let (generation, handle) = { - let Some(quick) = cx.global_mut::().quick_terminal.as_mut() else { - return; - }; - quick.animation_generation = quick.animation_generation.wrapping_add(1); - (quick.animation_generation, quick.handle) - }; - let _ = handle.update(cx, move |_, window, _| { - window.on_next_frame(move |window, cx| { - quick_terminal_animation_frame(generation, window, cx); - }); - }); -} - -#[cfg(windows)] -fn quick_terminal_animation_frame(generation: u64, window: &mut Window, cx: &mut App) { - if quick_terminal_animation_tick(generation, window, cx) { - window.on_next_frame(move |window, cx| { - quick_terminal_animation_frame(generation, window, cx); - }); - } -} - -#[cfg(windows)] -fn quick_terminal_animation_tick(generation: u64, window: &mut Window, cx: &mut App) -> bool { - prune_entries(cx); - let frame = { - let Some(quick) = cx.global_mut::().quick_terminal.as_mut() else { - return false; - }; - if quick.animation_generation != generation { - return false; - } - let active = quick.motion.step(quick.motion_clock.tick()); - let frame = - (quick.native_hwnd, quick.geometry, quick.motion.value(), quick.target_visible, active); - frame - }; - let (hwnd, geometry, hidden, target_visible, active) = frame; - if !super::quick_terminal::slide_native_window(hwnd, geometry, hidden) { - log::warn!("quick terminal native positioning failed"); - window.remove_window(); - cx.global_mut::().quick_terminal = None; - return false; - } - if !active && !target_visible { - super::quick_terminal::hide_native_window(hwnd); - } - active -} - #[cfg(not(windows))] pub(crate) fn toggle_quick_terminal_window(cx: &mut App) { let target = entries_by_mru(cx).into_iter().next().map(|entry| (entry.handle, entry.workspace)); @@ -1334,6 +1202,8 @@ pub(crate) fn publish_runtime_snapshot_with_current( } pub(crate) fn autosave_tick(cx: &mut App) { + #[cfg(windows)] + quick_window::persist_quick_size(cx); save_combined_session(cx, false); } @@ -1528,7 +1398,7 @@ impl NebulaWorkspace { pub(crate) fn accept_cross_window_tab( &mut self, payload: &CrossWindowTabDrag, - dock: Option, + dock: Option, window: &mut Window, cx: &mut Context, ) -> bool { @@ -1614,14 +1484,13 @@ impl NebulaWorkspace { &mut self, tab: WorkspaceTab, meta: TabMeta, - dock: Option, + dock: Option, window: &mut Window, cx: &mut Context, ) { - let can_dock = dock.is_some() - && matches!(self.tabs.get(self.active), Some(WorkspaceTab::Terminal { .. })); + let can_dock = dock.is_some_and(|target| matches!(self.tabs.get(self.active), Some(WorkspaceTab::Terminal { tree, .. }) if target.pane.is_none_or(|pane| tree.contains(pane)))); if can_dock { - let Some(nav) = dock else { unreachable!("can_dock requires a direction") }; + let Some(target) = dock else { unreachable!("can_dock requires a direction") }; let WorkspaceTab::Terminal { panes: source_panes, tree: source_tree, @@ -1636,8 +1505,13 @@ impl NebulaWorkspace { else { unreachable!("can_dock checked the active terminal tab") }; - let target_tree = std::mem::replace(tree, SplitTree::leaf(source_focused)); - *tree = dock_tree(target_tree, source_tree, nav); + if let Some(pane) = target.pane { + tree.dock_at_leaf(pane, source_tree, target.nav) + .expect("destination checked above"); + } else { + let previous = std::mem::replace(tree, SplitTree::leaf(source_focused)); + *tree = previous.joined(source_tree, target.nav); + } panes.extend(source_panes); *focused = source_focused; *zoomed = false; diff --git a/nebula_app/src/gpui_shell/workspace/windowing/quick_window.rs b/nebula_app/src/gpui_shell/workspace/windowing/quick_window.rs new file mode 100644 index 0000000..4786098 --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/windowing/quick_window.rs @@ -0,0 +1,261 @@ +//! Dedicated quick-window lifecycle and explicit recall of regular windows. + +use super::*; + +/// Animation coordinates must never be mistaken for a user's window placement. +pub(in crate::gpui_shell::workspace) fn quick_terminal_bounds_changed( + id: u64, + window: &mut Window, + cx: &mut App, +) { + use windows_sys::Win32::Foundation::RECT; + use windows_sys::Win32::UI::WindowsAndMessaging::{GetWindowRect, IsIconic, IsZoomed}; + let Some(quick) = cx.global_mut::().quick_terminal.as_mut() else { return }; + if quick.runtime_window_id != id || !quick.target_visible || quick.motion.is_active() { + return; + } + let hwnd = quick.native_hwnd as *mut core::ffi::c_void; + let mut rect = RECT { left: 0, top: 0, right: 0, bottom: 0 }; + // Ignore minimization/maximization; only a normal user-resized window becomes the preference. + if unsafe { IsIconic(hwnd) != 0 || IsZoomed(hwnd) != 0 || GetWindowRect(hwnd, &mut rect) == 0 } + { + return; + } + let width = rect.right - rect.left; + let height = rect.bottom - rect.top; + let resized = quick.geometry.width != width || quick.geometry.height != height; + quick.geometry = super::super::quick_terminal::QuickTerminalGeometry { + x: rect.left, + y: rect.top, + width, + height, + }; + if resized { + let scale = window.scale_factor().max(1.0); + cx.global_mut::().quick_size_dirty = + nebula_settings::QuickTerminalSize::new(width as f32 / scale, height as f32 / scale); + } +} + +pub(super) fn persist_quick_size(cx: &mut App) { + let Some(size) = cx.global::().quick_size_dirty else { return }; + match nebula_settings::persist_keys(&[ + ("quick_terminal_width", size.width.to_string()), + ("quick_terminal_height", size.height.to_string()), + ]) { + Ok(()) => cx.global_mut::().quick_size_dirty = None, + Err(error) => log::warn!("could not save quick terminal size: {error}"), + } +} + +/// 快速终端是独立窗口,不占用普通工作区的 MRU、runtime 路由或 session 恢复槽。 +/// 三态仍与旧壳一致:隐藏时显示并聚焦;可见但在后台时只聚焦;只有可见且 +/// 已在前台时才向上收起。 +#[cfg(windows)] +pub(crate) fn toggle_quick_terminal_window(cx: &mut App) { + prune_entries(cx); + if nebula_settings::RuntimeSettings::load().quick_terminal_mode + == nebula_settings::QuickTerminalMode::Existing + { + if let Some(entry) = entries_by_mru(cx).into_iter().next() { + focus_entry(&entry, None, cx); + } else { + let _ = open_workspace_window( + cx, + WorkspaceStartup::NewTerminal { cwd: None }, + None, + None, + true, + WindowRole::Regular, + ); + } + return; + } + let quick = cx.global::().quick_terminal.as_ref().map(|quick| { + ( + quick.handle, + quick.native_hwnd, + quick.geometry, + quick.target_visible, + quick.motion.is_active(), + ) + }); + let Some((handle, hwnd, geometry, target_visible, motion_active)) = quick else { + open_quick_terminal_window(cx); + return; + }; + + let is_active = handle.update(cx, |_, window, _| window.is_window_active()).unwrap_or(false); + if target_visible && !is_active { + // 用户从其它应用按热键时是召回,不是把仍可见的快速终端反向隐藏。 + let _ = handle.update(cx, |_, window, _| window.activate_window()); + return; + } + + if target_visible { + let registry = cx.global_mut::(); + let Some(quick) = registry.quick_terminal.as_mut() else { return }; + quick.motion_clock.reset(); + quick.target_visible = false; + quick.motion.animate_role(1.0, MotionRole::Exit, MotionPolicy::Full); + } else { + // 对齐旧壳:完整隐藏后从屏幕外的 1.0 位置重新开始;若用户在退场 + // 中反向切换,则保留当前进度,不跳回起点。 + if !motion_active { + if !super::super::quick_terminal::slide_native_window(hwnd, geometry, 1.0) { + log::warn!("quick terminal could not prepare its hidden position"); + let _ = handle.update(cx, |_, window, _| window.remove_window()); + cx.global_mut::().quick_terminal = None; + return; + } + super::super::quick_terminal::show_native_window(hwnd); + } + // 显式热键显示与旧壳 `focus_window` 相同,激活发生在动画开始前。 + let _ = handle.update(cx, |_, window, _| window.activate_window()); + let registry = cx.global_mut::(); + let Some(quick) = registry.quick_terminal.as_mut() else { return }; + quick.motion_clock.reset(); + if !motion_active { + quick.motion.snap_to(1.0); + } + quick.target_visible = true; + quick.motion.animate_role(0.0, MotionRole::Enter, MotionPolicy::Full); + } + start_quick_terminal_animation(cx); +} + +#[cfg(windows)] +fn quick_terminal_anchor_hwnd(cx: &mut App) -> isize { + entries_by_mru(cx).into_iter().next().map_or(0, |entry| entry.native_hwnd) +} + +#[cfg(windows)] +pub(super) fn quick_terminal_anchor_display( + cx: &mut App, +) -> Option<(gpui::DisplayId, Bounds)> { + let entry = entries_by_mru(cx).into_iter().next()?; + entry + .handle + .update(cx, |_, window, cx| { + window.display(cx).map(|display| (display.id(), display.bounds())) + }) + .ok() + .flatten() +} + +#[cfg(windows)] +fn open_quick_terminal_window(cx: &mut App) { + persist_quick_size(cx); + let anchor_hwnd = quick_terminal_anchor_hwnd(cx); + let Some(geometry) = super::super::quick_terminal::native_geometry( + anchor_hwnd, + nebula_settings::RuntimeSettings::load().quick_terminal_size, + ) else { + log::warn!("quick terminal could not resolve a target monitor"); + return; + }; + let opened = open_workspace_window( + cx, + WorkspaceStartup::NewTerminal { cwd: None }, + None, + None, + false, + WindowRole::QuickTerminal, + ); + let Ok((runtime_window_id, _)) = opened else { + log::warn!("quick terminal window creation failed: {}", opened.unwrap_err()); + return; + }; + let Some(entry) = cx + .global::() + .entries + .iter() + .find(|entry| entry.runtime_window_id == runtime_window_id) + .cloned() + else { + log::warn!("quick terminal window was not registered after creation"); + return; + }; + + // 首次显示前在屏幕外完成唯一一次 native 尺寸同步。窗口此时已经使用 + // QuickTerminal 的 GPUI bounds 构造,且 workspace 不再排队普通网格 + // resize;WM_SIZE/ResizeBuffers 会在用户看见任何像素之前完成。 + if !super::super::quick_terminal::configure_native_window( + entry.native_hwnd, + geometry, + 1.0, + true, + ) { + log::warn!("quick terminal initial native configuration failed"); + let _ = entry.handle.update(cx, |_, window, _| window.remove_window()); + return; + } + + let mut motion = Tween::new(1.0); + motion.animate_role(0.0, MotionRole::Enter, MotionPolicy::Full); + cx.global_mut::().quick_terminal = Some(QuickTerminalWindow { + runtime_window_id, + handle: entry.handle, + native_hwnd: entry.native_hwnd, + geometry, + target_visible: true, + motion, + motion_clock: MotionClock::default(), + animation_generation: 0, + }); + let _ = entry.handle.update(cx, |_, window, _| window.activate_window()); + start_quick_terminal_animation(cx); +} + +#[cfg(windows)] +fn start_quick_terminal_animation(cx: &mut App) { + let (generation, handle) = { + let Some(quick) = cx.global_mut::().quick_terminal.as_mut() else { + return; + }; + quick.animation_generation = quick.animation_generation.wrapping_add(1); + (quick.animation_generation, quick.handle) + }; + let _ = handle.update(cx, move |_, window, _| { + window.on_next_frame(move |window, cx| { + quick_terminal_animation_frame(generation, window, cx); + }); + }); +} + +#[cfg(windows)] +fn quick_terminal_animation_frame(generation: u64, window: &mut Window, cx: &mut App) { + if quick_terminal_animation_tick(generation, window, cx) { + window.on_next_frame(move |window, cx| { + quick_terminal_animation_frame(generation, window, cx); + }); + } +} + +#[cfg(windows)] +fn quick_terminal_animation_tick(generation: u64, window: &mut Window, cx: &mut App) -> bool { + prune_entries(cx); + let frame = { + let Some(quick) = cx.global_mut::().quick_terminal.as_mut() else { + return false; + }; + if quick.animation_generation != generation { + return false; + } + let active = quick.motion.step(quick.motion_clock.tick()); + let frame = + (quick.native_hwnd, quick.geometry, quick.motion.value(), quick.target_visible, active); + frame + }; + let (hwnd, geometry, hidden, target_visible, active) = frame; + if !super::super::quick_terminal::slide_native_window(hwnd, geometry, hidden) { + log::warn!("quick terminal native positioning failed"); + window.remove_window(); + cx.global_mut::().quick_terminal = None; + return false; + } + if !active && !target_visible { + super::super::quick_terminal::hide_native_window(hwnd); + } + active +} diff --git a/nebula_settings/src/lib.rs b/nebula_settings/src/lib.rs index e888f4d..6a7882f 100644 --- a/nebula_settings/src/lib.rs +++ b/nebula_settings/src/lib.rs @@ -18,7 +18,11 @@ use std::collections::HashMap; mod app_icon; pub use app_icon::{AppIconName, AppIconPalette}; mod language; +mod quick_terminal; +mod themes; pub use language::{LanguageInfo, LanguagePref}; +pub use quick_terminal::{QuickTerminalMode, QuickTerminalSize}; +pub use themes::FreshPalette; mod reset; pub use reset::restore_default_settings; mod paths; @@ -196,6 +200,10 @@ pub enum ThemeName { Nord, /// 浅色:暖纸面 Paper 配色。跟随系统时作为 [`Self::Nord`] 的浅色成员。 Paper, + BreezeLight, + BreezeDark, + MintLight, + MintDark, } impl ThemeName { @@ -210,6 +218,11 @@ impl ThemeName { "MossDark" => Self::MossDark, "Nord" => Self::Nord, "Paper" => Self::Paper, + "BreezeLight" => Self::BreezeLight, + "BreezeDark" => Self::BreezeDark, + "MintLight" => Self::MintLight, + "MintDark" => Self::MintDark, + _ => return None, }) } @@ -225,6 +238,10 @@ impl ThemeName { Self::MossDark => "MossDark", Self::Nord => "Nord", Self::Paper => "Paper", + Self::BreezeLight => "BreezeLight", + Self::BreezeDark => "BreezeDark", + Self::MintLight => "MintLight", + Self::MintDark => "MintDark", } } @@ -233,6 +250,9 @@ impl ThemeName { // 背景与 is_light 来自各主题 palette();powerline 为提示符段色 // (icon bg/fg、path bg/fg、branch bg/fg、time bg/fg)。 match self { + Self::BreezeLight | Self::BreezeDark | Self::MintLight | Self::MintDark => { + themes::fresh_terminal(self) + }, Self::Nebula => TermTheme { background: [15, 17, 26], is_light: false, @@ -978,6 +998,9 @@ pub struct RuntimeSettings { pub ssh_proxy_url: String, pub ssh_proxy_no_proxy: String, pub quick_terminal_hotkey: String, + pub quick_terminal_mode: QuickTerminalMode, + /// Remembered logical size; independent of monitor DPI. + pub quick_terminal_size: Option, /// 终端卡圆角(逻辑像素)。`None` = 跟随主题自带的几何 /// (见 [`ThemeName::card_geometry`])。两壳同径:旧壳的 /// `UI_SHELL_RADIUS_LOGICAL` 直接引用 [`DEFAULT_PANE_CARD_RADIUS`], @@ -1142,10 +1165,19 @@ impl RuntimeSettings { .unwrap_or_default(), ssh_proxy_url: raw.value("ssh_proxy_url").unwrap_or_default().to_owned(), ssh_proxy_no_proxy: raw.value("ssh_proxy_no_proxy").unwrap_or_default().to_owned(), + quick_terminal_mode: raw + .value("quick_terminal_mode") + .and_then(QuickTerminalMode::from_settings) + .unwrap_or_default(), + quick_terminal_size: raw + .f32("quick_terminal_width") + .zip(raw.f32("quick_terminal_height")) + .and_then(|(w, h)| QuickTerminalSize::new(w, h)), quick_terminal_hotkey: raw - .value("quick_terminal_hotkey") - .unwrap_or(DEFAULT_QUICK_TERMINAL_HOTKEY) - .to_owned(), + .values + .get("quick_terminal_hotkey") + .cloned() + .unwrap_or_else(|| DEFAULT_QUICK_TERMINAL_HOTKEY.to_owned()), // 这四项一律保留 `Option`:`None` 就是「用户没设过」,让 // `ThemeName::card_geometry` 的主题默认生效。若在这里就 unwrap 成 // 具体数字,切主题便再也换不了形态——那正是要避免的。 diff --git a/nebula_settings/src/quick_terminal.rs b/nebula_settings/src/quick_terminal.rs new file mode 100644 index 0000000..e96d06f --- /dev/null +++ b/nebula_settings/src/quick_terminal.rs @@ -0,0 +1,76 @@ +//! Persisted quick-terminal preferences; native monitor/window adapters live in the UI. + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum QuickTerminalMode { + #[default] + Dedicated, + Existing, +} + +impl QuickTerminalMode { + pub fn from_settings(value: &str) -> Option { + match value { + "dedicated" => Some(Self::Dedicated), + "existing" => Some(Self::Existing), + _ => None, + } + } + + pub const fn settings_value(self) -> &'static str { + match self { + Self::Dedicated => "dedicated", + Self::Existing => "existing", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct QuickTerminalSize { + pub width: f32, + pub height: f32, +} + +impl QuickTerminalSize { + pub fn new(width: f32, height: f32) -> Option { + (width.is_finite() + && height.is_finite() + && width >= 320.0 + && height >= 160.0 + && width <= 16384.0 + && height <= 16384.0) + .then_some(Self { width, height }) + } + + pub fn fit(self, width: f32, height: f32) -> Self { + Self { width: self.width.min(width.max(1.0)), height: self.height.min(height.max(1.0)) } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{RawSettings, RuntimeSettings}; + + #[test] + fn preferences_round_trip_and_invalid_dimensions_fall_back() { + let raw = RawSettings::from_text( + "quick_terminal_mode=existing\nquick_terminal_width=900\nquick_terminal_height=400\n", + ); + let settings = RuntimeSettings::from_raw(&raw); + assert_eq!(settings.quick_terminal_mode, QuickTerminalMode::Existing); + assert_eq!(settings.quick_terminal_size, QuickTerminalSize::new(900.0, 400.0)); + assert_eq!(QuickTerminalMode::Existing.settings_value(), "existing"); + assert!(QuickTerminalSize::new(f32::NAN, 400.0).is_none()); + assert!(QuickTerminalSize::new(0.0, 400.0).is_none()); + assert!(QuickTerminalSize::new(900.0, f32::INFINITY).is_none()); + let settings = RuntimeSettings::from_raw(&RawSettings::from_text( + "quick_terminal_mode=unknown\nquick_terminal_width=900\n", + )); + assert_eq!(settings.quick_terminal_mode, QuickTerminalMode::Dedicated); + assert_eq!(settings.quick_terminal_size, None); + assert_eq!( + QuickTerminalSize::new(1800.0, 900.0).unwrap().fit(1280.0, 720.0), + QuickTerminalSize { width: 1280.0, height: 720.0 } + ); + } +} diff --git a/nebula_settings/src/reset.rs b/nebula_settings/src/reset.rs index fd5007e..7d28162 100644 --- a/nebula_settings/src/reset.rs +++ b/nebula_settings/src/reset.rs @@ -53,6 +53,9 @@ const RESET_KEYS: &[&str] = &[ "ssh_proxy_url", "ssh_proxy_no_proxy", "quick_terminal_hotkey", + "quick_terminal_mode", + "quick_terminal_width", + "quick_terminal_height", "pane_card_radius", "pane_card_gutter", "pane_card_shadow", diff --git a/nebula_settings/src/themes.rs b/nebula_settings/src/themes.rs new file mode 100644 index 0000000..293353f --- /dev/null +++ b/nebula_settings/src/themes.rs @@ -0,0 +1,109 @@ +//! Four quiet built-in palettes, shared by terminal and chrome adapters. +use crate::{ExactTermColors, Rgb8, TermTheme, ThemeName}; + +#[derive(Clone, Copy, Debug)] +pub struct FreshPalette { + pub shell: Rgb8, + pub surface: Rgb8, + pub accent: Rgb8, + pub foreground: Rgb8, + pub muted: Rgb8, + pub is_light: bool, +} + +impl ThemeName { + pub fn fresh_palette(self) -> Option { + let (shell, surface, accent, foreground, muted, is_light) = match self { + Self::BreezeLight => (0xeaf0f6, 0xf9fbff, 0x306592, 0x26384a, 0x576c80, true), + Self::BreezeDark => (0x18232e, 0x202e3b, 0x91bcdf, 0xe0eaf3, 0xa5b6c7, false), + Self::MintLight => (0xeaf2ee, 0xf8fcf9, 0x28745e, 0x263c33, 0x587365, true), + Self::MintDark => (0x192a26, 0x213730, 0x8bcbb3, 0xdfeee7, 0xa6bfb2, false), + _ => return None, + }; + Some(FreshPalette { + shell: rgb(shell), + surface: rgb(surface), + accent: rgb(accent), + foreground: rgb(foreground), + muted: rgb(muted), + is_light, + }) + } +} + +const fn rgb(value: u32) -> Rgb8 { + [(value >> 16) as u8, (value >> 8) as u8, value as u8] +} + +pub(crate) fn fresh_terminal(name: ThemeName) -> TermTheme { + let palette = name.fresh_palette().expect("fresh theme"); + let ansi = if palette.is_light { + [ + 0x26384a, 0xa34248, 0x28745e, 0x88621f, 0x306592, 0x77569b, 0x247782, 0x526375, + 0x667788, 0xb13d48, 0x247052, 0x826013, 0x295e9b, 0x80529a, 0x19717d, 0x34485c, + ] + } else { + [ + 0x263b49, 0xe49a9d, 0x9bc5a6, 0xe0c38b, 0x91bcdf, 0xc5addd, 0x91cecf, 0xdfeaf1, + 0x90a5b5, 0xf0b2b3, 0xb1dcc0, 0xeed4a5, 0xb0d1ed, 0xd9c4ed, 0xb0e1dc, 0xf2f7fa, + ] + } + .map(rgb); + TermTheme { + background: palette.surface, + is_light: palette.is_light, + exact: Some(ExactTermColors { + foreground: palette.foreground, + ansi, + cursor: Some(palette.accent), + cursor_text: Some(palette.surface), + cursor_stroke: Some(palette.accent), + selection_foreground: Some(palette.foreground), + selection_background: Some(palette.shell), + }), + powerline: [ + palette.accent, + palette.surface, + palette.shell, + palette.foreground, + palette.shell, + palette.accent, + palette.surface, + palette.muted, + ], + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn fresh_themes_round_trip_and_keep_readable_foreground() { + for name in [ + ThemeName::BreezeLight, + ThemeName::BreezeDark, + ThemeName::MintLight, + ThemeName::MintDark, + ] { + assert_eq!(ThemeName::from_prompt_name(name.prompt_name()), Some(name)); + let palette = name.fresh_palette().unwrap(); + let terminal = name.term_theme(); + assert_eq!(terminal.background, palette.surface); + assert_eq!(terminal.is_light, palette.is_light); + let luma = |color: Rgb8| { + color + .into_iter() + .zip([0.2126, 0.7152, 0.0722]) + .map(|(c, weight)| { + let c = f64::from(c) / 255.0; + (if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }) + * weight + }) + .sum::() + }; + let a = luma(palette.foreground); + let b = luma(palette.surface); + assert!((a.max(b) + 0.05) / (a.min(b) + 0.05) >= 7.0, "{}", name.prompt_name()); + } + } +} diff --git a/nebula_split/src/dock.rs b/nebula_split/src/dock.rs new file mode 100644 index 0000000..ceee29c --- /dev/null +++ b/nebula_split/src/dock.rs @@ -0,0 +1,76 @@ +//! Graft an existing pane tree beside one leaf, preserving unrelated splits. +use super::*; + +impl SplitTree { + pub fn joined(self, source: Self, side: SplitNav) -> Self { + let (direction, before) = match side { + SplitNav::Left => (SplitDirection::LeftRight, true), + SplitNav::Right => (SplitDirection::LeftRight, false), + SplitNav::Up => (SplitDirection::TopBottom, true), + SplitNav::Down => (SplitDirection::TopBottom, false), + }; + let (first, second) = if before { (source, self) } else { (self, source) }; + Self::Split { + direction, + ratio: 0.5, + preview_ratio: None, + dragging: false, + first: Box::new(first), + second: Box::new(second), + } + } + + /// Return the source unchanged if the destination disappeared during the gesture. + pub fn dock_at_leaf(&mut self, target: T, source: Self, side: SplitNav) -> Result<(), Self> { + match self { + Self::Leaf(id) if *id == target => { + let previous = std::mem::replace(self, Self::Leaf(target)); + *self = previous.joined(source, side); + Ok(()) + }, + Self::Split { first, second, .. } => match first.dock_at_leaf(target, source, side) { + Ok(()) => Ok(()), + Err(source) => second.dock_at_leaf(target, source, side), + }, + _ => Err(source), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fifth_pane_only_splits_the_hovered_quadrant() { + let mut tree = SplitTree::leaf(1).joined(SplitTree::leaf(2), SplitNav::Right); + tree.split_leaf(1, 3, SplitDirection::TopBottom, 0.4); + tree.split_leaf(2, 4, SplitDirection::TopBottom, 0.6); + let viewport = Rect::new(0.0, 0.0, 1200.0, 800.0); + let before = tree.layout(viewport, 1.0, 1.0, DIVIDER_GAP, false).panes; + tree.dock_at_leaf(4, SplitTree::leaf(5), SplitNav::Right).unwrap(); + let after = tree.layout(viewport, 1.0, 1.0, DIVIDER_GAP, false).panes; + for id in [1, 2, 3] { + assert_eq!( + before.iter().find(|(pane, _)| *pane == id), + after.iter().find(|(pane, _)| *pane == id) + ); + } + let target = before.iter().find(|(pane, _)| *pane == 4).unwrap().1; + let dropped = after.iter().find(|(pane, _)| *pane == 5).unwrap().1; + assert_eq!(dropped.h, target.h); + assert_eq!(dropped.y, target.y); + assert!(dropped.w < target.w && dropped.x > target.x); + let original = tree.clone(); + assert!(tree.dock_at_leaf(99, SplitTree::leaf(6), SplitNav::Down).is_err()); + assert_eq!(tree.leaves(), original.leaves()); + } + + #[test] + fn docking_preserves_a_multi_pane_source() { + let source = SplitTree::leaf(7).joined(SplitTree::leaf(8), SplitNav::Down); + let mut target = SplitTree::leaf(1).joined(SplitTree::leaf(2), SplitNav::Right); + target.dock_at_leaf(2, source, SplitNav::Up).unwrap(); + assert_eq!(target.leaves(), [1, 7, 8, 2]); + } +} diff --git a/nebula_split/src/lib.rs b/nebula_split/src/lib.rs index 44ff7b9..5f3cd2c 100644 --- a/nebula_split/src/lib.rs +++ b/nebula_split/src/lib.rs @@ -13,6 +13,8 @@ use std::mem; +mod dock; + /// 可见分隔条厚度(逻辑像素;使用处乘以 scale 后取整,至少 1 物理像素)。 pub const DIVIDER_GAP: f32 = 2.0; /// 分隔条命中扩边(逻辑像素):视觉线很细,抓取目标刻意加宽。