feat(workspace): add file editing, layout recipes and fresh themes

File tabs now save Markdown, code and text edits through one document
lifecycle, with dirty indicators, close protection, external-change checks,
and BOM/newline preservation. Markdown gains heading navigation and file
information alongside preview and source editing.

Add named tab/window layout recipes using the existing session schema,
remember quick-terminal dimensions, and allow the global hotkey to recall
an existing window. Dock previews and insertion use the hovered pane, with
an outer-edge target for a full row or column.

Add Breeze and Mint light/dark themes, filter settings navigation as the
query changes, expose a font dropdown arrow, and redraw the command icon.

Validated on the isolated commit snapshot with Windows GPUI compilation,
editor and settings interaction tests, workspace/theme tests, shared
settings/split and i18n contracts, architecture checks, and formatting.
This commit is contained in:
kuddev committed 2026-09-08 10:51:21 +08:00
1 parent 9cb027e2dd
commit 3a1572b7f6
39 files changed
+3377 -870

No files matched your search

+40
View File
@@ -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.
@@ -1,6 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 2 3 12h4l-1 9 7-12H9l3-7Z"/>
<path d="M15 7h6"/>
<path d="M15 12h6"/>
<path d="M15 17h4"/>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="16" rx="3"/>
<path d="m7 9 3 3-3 3m6 0h4"/>
</svg>

Before

Width:  |  Height:  |  Size: 300 B

After

Width:  |  Height:  |  Size: 277 B

