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:
1 parent
9cb027e2dd
commit
3a1572b7f6
39 files changed
+3377
-870
No files matched your search
@@ -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 |
@@ -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…"
|
||||
}
|
||||
}
|
||||
@@ -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": "打开布局配方…"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: ®ex::Captures<'_>| {
|
||||
format!("{}{}", &caps[1], flatten_local_image_url(&caps[2], base))
|
||||
});
|
||||
let rewritten = HTML_IMG_DQ.replace_all(&rewritten, |caps: ®ex::Captures<'_>| {
|
||||
format!(r#"{}{}""#, &caps[1], flatten_local_image_url(&caps[2], base))
|
||||
});
|
||||
HTML_IMG_SQ
|
||||
.replace_all(&rewritten, |caps: ®ex::Captures<'_>| {
|
||||
format!("{}{}'", &caps[1], flatten_local_image_url(&caps[2], base))
|
||||
})
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
fn flatten_local_image_url(url: &str, base: Option<&Path>) -> String {
|
||||
let lower = url.to_ascii_lowercase();
|
||||
if lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("data:") {
|
||||
return url.to_owned();
|
||||
}
|
||||
let path = Path::new(url);
|
||||
let resolved = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
match base {
|
||||
Some(base) => base.join(path),
|
||||
None => return url.to_owned(),
|
||||
}
|
||||
};
|
||||
let Ok(bytes) = std::fs::read(&resolved) else {
|
||||
return url.to_owned();
|
||||
};
|
||||
let Some(png) = crate::gpui_shell::http::flatten_animated_to_png(&bytes) else {
|
||||
return url.to_owned();
|
||||
};
|
||||
let cache = std::env::temp_dir().join("nebula-md-img");
|
||||
if std::fs::create_dir_all(&cache).is_err() {
|
||||
return url.to_owned();
|
||||
}
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
std::hash::Hash::hash(&bytes, &mut hasher);
|
||||
let dest = cache.join(format!("{:016x}.png", std::hash::Hasher::finish(&hasher)));
|
||||
if !dest.exists() && std::fs::write(&dest, png).is_err() {
|
||||
return url.to_owned();
|
||||
}
|
||||
dest.to_string_lossy().replace('\\', "/")
|
||||
}
|
||||
pub use super::file_editor::{TextFileEvent as DocTabViewEvent, TextFileView as DocTabView};
|
||||
@@ -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: ®ex::Captures<'_>| {
|
||||
format!("{}{}", &caps[1], flatten_local_image_url(&caps[2], base))
|
||||
});
|
||||
let rewritten = HTML_IMG_DQ.replace_all(&rewritten, |caps: ®ex::Captures<'_>| {
|
||||
format!(r#"{}{}""#, &caps[1], flatten_local_image_url(&caps[2], base))
|
||||
});
|
||||
HTML_IMG_SQ
|
||||
.replace_all(&rewritten, |caps: ®ex::Captures<'_>| {
|
||||
format!("{}{}'", &caps[1], flatten_local_image_url(&caps[2], base))
|
||||
})
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
fn flatten_local_image_url(url: &str, base: Option<&Path>) -> String {
|
||||
let lower = url.to_ascii_lowercase();
|
||||
if lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("data:") {
|
||||
return url.to_owned();
|
||||
}
|
||||
let path = Path::new(url);
|
||||
let resolved = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
match base {
|
||||
Some(base) => base.join(path),
|
||||
None => return url.to_owned(),
|
||||
}
|
||||
};
|
||||
let Ok(bytes) = std::fs::read(&resolved) else {
|
||||
return url.to_owned();
|
||||
};
|
||||
let Some(png) = crate::gpui_shell::http::flatten_animated_to_png(&bytes) else {
|
||||
return url.to_owned();
|
||||
};
|
||||
let cache = std::env::temp_dir().join("nebula-md-img");
|
||||
if std::fs::create_dir_all(&cache).is_err() {
|
||||
return url.to_owned();
|
||||
}
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
std::hash::Hash::hash(&bytes, &mut hasher);
|
||||
let dest = cache.join(format!("{:016x}.png", std::hash::Hasher::finish(&hasher)));
|
||||
if !dest.exists() && std::fs::write(&dest, png).is_err() {
|
||||
return url.to_owned();
|
||||
}
|
||||
dest.to_string_lossy().replace('\\', "/")
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
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();
|
||||
}
|
||||
|
||||
pub(super) fn render_search_header(&self, _: &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));
|
||||
cx.notify();
|
||||
}))
|
||||
.child(
|
||||
Icon::default()
|
||||
.path(section_icon(index))
|
||||
.size(px(SETTINGS_NAV_ICON_SIZE)),
|
||||
)
|
||||
.child(section_label(index, language))
|
||||
}))
|
||||
});
|
||||
let trigger = cx.entity().downgrade();
|
||||
let search = div()
|
||||
.relative()
|
||||
h_flex()
|
||||
.w_full()
|
||||
.h(px(SETTINGS_HEADER_HEIGHT))
|
||||
.flex_shrink_0()
|
||||
.px_5()
|
||||
.items_center()
|
||||
.child(
|
||||
Input::new(&self.settings_search_input)
|
||||
.w_full()
|
||||
@@ -74,41 +43,36 @@ impl SettingsPane {
|
||||
)
|
||||
.aria_label(language.pick("在全部设置中搜索", "Search all settings")),
|
||||
)
|
||||
.child(
|
||||
gpui::canvas(
|
||||
move |bounds, _, cx| {
|
||||
let _ = trigger.update(cx, |pane, cx| {
|
||||
if pane.settings_search_trigger_bounds != Some(bounds) {
|
||||
pane.settings_search_trigger_bounds = Some(bounds);
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
},
|
||||
|_, _, _, _| {},
|
||||
)
|
||||
.absolute()
|
||||
.size_full(),
|
||||
)
|
||||
.when_some(panel.zip(bounds), |search, (panel, bounds)| {
|
||||
search.child(
|
||||
deferred(
|
||||
anchored()
|
||||
.anchor(gpui::Anchor::TopLeft)
|
||||
.position(bounds.bottom_left())
|
||||
.offset(gpui::point(px(0.0), px(6.0)))
|
||||
.snap_to_window_with_margin(px(8.0))
|
||||
.child(panel),
|
||||
)
|
||||
.with_priority(3),
|
||||
)
|
||||
});
|
||||
h_flex()
|
||||
.w_full()
|
||||
.h(px(SETTINGS_HEADER_HEIGHT))
|
||||
.flex_shrink_0()
|
||||
.px_5()
|
||||
.items_center()
|
||||
.child(search)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn matching_sections(query: &str, language: crate::display::UiLanguage) -> Vec<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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
fn dock_pane_areas(&self) -> Vec<(u64, nebula_split::Rect)> {
|
||||
let Some(area) = self.active_terminal_area() else { return vec![] };
|
||||
let Some(WorkspaceTab::Terminal { tree, zoomed, .. }) = self.tabs.get(self.active) else {
|
||||
return vec![];
|
||||
};
|
||||
// A zoomed tree must be restored before adding a split, otherwise its post-drop
|
||||
// geometry cannot match the visible full-pane preview.
|
||||
if *zoomed && !tree.is_leaf() {
|
||||
return vec![];
|
||||
}
|
||||
let nx = (x - area.x) / area.w;
|
||||
let ny = (y - area.y) / area.h;
|
||||
let (dl, dr, dt, db) = (nx, 1.0 - nx, ny, 1.0 - ny);
|
||||
let min = dl.min(dr).min(dt).min(db);
|
||||
Some(if min == dl {
|
||||
SplitNav::Left
|
||||
} else if min == dr {
|
||||
SplitNav::Right
|
||||
} else if min == dt {
|
||||
SplitNav::Up
|
||||
tree.layout(area, 1.0, 1.0, nebula_split::DIVIDER_GAP, false).panes
|
||||
}
|
||||
|
||||
pub(super) fn dock_preview_area(&self, target: DockTarget) -> Option<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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 成
|
||||
// 具体数字,切主题便再也换不了形态——那正是要避免的。
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@
|
||||
|
||||
use std::mem;
|
||||
|
||||
mod dock;
|
||||
|
||||
/// 可见分隔条厚度(逻辑像素;使用处乘以 scale 后取整,至少 1 物理像素)。
|
||||
pub const DIVIDER_GAP: f32 = 2.0;
|
||||
/// 分隔条命中扩边(逻辑像素):视觉线很细,抓取目标刻意加宽。
|
||||
|
||||
Reference in new issue
Block a user