+68 -2
View File
@@ -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…"
}
}
+68 -2
View File
@@ -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": "打开布局配方…"
}
}
+1 -9
View File
@@ -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",
+79 -5
View File
@@ -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<nebula_settings::ExactTermColors> {
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::FreshPalette> {
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),
+45 -108
View File
@@ -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<CodeTabViewEvent> for CodeTabView {}
@@ -134,11 +136,13 @@ struct ConflictDocument {
pub struct CodeTabView {
pub path: PathBuf,
pub title: String,
/// 普通文件的正文;冲突页中它就是唯一可编辑的中栏合并结果。
/// 冲突页的中栏合并结果;普通文件的缓冲区由 TextFileView 持有。
input: Entity<InputState>,
notice: Option<String>,
lines: usize,
merge: Option<MergeEditor>,
file: Option<Entity<super::file_editor::TextFileView>>,
_file_subscription: Option<gpui::Subscription>,
}
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<Entity<super::file_editor::TextFileView>> {
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<Self>) {
@@ -328,90 +347,8 @@ impl CodeTabView {
cx.notify();
}
fn render_file(&self, cx: &mut Context<Self>) -> 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<Self>) -> gpui::AnyElement {
div().size_full().children(self.file.clone()).into_any_element()
}
fn render_merge(&self, cx: &mut Context<Self>) -> gpui::AnyElement {
+6 -193
View File
@@ -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<String>,
/// 必须保留组件的 managed state:真实鼠标选区、跨块文本提取和 Ctrl+C
/// 都由 TextView 维护,宿主只在右键时读取已经成立的选区。
text_view: gpui::Entity<TextViewState>,
}
pub enum DocTabViewEvent {
SelectionContextMenuRequested { position: Point<Pixels>, text: String },
}
impl DocTabView {
pub fn new(path: PathBuf, cx: &mut Context<Self>) -> 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<Self>) {
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<Self>) {
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<DocTabViewEvent> for DocTabView {}
fn load_doc_content(path: &Path) -> (SharedString, Option<String>) {
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<Self>) -> 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<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r#"(!\[[^\]]*\]\()([^)\s]+)"#).unwrap());
// `regex` crate 不支持反向引用 `\2`,双引号/单引号拆开写。
static HTML_IMG_DQ: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r#"(?i)(<img\b[^>]*?\bsrc\s*=\s*")([^"]+)""#).unwrap());
static HTML_IMG_SQ: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r#"(?i)(<img\b[^>]*?\bsrc\s*=\s*')([^']+)'"#).unwrap());
let rewritten = MARKDOWN_IMG.replace_all(text, |caps: &regex::Captures<'_>| {
format!("{}{}", &caps[1], flatten_local_image_url(&caps[2], base))
});
let rewritten = HTML_IMG_DQ.replace_all(&rewritten, |caps: &regex::Captures<'_>| {
format!(r#"{}{}""#, &caps[1], flatten_local_image_url(&caps[2], base))
});
HTML_IMG_SQ
.replace_all(&rewritten, |caps: &regex::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};
@@ -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<std::time::SystemTime>,
}
#[derive(Debug)]
pub(super) enum SaveError {
Changed,
ReadOnly,
Io(std::io::Error),
}
impl From<std::io::Error> for SaveError {
fn from(error: std::io::Error) -> Self {
Self::Io(error)
}
}
impl Document {
pub(super) fn load(path: &Path) -> std::io::Result<Self> {
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<Self, SaveError> {
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);
}
}
@@ -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<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r#"(!\[[^\]]*\]\()([^)\s]+)"#).unwrap());
// `regex` crate 不支持反向引用 `\2`,双引号/单引号拆开写。
static HTML_IMG_DQ: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r#"(?i)(<img\b[^>]*?\bsrc\s*=\s*")([^"]+)""#).unwrap());
static HTML_IMG_SQ: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r#"(?i)(<img\b[^>]*?\bsrc\s*=\s*')([^']+)'"#).unwrap());
let rewritten = MARKDOWN_IMG.replace_all(text, |caps: &regex::Captures<'_>| {
format!("{}{}", &caps[1], flatten_local_image_url(&caps[2], base))
});
let rewritten = HTML_IMG_DQ.replace_all(&rewritten, |caps: &regex::Captures<'_>| {
format!(r#"{}{}""#, &caps[1], flatten_local_image_url(&caps[2], base))
});
HTML_IMG_SQ
.replace_all(&rewritten, |caps: &regex::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('\\', "/")
}
@@ -0,0 +1,118 @@
use super::*;
impl TextFileView {
pub(super) fn render_info(&self, cx: &mut Context<Self>) -> 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<chrono::Local> = 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()
}
}
@@ -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<Pixels>, text: String },
}
pub struct TextFileView {
pub path: PathBuf,
pub title: String,
input: Entity<InputState>,
focus: FocusHandle,
document: Option<Document>,
dirty: bool,
loading: bool,
saving: bool,
notice: Option<(Message, Option<String>)>,
markdown: bool,
preview: bool,
show_details: bool,
info: bool,
outline: Outline,
blocks: Vec<Entity<TextViewState>>,
scroll: ListState,
selected_heading: Option<usize>,
all_selected: bool,
revision: u64,
preview_task: Option<Task<()>>,
_input_subscription: Subscription,
}
impl EventEmitter<TextFileEvent> 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>) -> 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<Self>) {
if self.dirty || self.saving || self.loading {
return;
}
self.load(window, cx);
}
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
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<Self>) {
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<Self>) -> Task<bool> {
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>) {
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>) {
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<Self>) {
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<Self>) -> 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<Self>) -> 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::<Vec<_>>()
.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)
})
},
))
}
}
@@ -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<Heading>,
pub(super) blocks: Vec<String>,
}
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::<Vec<_>>()
.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<Heading>) {
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::<Vec<_>>(),
[("标题 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"));
}
}
@@ -0,0 +1,87 @@
use super::*;
use gpui::{TestAppContext, VisualTestContext};
use gpui_component::Root;
fn open(path: PathBuf, cx: &mut TestAppContext) -> (Entity<TextFileView>, 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"
);
}
+2
View File
@@ -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);
+14 -2
View File
@@ -96,7 +96,7 @@ pub struct SettingsPane {
about_update_seq: u64,
about_last_checked: Option<String>,
settings_search_input: Entity<InputState>,
settings_search_trigger_bounds: Option<gpui::Bounds<gpui::Pixels>>,
search_origin_section: Option<usize>,
/// 每项还带着自己的 `values` 表:`SelectState` 只认索引,而从代码侧
/// 改设置(还原默认值、命令面板切换)时手里只有配置文件记号,没有
/// 这张表就没法把闭框的选中项拉回去。
@@ -1130,6 +1130,18 @@ impl SettingsPane {
fn section_content(&mut self, window: &mut Window, cx: &mut Context<Self>) -> 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()
@@ -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));
}
@@ -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);
}
}
@@ -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<InputState>,
event: &InputEvent,
_: &mut Window,
cx: &mut Context<Self>| {
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,
@@ -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 (_)"),
@@ -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,
}
}
@@ -1,68 +1,37 @@
use super::*;
impl SettingsPane {
pub(super) fn render_search_header(
&self,
window: &Window,
cx: &Context<Self>,
) -> 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<usize> = 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));
pub(super) fn matching_settings_sections(&self, cx: &App) -> Vec<usize> {
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<Self>) {
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();
}))
.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()
}
pub(super) fn render_search_header(&self, _: &Window, cx: &Context<Self>) -> gpui::AnyElement {
let language = crate::gpui_shell::config::ui_language(cx);
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<usize> {
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::<Vec<_>>());
assert!(!matching_sections("AI", en).contains(&3));
}
}
@@ -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()
+12
View File
@@ -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);
}
+52 -209
View File
@@ -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<KeyBinding> {
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> {
[
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<u64>, source: SplitTree<u64>, nav: SplitNav) -> SplitTree<u64> {
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<SharedString>,
}
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<pane_header::PaneDrag>,
/// 其它窗口的标签悬停在本窗口终端区时的跨窗 dock 预览。
cross_window_dock: Option<SplitNav>,
cross_window_dock: Option<DockTarget>,
/// 进行中的分隔条拖拽;预览比例写进树节点,松手提交(吸附整格)。
split_drag: Option<SplitDrag>,
/// 进行中的侧栏拖宽(设置「面板拖拽调节」开启时才有入口);宽度实时
@@ -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<Self>) -> 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<Self>,
) -> 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>,
) {
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<Self>,
) {
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<Self>,
) {
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<Self>,
) {
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<Self>,
) {
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<Self>,
) {
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<crate::gpui_shell::doc_tabs::DocTabView>,
event: &DocTabViewEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
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<crate::gpui_shell::code_tab::CodeTabView>,
event: &CodeTabViewEvent,
cx: &mut Context<Self>,
) {
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<Self>) {
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<Self>) {
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);
}))
@@ -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<Entity<TextFileView>> {
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<Self>,
) -> 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<Self>,
) -> 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<Entity<TextFileView>>,
whole_window: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
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::<Vec<_>>()
} 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>,
) {
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<Self>,
) {
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<Self>,
) {
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<Self>,
) {
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<Self>,
) {
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<Self>,
) {
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<crate::gpui_shell::doc_tabs::DocTabView>,
event: &DocTabViewEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
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<crate::gpui_shell::code_tab::CodeTabView>,
event: &CodeTabViewEvent,
cx: &mut Context<Self>,
) {
match event {
CodeTabViewEvent::Changed => cx.notify(),
CodeTabViewEvent::GitConflictResolved => {
self.side_panel.request_refresh();
cx.notify();
},
}
}
}
@@ -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);
@@ -42,7 +42,10 @@ impl QuickTerminalGeometry {
/// 以普通工作区 HWND 所在显示器为目标;没有锚点时 Win32 回退主显示器。
#[cfg(windows)]
pub(super) fn native_geometry(anchor_hwnd: isize) -> Option<QuickTerminalGeometry> {
pub(super) fn native_geometry(
anchor_hwnd: isize,
remembered: Option<nebula_settings::QuickTerminalSize>,
) -> Option<QuickTerminalGeometry> {
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<QuickTerminalGeometr
let width = (info.rcMonitor.right - info.rcMonitor.left).max(1);
let monitor_height = (info.rcMonitor.bottom - info.rcMonitor.top).max(1);
let height = ((monitor_height as f64) * 0.4).round().max(1.0) as i32;
Some(QuickTerminalGeometry { x: info.rcMonitor.left, y: info.rcMonitor.top, width, height })
let scale = if anchor_hwnd != 0 {
(unsafe { windows_sys::Win32::UI::HiDpi::GetDpiForWindow(anchor) }) as f32 / 96.0
} else {
1.0
};
let scale = scale.max(1.0);
let saved =
remembered.map(|size| size.fit(width as f32 / scale, monitor_height as f32 / scale));
let saved_width = saved.map_or(width, |size| (size.width * scale).round() as i32);
let saved_height = saved.map_or(height, |size| (size.height * scale).round() as i32);
Some(QuickTerminalGeometry {
x: info.rcMonitor.left + (width - saved_width) / 2,
y: info.rcMonitor.top,
width: saved_width,
height: saved_height,
})
}
/// 创建时一次性设置旧壳的完整几何和 topmost,再把窗口显示在屏幕上缘之外。
@@ -225,16 +243,14 @@ impl NebulaWorkspace {
/// 设置里持久化的组合键;缺失或非法时回落到与旧壳同一个默认值。
fn current_combo() -> 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<HotKey> {
if combo.trim().is_empty() {
return None;
}
let hotkey = match combo.parse::<HotKey>() {
Ok(hotkey) => hotkey,
Err(err) => {
@@ -257,10 +273,21 @@ fn register(manager: &GlobalHotKeyManager, combo: &str) -> Option<HotKey> {
fn resync_combo(cx: &mut App) {
let latest = current_combo();
let state = cx.global::<QuickTerminalHotkey>();
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::<QuickTerminalHotkey>();
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() {
@@ -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<Self>) {
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<InputState>,
window_snapshot: Session,
tab_snapshot: Option<Session>,
current_tab: bool,
entries: Vec<store::Recipe>,
busy: bool,
error: Option<String>,
}
impl EventEmitter<Session> for RecipeLibrary {}
impl RecipeLibrary {
fn new(
window_snapshot: Session,
tab_snapshot: Option<Session>,
window: &mut Window,
cx: &mut Context<Self>,
) -> 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>) {
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<Self>) {
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<Self>) {
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<Self>) -> 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::<super::super::SplitDown>());
let (recipe, _) = keymap
.bindings_for_input(&[gpui::Keystroke::parse("ctrl-alt-shift-s").unwrap()], &contexts);
assert!(recipe[0].action().as_any().is::<OpenLayoutRecipes>());
}
}
@@ -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<Self, String> {
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<Vec<Recipe>, 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());
}
}
+118 -22
View File
@@ -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<SplitNav>,
pub(super) dock: Option<DockTarget>,
}
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<SplitNav> {
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<DockTarget> {
let areas = self.dock_pane_areas();
dock_target_at(self.active_terminal_area()?, &areas, x, y)
}
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
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![];
}
tree.layout(area, 1.0, 1.0, nebula_split::DIVIDER_GAP, false).panes
}
pub(super) fn dock_preview_area(&self, target: DockTarget) -> Option<nebula_split::Rect> {
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<Self>,
) {
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<u64>,
pub(crate) nav: SplitNav,
}
fn dock_target_at(
area: nebula_split::Rect,
panes: &[(u64, nebula_split::Rect)],
x: f32,
y: f32,
) -> Option<DockTarget> {
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);
}
}
+70 -196
View File
@@ -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<QuickTerminalWindow>,
#[cfg(windows)]
quick_size_dirty: Option<nebula_settings::QuickTerminalSize>,
_subscriptions: Vec<Subscription>,
}
@@ -145,7 +157,7 @@ impl CrossWindowTabDrag {
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct CrossWindowDropDestination {
pub(crate) window_id: u64,
pub(crate) dock: Option<SplitNav>,
pub(crate) dock: Option<DockTarget>,
}
#[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::<WindowRegistry>()
.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::<WindowRegistry>().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::<WindowRegistry>();
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::<WindowRegistry>().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::<WindowRegistry>();
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<gpui::Pixels>)> {
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::<WindowRegistry>()
.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::<WindowRegistry>().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::<WindowRegistry>().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::<WindowRegistry>().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::<WindowRegistry>().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<SplitNav>,
dock: Option<DockTarget>,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
@@ -1614,14 +1484,13 @@ impl NebulaWorkspace {
&mut self,
tab: WorkspaceTab,
meta: TabMeta,
dock: Option<SplitNav>,
dock: Option<DockTarget>,
window: &mut Window,
cx: &mut Context<Self>,
) {
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;
@@ -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::<WindowRegistry>().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::<WindowRegistry>().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::<WindowRegistry>().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::<WindowRegistry>().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::<WindowRegistry>().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::<WindowRegistry>();
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::<WindowRegistry>().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::<WindowRegistry>();
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<gpui::Pixels>)> {
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::<WindowRegistry>()
.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::<WindowRegistry>().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::<WindowRegistry>().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::<WindowRegistry>().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::<WindowRegistry>().quick_terminal = None;
return false;
}
if !active && !target_visible {
super::super::quick_terminal::hide_native_window(hwnd);
}
active
}
+35 -3
View File
@@ -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<QuickTerminalSize>,
/// 终端卡圆角(逻辑像素)。`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 成
// 具体数字,切主题便再也换不了形态——那正是要避免的。
+76
View File
@@ -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<Self> {
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<Self> {
(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 }
);
}
}
+3
View File
@@ -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",
+109
View File
@@ -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<FreshPalette> {
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::<f64>()
};
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());
}
}
}
+76
View File
@@ -0,0 +1,76 @@
//! Graft an existing pane tree beside one leaf, preserving unrelated splits.
use super::*;
impl<T: Copy + Eq> SplitTree<T> {
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]);
}
}
+2
View File
@@ -13,6 +13,8 @@
use std::mem;
mod dock;
/// 可见分隔条厚度(逻辑像素;使用处乘以 scale 后取整,至少 1 物理像素)。
pub const DIVIDER_GAP: f32 = 2.0;
/// 分隔条命中扩边(逻辑像素):视觉线很细,抓取目标刻意加宽。