diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index db008a0..a4eae66 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -113,11 +113,17 @@ jobs:
# 共享程序集绝不能落进插件目录 —— 落了就说明 SDK 包的 exclude=Runtime 链路断了。
# 这条在插件侧再验一遍(工具链仓库的 CI 只在模板生成的空工程上验过),
# 因为真实插件才有第三方依赖,才可能把 Avalonia 从别的包里拖回来。
+ #
+ # MicroCom.Runtime 单列一条:它是 Avalonia 的依赖,但**不以 Avalonia 打头** ——
+ # 既躲过装载器的共享前缀判定(插件会加载自己那一份),也躲过上面那条按名字前缀的体检。
+ # 直接引 Avalonia.* 包的插件(DockerPanel 引了 Avalonia.AvaloniaEdit)一旦漏写
+ # ExcludeAssets="runtime",它就会静悄悄落进插件目录 —— 2026-09-11 并 DockerPanel 时
+ # 实测过这条路:构建 0 错 0 警告,包也照常打出来,只有肉眼逐个文件看才发现。
- name: Assert no shared assemblies leaked
shell: pwsh
run: |
$leaked = Get-ChildItem artifacts/plugins-bundle -Recurse -Filter '*.dll' |
- Where-Object { $_.Name -like 'Avalonia*' -or $_.Name -eq 'VelaShell.PluginSdk.dll' }
+ Where-Object { $_.Name -like 'Avalonia*' -or $_.Name -eq 'VelaShell.PluginSdk.dll' -or $_.Name -eq 'MicroCom.Runtime.dll' }
if ($leaked) {
throw "Shared assemblies leaked into the plugin bundle: $(($leaked | ForEach-Object FullName) -join ', ')"
}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index fb77a9a..c9526df 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -148,7 +148,7 @@ jobs:
shell: pwsh
run: |
$leaked = Get-ChildItem artifacts/plugins-bundle -Recurse -Filter '*.dll' |
- Where-Object { $_.Name -like 'Avalonia*' -or $_.Name -eq 'VelaShell.PluginSdk.dll' }
+ Where-Object { $_.Name -like 'Avalonia*' -or $_.Name -eq 'VelaShell.PluginSdk.dll' -or $_.Name -eq 'MicroCom.Runtime.dll' }
if ($leaked) {
throw "Shared assemblies leaked into the plugin bundle: $(($leaked | ForEach-Object FullName) -join ', ')"
}
diff --git a/AGENTS.md b/AGENTS.md
index 82a95d2..e8cc5b3 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -39,7 +39,7 @@ VelaShell 生态的**全部文档**集中在一个仓库:
## 三、本仓库:velashell-plugins(第一方插件)
-Redis / S3 / Telnet / 串口等第一方插件,以 Release 资产 `velashell-plugins-<版本>.zip` 交付。
+Redis / S3 / Telnet / 串口 / Docker 面板等第一方插件,以 Release 资产 `.vpx`(每插件一份,已签名)交付。
### 构建与打包
@@ -65,6 +65,13 @@ S3 见 [`zh/host/S3协议插件化设计.md`](https://github.com/VelaShellLabs/v
Telnet / 串口见 [`zh/host/Telnet与串口可行性调研.md`](https://github.com/VelaShellLabs/velashell-docs/blob/main/zh/host/Telnet与串口可行性调研.md)。
**改了这些插件的设计取舍,要回写对应那篇。**
+Docker 面板是个例外:它从独立仓库 `VelaShell.Plugin.DockerPanel` 并进来(2026-09-11),
+设计取证留在插件目录自己的 [`README.md`](plugins/VelaShell.Plugin.DockerPanel/README.md) 与
+[`plan.md`](plugins/VelaShell.Plugin.DockerPanel/plan.md)(后者是对着
+`VelaShell.Plugin.DockerPanel.pen` 的 19 个画板逐条核对的完成度快照)。
+这三份是上面第二节说的那类**例外**:它们服务的是"在这个目录里改这个插件",
+跟着代码走比搬进 velashell-docs 更有用。
+
### 视觉对齐宿主
插件面板的几何与配色取自宿主的
diff --git a/Directory.Build.targets b/Directory.Build.targets
index 9097b36..02d4341 100644
--- a/Directory.Build.targets
+++ b/Directory.Build.targets
@@ -34,4 +34,26 @@
Code="VELAP1000"
Text="Avalonia 版本与 SDK 锁的不一致:VelaShell.PluginSdk $(VelaSdkPackageVersion) 锁的是 $(VelaSdkPinnedAvaloniaVersion),而本仓库 Directory.Packages.props 里的 Avalonia 是 $(_VelaAvaloniaPin)。把 Directory.Packages.props 里三个 Avalonia* 都改成 $(VelaSdkPinnedAvaloniaVersion)。" />
+
+
+
+
+ <_VelaAvaloniaEditPin>@(PackageVersion->WithMetadataValue('Identity','Avalonia.AvaloniaEdit')->'%(Version)')
+
+
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 349aac8..8c8b425 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -17,9 +17,9 @@
-
+
-
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ^\s*\[\[?[^\]]*\]\]?
+
+
+
+
+ ^\s*[A-Za-z_][A-Za-z0-9_.\-]*(?=\s*[=:])
+
+
+
+ "
+ "
+
+
+
+
+
+
+ '
+ '
+
+
+
+ true
+ false
+ yes
+ no
+ on
+ off
+ none
+ null
+ enabled
+ disabled
+
+
+
+ \b[0-9]+([.:][0-9]+)*\b
+
+
+
+ [=:]
+
+
+
+
diff --git a/plugins/VelaShell.Plugin.DockerPanel/Syntax/Yaml.xshd b/plugins/VelaShell.Plugin.DockerPanel/Syntax/Yaml.xshd
new file mode 100644
index 0000000..d80a10d
--- /dev/null
+++ b/plugins/VelaShell.Plugin.DockerPanel/Syntax/Yaml.xshd
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ^(---|\.\.\.)\s*$
+
+
+
+
+ ^\s*(-\s+)?[^\s#][^:#]*(?=:(\s|$))
+
+
+
+ '
+ '
+
+
+
+ "
+ "
+
+
+
+
+
+
+
+ [&*][A-Za-z0-9_-]+|!!?[A-Za-z0-9_/:.-]+
+
+
+
+ true
+ false
+ True
+ False
+ TRUE
+ FALSE
+ yes
+ no
+ on
+ off
+ null
+ Null
+ NULL
+ ~
+
+
+
+ \b[-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?\b
+
+
+
+
+ [|>][-+]?\s*$|[\[\]{},]
+
+
+
+
diff --git a/plugins/VelaShell.Plugin.DockerPanel/Ui/AutoScroll.cs b/plugins/VelaShell.Plugin.DockerPanel/Ui/AutoScroll.cs
new file mode 100644
index 0000000..cae36ef
--- /dev/null
+++ b/plugins/VelaShell.Plugin.DockerPanel/Ui/AutoScroll.cs
@@ -0,0 +1,138 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Threading;
+using Avalonia.VisualTree;
+using System.Collections.Specialized;
+
+namespace VelaShell.Plugin.DockerPanel.Ui;
+
+///
+/// 让一份滚动内容跟着集合的新条目往下走。
+///
+/// 规矩只有一条:用户自己滚上去看东西的时候不许抢。所以"跟不跟"不是一个设置,
+/// 而是从用户最近一次滚动推出来的 —— 停在底部就继续跟,往上翻了就停住,
+/// 再滚回底部又接着跟。日志窗口都是这个约定,只是通常没人把它写下来。
+///
+///
+/// 判断"是不是用户滚的"看 :内容变长会带来
+/// ExtentDelta,而用户拖滚动条只有 OffsetDelta。把两者分开,
+/// 才不会把"新日志把视口顶上去"误当成"用户翻上去了"。
+///
+///
+/// 挂在哪:可以是 自己,也可以是**里面**有一个滚动条的控件
+/// (虚拟化的列表就是这种 —— 滚动条在它自己的模板里,外面套不得)。
+///
+///
+public static class AutoScroll
+{
+ private static readonly Dictionary Watchers = [];
+
+ /// 要跟的集合。绑上去就开始跟。
+ public static readonly AttachedProperty SourceProperty =
+ AvaloniaProperty.RegisterAttached("Source", typeof(AutoScroll));
+
+ static AutoScroll() => SourceProperty.Changed.AddClassHandler(OnSourceChanged);
+
+ /// 读 。
+ public static INotifyCollectionChanged? GetSource(Control control) => control.GetValue(SourceProperty);
+
+ /// 写 。
+ public static void SetSource(Control control, INotifyCollectionChanged? value) =>
+ control.SetValue(SourceProperty, value);
+
+ private static void OnSourceChanged(Control control, AvaloniaPropertyChangedEventArgs e)
+ {
+ if (Watchers.Remove(control, out var previous))
+ {
+ previous.Detach();
+ }
+ if (e.NewValue is INotifyCollectionChanged source)
+ {
+ Watchers[control] = new(control, source);
+ }
+ }
+
+ /// 一个控件上的一次"跟随"。状态是每个控件各自的,所以做成实例而不是静态表。
+ private sealed class Watcher
+ {
+ private readonly Control _control;
+ private readonly INotifyCollectionChanged _source;
+ private ScrollViewer? _scroll;
+ // 一接上就是"在底部":此时还没有内容,用户也还没表达过任何意图。
+ private bool _sticky = true;
+
+ internal Watcher(Control control, INotifyCollectionChanged source)
+ {
+ _control = control;
+ _source = source;
+ source.CollectionChanged += OnCollectionChanged;
+ control.AttachedToVisualTree += OnAttached;
+ Bind();
+ }
+
+ internal void Detach()
+ {
+ _source.CollectionChanged -= OnCollectionChanged;
+ _control.AttachedToVisualTree -= OnAttached;
+ if (_scroll is { } scroll)
+ {
+ scroll.ScrollChanged -= OnScrollChanged;
+ _scroll = null;
+ }
+ }
+
+ private void OnAttached(object? sender, VisualTreeAttachmentEventArgs e) => Bind();
+
+ ///
+ /// 找到那个真正在滚的 ScrollViewer。
+ ///
+ /// 虚拟化列表的滚动条长在它自己的模板里,模板套上之前找不着 —— 所以这里
+ /// 每次要用的时候再找,而不是找不到就排一个任务下次再找:
+ /// 那个控件可能压根不会显示(收起来的页签),自我重排的任务就成了一个永不结束的循环。
+ ///
+ ///
+ private ScrollViewer? Bind()
+ {
+ if (_scroll is not null)
+ {
+ return _scroll;
+ }
+ _scroll = _control as ScrollViewer ?? _control.FindDescendantOfType();
+ if (_scroll is { } found)
+ {
+ found.ScrollChanged += OnScrollChanged;
+ }
+ return _scroll;
+ }
+
+ private void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
+ {
+ // 清屏(Reset)也要回到底,否则视口会停在一个已经不存在的位置上。
+ if (e.Action is NotifyCollectionChangedAction.Add or NotifyCollectionChangedAction.Reset && _sticky)
+ {
+ ScrollLater();
+ }
+ }
+
+ private void OnScrollChanged(object? sender, ScrollChangedEventArgs e)
+ {
+ if (_scroll is not { } scroll)
+ {
+ return;
+ }
+ // 内容变长带来的滚动不算用户意图 —— 只有 Offset 单独动了才是他自己滚的。
+ if (e.ExtentDelta.Y != 0 || e.OffsetDelta.Y == 0)
+ {
+ return;
+ }
+ // 留一行的余量:滚动条很少停在恰好 0 的位置上。
+ _sticky = scroll.Extent.Height - scroll.Viewport.Height - scroll.Offset.Y <= 24;
+ }
+
+ ///
+ /// 排到布局之后再滚:集合刚变,新的那几行还没测量,这一刻的 Extent 还是旧的。
+ ///
+ private void ScrollLater() =>
+ Dispatcher.UIThread.Post(() => Bind()?.ScrollToEnd(), DispatcherPriority.Background);
+ }
+}
diff --git a/plugins/VelaShell.Plugin.DockerPanel/Ui/CodeEditor.cs b/plugins/VelaShell.Plugin.DockerPanel/Ui/CodeEditor.cs
new file mode 100644
index 0000000..aab1eb5
--- /dev/null
+++ b/plugins/VelaShell.Plugin.DockerPanel/Ui/CodeEditor.cs
@@ -0,0 +1,336 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Media;
+using AvaloniaEdit;
+using AvaloniaEdit.Highlighting;
+using AvaloniaEdit.Highlighting.Xshd;
+using System.Xml;
+
+namespace VelaShell.Plugin.DockerPanel.Ui;
+
+/// 这块文本按哪种语法着色。
+public enum CodeLanguage
+{
+ /// 不着色(纯文本)。
+ None,
+
+ /// YAML —— compose.yaml 与 config 展开。
+ Yaml,
+
+ /// .env。
+ DotEnv
+}
+
+///
+/// 带语法高亮的文本框。
+///
+/// 里面是 AvaloniaEdit 的 ,但外面只露出四个属性 ——
+/// compose 页有三处要它(compose.yaml、.env、config 展开),各自的差别只有
+/// "什么语言、能不能改、要不要折行"。把 TextEditor 直接写进 AXAML 的话,
+/// 这三处就得各抄一遍主题、字体、行号与配色的接线。
+///
+///
+/// 为什么不是 TextBox:TextBox 的整块文本共用一个 Foreground,
+/// 按 token 着色它给不了。而 compose.yaml 恰恰是"缩进错一格就全错"的格式,
+/// 高亮在这里不是装饰,是校对工具。
+///
+///
+public sealed class CodeEditor : UserControl
+{
+ private static readonly Lock RegistrationGate = new();
+ private static bool _registered;
+
+ private readonly TextEditor _editor;
+
+ /// 应用级资源变更(= 宿主换了配色)的处理器;进出可视树时挂/摘。
+ private readonly EventHandler _onAppResourcesChanged;
+ private bool _syncing;
+
+ /// 文本。双向 —— 编辑器里改了要回到视图模型。
+ public static readonly StyledProperty TextProperty =
+ AvaloniaProperty.Register(nameof(Text),
+ defaultBindingMode: Avalonia.Data.BindingMode.TwoWay);
+
+ /// 按哪种语法着色。
+ public static readonly StyledProperty LanguageProperty =
+ AvaloniaProperty.Register(nameof(Language));
+
+ /// 只读(config 展开是 compose 算出来的,改它没有意义)。
+ public static readonly StyledProperty IsReadOnlyProperty =
+ AvaloniaProperty.Register(nameof(IsReadOnly));
+
+ /// 自动折行。
+ public static readonly StyledProperty WordWrapProperty =
+ AvaloniaProperty.Register(nameof(WordWrap));
+
+ /// 显示行号。
+ public static readonly StyledProperty ShowLineNumbersProperty =
+ AvaloniaProperty.Register(nameof(ShowLineNumbers), true);
+
+ /// 建编辑器。
+ public CodeEditor()
+ {
+ _editor = new()
+ {
+ Background = Brushes.Transparent,
+ BorderThickness = new(0),
+ Padding = new(10, 8),
+ ShowLineNumbers = true,
+ HorizontalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Auto,
+ VerticalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Auto
+ };
+ _editor.Options.IndentationSize = 2;
+ _editor.Options.ConvertTabsToSpaces = true;
+ _editor.Options.HighlightCurrentLine = true;
+ // 关掉链接识别。AvaloniaEdit 默认会把 http:// 与邮箱地址画成"超链接":
+ // 一个写死的蓝色加下划线,既不认主题也不认语法配色 —— 深色底上那个蓝几乎读不出来,
+ // 而在一份 compose.yaml 里,ZO_ROOT_USER_EMAIL 的值本来就只是一个字符串,
+ // 把它做成可点的链接既没用又抢眼。
+ _editor.Options.EnableHyperlinks = false;
+ _editor.Options.EnableEmailHyperlinks = false;
+ _editor.TextChanged += (_, _) =>
+ {
+ if (_syncing)
+ {
+ return;
+ }
+ _syncing = true;
+ SetCurrentValue(TextProperty, _editor.Text);
+ _syncing = false;
+ };
+ Content = _editor;
+ Focusable = false;
+ // 编辑器的配色是**一次性取值**的(xshd 高亮定义要的是 Color 不是 Brush,
+ // 挂不了 DynamicResource),所以换肤时必须有人来叫醒它。
+ //
+ // 只挂 ActualThemeVariantChanged 是不够的 —— 那只在明↔暗之间翻转时才响。
+ // 宿主有十二套主题,VelaDark → Tokyo Night 是**同一个变体**内部的换肤:
+ // 变体没变,颜色全变了,这个事件一声不吭。
+ // 应用级资源字典的变更覆盖全部三种情形(换具名主题、跟随系统翻转、改强调色),
+ // 是这里唯一靠得住的信号。
+ ActualThemeVariantChanged += (_, _) => ApplyLanguage();
+ _onAppResourcesChanged = (_, _) => ApplyLanguage();
+ }
+
+ ///
+ public string? Text
+ {
+ get => GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ ///
+ public CodeLanguage Language
+ {
+ get => GetValue(LanguageProperty);
+ set => SetValue(LanguageProperty, value);
+ }
+
+ ///
+ public bool IsReadOnly
+ {
+ get => GetValue(IsReadOnlyProperty);
+ set => SetValue(IsReadOnlyProperty, value);
+ }
+
+ ///
+ public bool WordWrap
+ {
+ get => GetValue(WordWrapProperty);
+ set => SetValue(WordWrapProperty, value);
+ }
+
+ ///
+ public bool ShowLineNumbers
+ {
+ get => GetValue(ShowLineNumbersProperty);
+ set => SetValue(ShowLineNumbersProperty, value);
+ }
+
+ ///
+ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+ {
+ base.OnPropertyChanged(change);
+ if (change.Property == TextProperty && !_syncing)
+ {
+ _syncing = true;
+ var incoming = Text ?? "";
+ // 只在真不一样时才写:同一份文本写回去会把光标顶到开头,
+ // 用户打一个字就跳一次。
+ if (_editor.Text != incoming)
+ {
+ _editor.Text = incoming;
+ }
+ _syncing = false;
+ }
+ else if (change.Property == LanguageProperty || change.Property == ForegroundProperty ||
+ change.Property == FontFamilyProperty || change.Property == FontSizeProperty)
+ {
+ ApplyLanguage();
+ }
+ else if (change.Property == IsReadOnlyProperty)
+ {
+ _editor.IsReadOnly = IsReadOnly;
+ }
+ else if (change.Property == WordWrapProperty)
+ {
+ _editor.WordWrap = WordWrap;
+ }
+ else if (change.Property == ShowLineNumbersProperty)
+ {
+ _editor.ShowLineNumbers = ShowLineNumbers;
+ }
+ }
+
+ ///
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+ if (Application.Current is { } app)
+ {
+ app.ResourcesChanged += _onAppResourcesChanged;
+ }
+ ApplyLanguage();
+ }
+
+ ///
+ ///
+ /// 订阅挂在 Application.Current 上(活得比本控件久),不摘就是一条把整个面板
+ /// 钉在内存里的引用 —— 插件停用后 ALC 也就回收不掉了。
+ ///
+ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ if (Application.Current is { } app)
+ {
+ app.ResourcesChanged -= _onAppResourcesChanged;
+ }
+ base.OnDetachedFromVisualTree(e);
+ }
+
+ /// 把语言、字体与当前主题的配色一起落到编辑器上。
+ private void ApplyLanguage()
+ {
+ _editor.FontFamily = FontFamily;
+ _editor.FontSize = FontSize;
+ if (Foreground is { } foreground)
+ {
+ _editor.Foreground = foreground;
+ }
+ if (Resource("VelaAccent") is IBrush caret)
+ {
+ _editor.TextArea.Caret.CaretBrush = caret;
+ }
+ if (Resource("VelaAccentDim") is ISolidColorBrush selection)
+ {
+ _editor.TextArea.SelectionBrush = selection;
+ _editor.TextArea.SelectionBorder = null;
+ }
+ if (Resource("VelaTextTertiary") is IBrush lineNumbers)
+ {
+ _editor.LineNumbersForeground = lineNumbers;
+ }
+ _editor.SyntaxHighlighting = Definition(Language) is { } definition ? Recolor(definition) : null;
+ }
+
+ ///
+ /// 按当前主题给定义重着色。
+ ///
+ /// xshd 里写死的是 Dracula 的暗色值;亮色主题下那几个浅黄浅绿压在奶白底上等于隐形。
+ /// 所以颜色的**唯一事实来源**是主题字典里的 DockerCode* 令牌,xshd 只负责"哪一段算什么角色"。
+ ///
+ ///
+ /// 定义是 里的全局单例,重着色改的是同一个对象 ——
+ /// 这正是想要的:主题一切,下次渲染就跟着变。
+ ///
+ ///
+ private IHighlightingDefinition Recolor(IHighlightingDefinition definition)
+ {
+ foreach (var color in definition.NamedHighlightingColors)
+ {
+ if (Role(color.Name) is { } token && Resource(token) is ISolidColorBrush brush)
+ {
+ color.Foreground = new SimpleHighlightingBrush(brush.Color);
+ }
+ }
+ return definition;
+ }
+
+ ///
+ /// xshd 里的角色名 → 宿主令牌名。
+ ///
+ /// 一律指宿主的**终端十六色**那一族(VelaShell*):那正是各主题为"代码/日志里的
+ /// 语法着色"准备的一套语义色,逐主题派生。面板自己曾经按明暗写死过两套 Dracula /
+ /// Alucard 的值,结果是十二套主题只有两套语法色 —— 底色换了,字没换。
+ ///
+ ///
+ private static string? Role(string name) => name switch
+ {
+ "Comment" => "VelaShellSubtle",
+ "String" => "VelaShellYellow",
+ "Number" or "Constant" => "VelaShellBlue",
+ "Key" => "VelaShellCyan",
+ "Section" => "VelaShellGreen",
+ "Punctuation" => "VelaShellMagenta",
+ "Variable" => "VelaWarning",
+ _ => null
+ };
+
+ private object? Resource(string key) =>
+ this.TryFindResource(key, ActualThemeVariant, out var value) ? value : null;
+
+ ///
+ /// 取一份语法定义。
+ ///
+ /// 定义随插件自带(Syntax/*.xshd 的嵌入资源),注册名带 Docker. 前缀:
+ /// 是**整个进程共享**的,宿主自己也往里注册
+ /// "YAML"、"Ini" —— 不加前缀就会互相顶掉,而且谁先跑起来是不确定的。
+ ///
+ ///
+ private static IHighlightingDefinition? Definition(CodeLanguage language)
+ {
+ if (language == CodeLanguage.None)
+ {
+ return null;
+ }
+ EnsureRegistered();
+ return HighlightingManager.Instance.GetDefinition(language == CodeLanguage.Yaml
+ ? "Docker.Yaml"
+ : "Docker.DotEnv");
+ }
+
+ private static void EnsureRegistered()
+ {
+ lock (RegistrationGate)
+ {
+ if (_registered)
+ {
+ return;
+ }
+ _registered = true;
+ var assembly = typeof(CodeEditor).Assembly;
+ foreach ((var resource, var extensions) in
+ new[]
+ {
+ ("VelaShell.Plugin.DockerPanel.Syntax.Yaml.xshd", new[] { ".yaml", ".yml" }),
+ ("VelaShell.Plugin.DockerPanel.Syntax.DotEnv.xshd", [".env"])
+ })
+ {
+ try
+ {
+ using var stream = assembly.GetManifestResourceStream(resource);
+ if (stream is null)
+ {
+ continue;
+ }
+ using var reader = XmlReader.Create(stream);
+ var definition = HighlightingLoader.Load(reader, HighlightingManager.Instance);
+ HighlightingManager.Instance.RegisterHighlighting(definition.Name, extensions, definition);
+ }
+ catch (Exception)
+ {
+ // 一份定义坏掉不该让编辑器打不开 —— 那一种退化成纯文本,其余照常。
+ }
+ }
+ }
+ }
+}
diff --git a/plugins/VelaShell.Plugin.DockerPanel/Ui/ColumnResizer.cs b/plugins/VelaShell.Plugin.DockerPanel/Ui/ColumnResizer.cs
new file mode 100644
index 0000000..d813a6a
--- /dev/null
+++ b/plugins/VelaShell.Plugin.DockerPanel/Ui/ColumnResizer.cs
@@ -0,0 +1,150 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Media;
+using System.Globalization;
+
+namespace VelaShell.Plugin.DockerPanel.Ui;
+
+///
+/// 列头上的列宽拖拽。四张列表(容器 / 镜像 / 卷 / 网络)共用这一份。
+///
+/// 做法与宿主的文件浏览器一致:每列右边一条 宽的轨道,
+/// 轨道的 Tag 说明它改的是哪一列;按下时**把当前全部列宽快照下来**,
+/// 位移一律按"当前指针 − 按下时指针"算,而不是一帧一帧累加 ——
+/// 累加会在列宽被夹住(碰到上下限)之后跑偏,指针和界线就再也对不上了。
+///
+///
+/// 指针捕获在**视图**上而不是那条 6px 的窄轨道上:拖出轨道之外仍然收得到移动。
+/// 移动与抬起因此挂在视图根上(隧道式),而不是挂在轨道自己身上。
+///
+///
+public sealed class ColumnResizer
+{
+ private readonly Control _host;
+ private readonly Grid _header;
+ private readonly double _chrome;
+ private readonly Dictionary _startWidths = [];
+ private string? _active;
+ private double _startX;
+
+ /// 接上一张列表的列头。
+ /// 视图根(指针捕获在它身上)。
+ /// 列头那张 Grid 的名字。
+ /// 列宽之外那些**不可拖**的固定占位之和(勾选框、状态色条、行尾动作…)。
+ public ColumnResizer(Control host, string headerName, double chrome)
+ {
+ _host = host;
+ _header = host.GetControl(headerName);
+ _chrome = chrome;
+ // 轨道在 XAML 里只写 Classes 与 Tag,事件在这里挂 —— 四个页面就不必各抄一遍转发方法。
+ foreach (var grip in _header.Children.OfType().Where(b => b.Tag is string))
+ {
+ grip.PointerPressed += OnPressed;
+ grip.PointerReleased += OnReleased;
+ }
+ host.AddHandler(InputElement.PointerMovedEvent, OnMoved, RoutingStrategies.Tunnel);
+ host.AddHandler(InputElement.PointerReleasedEvent, OnReleased, RoutingStrategies.Tunnel);
+ }
+
+ private ListColumns? Columns => (_host.DataContext as PageViewModel)?.ColumnLayout;
+
+ private void OnPressed(object? sender, PointerPressedEventArgs e)
+ {
+ if (sender is not Border { Tag: string key } grip || Columns is not { } columns)
+ {
+ return;
+ }
+ // 双击 = 按内容自适应,与宿主文件浏览器一致。
+ if (e.ClickCount >= 2)
+ {
+ AutoFit(columns, key, grip);
+ e.Handled = true;
+ return;
+ }
+ _active = key;
+ _startX = e.GetPosition(_host).X;
+ _startWidths.Clear();
+ foreach (var other in columns.Keys)
+ {
+ _startWidths[other] = columns.Get(other);
+ }
+ e.Pointer.Capture(_host);
+ e.Handled = true;
+ }
+
+ private void OnMoved(object? sender, PointerEventArgs e)
+ {
+ if (_active is not { } key || Columns is not { } columns ||
+ !_startWidths.TryGetValue(key, out var start))
+ {
+ return;
+ }
+ var delta = e.GetPosition(_host).X - _startX;
+ columns.Set(key, Math.Clamp(start + delta, columns.Min(key), Max(columns, key)));
+ e.Handled = true;
+ }
+
+ private void OnReleased(object? sender, PointerReleasedEventArgs e)
+ {
+ if (_active is null)
+ {
+ return;
+ }
+ _active = null;
+ e.Pointer.Capture(null);
+ e.Handled = true;
+ }
+
+ ///
+ /// 一列最宽能到哪:剩下那些列、每条轨道与行首行尾的固定占位都得留出来,
+ /// 否则一拖就把行尾那几颗动作按钮挤出可视区。
+ ///
+ private double Max(ListColumns columns, string key)
+ {
+ var others = columns.Keys.Where(k => k != key).Sum(columns.Get);
+ var available = _header.Bounds.Width > 0 ? _header.Bounds.Width : _host.Bounds.Width;
+ return Math.Max(columns.Min(key),
+ available - others - (columns.Keys.Count * ListColumns.TrackWidth) - _chrome);
+ }
+
+ ///
+ /// 双击轨道:把这一列收放到"正好装得下当前这些行"。
+ ///
+ /// 量的是当前**筛出来的**那些行,不是全部 —— 用户看得见的就是这些,
+ /// 为了一行被筛掉的超长文本把列撑开,反而看不成。
+ ///
+ ///
+ private void AutoFit(ListColumns columns, string key, Border grip)
+ {
+ if (_host.DataContext is not PageViewModel page)
+ {
+ return;
+ }
+ var size = Resource(key is "name" or "repo" ? "VelaFontSize12" : "VelaFontSize11") as double? ?? 11;
+ Typeface mono = new(Resource("VelaUiMonoFont") as FontFamily ?? FontFamily.Default);
+ Typeface ui = new(FontFamily.Default);
+ // 表头那几个字也要装得下。文字直接从列头里读,免得把同一串标题在代码里再写一遍。
+ var widest = Measure(HeaderText(grip), ui, size);
+ foreach (var text in page.ColumnTexts(key))
+ {
+ widest = Math.Max(widest, Measure(text, key is "name" or "repo" ? ui : mono, size));
+ }
+ columns.Set(key, Math.Clamp(widest + columns.Padding(key),
+ columns.Min(key), Math.Min(columns.MaxAutoFit(key), Max(columns, key))));
+ }
+
+ private string HeaderText(Border grip) =>
+ _header.Children.OfType()
+ .FirstOrDefault(t => Grid.GetColumn(t) == Grid.GetColumn(grip) - 1)?.Text ?? "";
+
+ private static double Measure(string text, Typeface typeface, double size) =>
+ text.Length == 0
+ ? 0
+ : new FormattedText(text, CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
+ typeface, size, Brushes.Black).Width;
+
+ private object? Resource(string key) =>
+ _host.TryFindResource(key, (_host as StyledElement)?.ActualThemeVariant, out var value) ? value : null;
+}
diff --git a/plugins/VelaShell.Plugin.DockerPanel/Ui/CommandPalette.cs b/plugins/VelaShell.Plugin.DockerPanel/Ui/CommandPalette.cs
new file mode 100644
index 0000000..eb9ec7d
--- /dev/null
+++ b/plugins/VelaShell.Plugin.DockerPanel/Ui/CommandPalette.cs
@@ -0,0 +1,234 @@
+using System.Collections.ObjectModel;
+
+namespace VelaShell.Plugin.DockerPanel.Ui;
+
+/// 命令面板里的一条。
+/// 分组标题(动作 / 容器 / 镜像·卷 / 面板命令)。
+/// 主文字。
+/// 右侧小字。
+/// 图标资源键。
+/// 语气(破坏性的那几条用危险色)。
+/// 破坏性:标题带省略号,选中后仍走确认闸门。
+/// 执行。
+public sealed record PaletteEntry(
+ string Group,
+ string Title,
+ string Detail,
+ string Icon,
+ RowTone Tone,
+ bool Destructive,
+ Func Run);
+
+/// 命令面板里的一行(含分组头的显示状态)。
+public sealed class PaletteItem(PaletteEntry entry) : ObservableObject
+{
+
+ /// 底层条目。
+ public PaletteEntry Entry { get; } = entry;
+
+ /// 分组标题。
+ public string Group => Entry.Group;
+
+ /// 主文字。
+ public string Title => Entry.Title;
+
+ /// 右侧小字。
+ public string Detail => Entry.Detail;
+
+ /// 图标。
+ public string Icon => Entry.Icon;
+
+ /// 语气。
+ public RowTone Tone => Entry.Tone;
+
+ /// 这一条上面要不要画分组头。
+ public bool ShowGroup { get; set; }
+
+ /// 分组里有几条(分组头右侧那个数字)。
+ public int GroupCount { get; set; }
+
+ /// 键盘选中的那一条。
+ public bool Active
+ {
+ get;
+ set => SetField(ref field, value);
+ }
+}
+
+///
+/// 面板内的命令面板(Ctrl+K)。
+///
+/// 它在面板里,不劫持宿主的命令面板。 宿主自己有一个 Ctrl+K,
+/// 那一个管的是会话、设置、插件;这一个管的是当前这台 Docker 主机上的容器、镜像、卷。
+/// 两者搜的是不同的东西,合并成一个只会让两边都变难用。
+///
+///
+/// 破坏性的条目标题带省略号,并且**选中后仍然走确认闸门** —— 命令面板是个加速器,
+/// 不是绕过闸门的后门。
+///
+///
+public sealed class CommandPalette : ObservableObject
+{
+ private readonly Func> _collect;
+ private List _all = [];
+ private int _selectedIndex;
+
+ /// 建面板。
+ /// 打开时去收集当前可用的条目。
+ public CommandPalette(Func> collect)
+ {
+ _collect = collect;
+ CloseCommand = new RelayCommand(_ =>
+ {
+ IsOpen = false;
+ return Task.CompletedTask;
+ });
+ RunSelectedCommand = new RelayCommand(_ => RunSelectedAsync());
+ RunCommand = new RelayCommand(p => p is PaletteItem item ? RunAsync(item) : Task.CompletedTask);
+ }
+
+ /// 开着没。
+ public bool IsOpen
+ {
+ get;
+ private set => SetField(ref field, value);
+ }
+
+ /// 搜索词。
+ public string Query
+ {
+ get;
+ set
+ {
+ if (SetField(ref field, value))
+ {
+ ApplyFilter();
+ }
+ }
+ } = "";
+
+ /// 过滤后的条目。
+ public ObservableCollection Items { get; } = [];
+
+ /// 一条都没匹配上。
+ public bool IsEmpty => Items.Count == 0;
+
+ /// 当前主机名(面板顶部显示 —— 命令作用在哪台机器上必须写清)。
+ public string HostName { get; private set; } = "";
+
+ /// 关闭。
+ public RelayCommand CloseCommand { get; }
+
+ /// 执行当前选中的那条。
+ public RelayCommand RunSelectedCommand { get; }
+
+ /// 执行指定的那条(鼠标点击)。
+ public RelayCommand RunCommand { get; }
+
+ /// 打开面板并收集条目。
+ public void Open(string hostName)
+ {
+ HostName = hostName;
+ _all = [.. _collect()];
+ Query = "";
+ ApplyFilter();
+ OnPropertyChanged(nameof(HostName));
+ IsOpen = true;
+ }
+
+ /// 上下移动选中项。
+ public void Move(int delta)
+ {
+ if (Items.Count == 0)
+ {
+ return;
+ }
+ // 环绕:到底了按下一次回到第一条,比停在那里不动更符合预期。
+ var next = (_selectedIndex + delta + Items.Count) % Items.Count;
+ Select(next);
+ }
+
+ ///
+ /// Tab 补全:把选中那条的标题填进搜索框。
+ ///
+ /// 补的是标题里去掉省略号的部分 —— 省略号代表"还会再问你一次",
+ /// 把它补进搜索框只会让下一次匹配失败。
+ ///
+ ///
+ public void Complete()
+ {
+ if (Items.Count == 0)
+ {
+ return;
+ }
+ Query = Items[_selectedIndex].Title.TrimEnd('…');
+ }
+
+ private void Select(int index)
+ {
+ for (var i = 0; i < Items.Count; i++)
+ {
+ Items[i].Active = i == index;
+ }
+ _selectedIndex = index;
+ }
+
+ private async Task RunSelectedAsync()
+ {
+ if (Items.Count == 0)
+ {
+ return;
+ }
+ await RunAsync(Items[_selectedIndex]).ConfigureAwait(true);
+ }
+
+ private async Task RunAsync(PaletteItem item)
+ {
+ // 先关面板再执行:被触发的动作多半会自己弹确认闸门或表单,
+ // 让命令面板压在它上面既难看也点不到。
+ IsOpen = false;
+ await item.Entry.Run().ConfigureAwait(true);
+ }
+
+ private void ApplyFilter()
+ {
+ Items.Clear();
+ var needle = Query.Trim();
+ List matched = needle.Length == 0
+ ? [.. _all]
+ : [.. _all.Where(e => Matches(e, needle)).OrderByDescending(e => Score(e, needle))];
+
+ string? group = null;
+ foreach (var entry in matched)
+ {
+ var item = new PaletteItem(entry);
+ if (entry.Group != group)
+ {
+ item.ShowGroup = true;
+ item.GroupCount = matched.Count(e => e.Group == entry.Group);
+ group = entry.Group;
+ }
+ Items.Add(item);
+ }
+ OnPropertyChanged(nameof(IsEmpty));
+ if (Items.Count > 0)
+ {
+ Select(0);
+ }
+ }
+
+ private static bool Matches(PaletteEntry entry, string needle) =>
+ entry.Title.Contains(needle, StringComparison.OrdinalIgnoreCase)
+ || entry.Detail.Contains(needle, StringComparison.OrdinalIgnoreCase);
+
+ ///
+ /// 排序权重。标题前缀命中 > 标题包含 > 只在小字里命中。
+ ///
+ /// 输入 res 时,"重启 nginx-proxy" 该排在"名字里恰好带 res 的某个卷"前面。
+ ///
+ ///
+ private static int Score(PaletteEntry entry, string needle) =>
+ entry.Title.StartsWith(needle, StringComparison.OrdinalIgnoreCase) ? 3
+ : entry.Title.Contains(needle, StringComparison.OrdinalIgnoreCase) ? 2
+ : 1;
+}
diff --git a/plugins/VelaShell.Plugin.DockerPanel/Ui/CommandPaletteView.axaml b/plugins/VelaShell.Plugin.DockerPanel/Ui/CommandPaletteView.axaml
new file mode 100644
index 0000000..bc1847b
--- /dev/null
+++ b/plugins/VelaShell.Plugin.DockerPanel/Ui/CommandPaletteView.axaml
@@ -0,0 +1,97 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/plugins/VelaShell.Plugin.DockerPanel/Ui/CommandPaletteView.axaml.cs b/plugins/VelaShell.Plugin.DockerPanel/Ui/CommandPaletteView.axaml.cs
new file mode 100644
index 0000000..5e56b4c
--- /dev/null
+++ b/plugins/VelaShell.Plugin.DockerPanel/Ui/CommandPaletteView.axaml.cs
@@ -0,0 +1,62 @@
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Markup.Xaml;
+using Avalonia.Threading;
+
+namespace VelaShell.Plugin.DockerPanel.Ui;
+
+///
+/// 命令面板的视图。
+///
+/// 代码后置只做键盘:上下移动、回车执行、Tab 补全、Esc 关闭。
+/// 这四个键必须在**输入框**上处理 —— 用户打开面板之后手就没离开过键盘,
+/// 让他为了选一条命令去摸鼠标,这个功能就白做了。
+///
+///
+public sealed partial class CommandPaletteView : UserControl
+{
+ /// 建视图。
+ public CommandPaletteView()
+ {
+ AvaloniaXamlLoader.Load(this);
+ // 打开即聚焦到输入框:面板是被 Ctrl+K 唤出来的,那一刻用户已经在打字了。
+ AttachedToVisualTree += (_, _) =>
+ Dispatcher.UIThread.Post(() => this.FindControl("QueryBox")?.Focus());
+ }
+
+ ///
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ if (DataContext is not CommandPalette palette)
+ {
+ base.OnKeyDown(e);
+ return;
+ }
+ switch (e.Key)
+ {
+ case Key.Down:
+ palette.Move(1);
+ e.Handled = true;
+ break;
+ case Key.Up:
+ palette.Move(-1);
+ e.Handled = true;
+ break;
+ case Key.Enter:
+ palette.RunSelectedCommand.Execute(null);
+ e.Handled = true;
+ break;
+ case Key.Tab:
+ palette.Complete();
+ e.Handled = true;
+ break;
+ case Key.Escape:
+ palette.CloseCommand.Execute(null);
+ e.Handled = true;
+ break;
+ default:
+ base.OnKeyDown(e);
+ break;
+ }
+ }
+}
diff --git a/plugins/VelaShell.Plugin.DockerPanel/Ui/ConfirmGate.cs b/plugins/VelaShell.Plugin.DockerPanel/Ui/ConfirmGate.cs
new file mode 100644
index 0000000..a2a1962
--- /dev/null
+++ b/plugins/VelaShell.Plugin.DockerPanel/Ui/ConfirmGate.cs
@@ -0,0 +1,313 @@
+using System.Collections.ObjectModel;
+
+namespace VelaShell.Plugin.DockerPanel.Ui;
+
+/// 确认闸门的档位。
+public enum ConfirmTier
+{
+ ///
+ /// 一般破坏性:删容器、删镜像、删网络、compose down、多数 prune。
+ /// 后果说明 + 危险色按钮,点一下就执行。
+ ///
+ Destructive,
+
+ ///
+ /// 会丢数据:删卷、compose down -v、system prune --volumes、
+ /// 覆盖容器内文件或远端 compose 文件。额外要求**手打确认串**,与删仓库同款 ——
+ /// 因为后果同款。
+ ///
+ DataLoss
+}
+
+/// 后果说明的一条。
+/// 0=中性 1=好消息 2=注意 3=危险,决定图标与颜色。
+/// 一句话。
+public readonly record struct ConfirmConsequence(int Severity, string Text);
+
+/// 确认框里列出的一个目标。
+/// 名字。
+/// 中间那段小字(镜像、驱动)。
+/// 右侧状态。
+/// 是否在运行(决定左侧圆点的颜色)。
+public readonly record struct ConfirmTarget(string Name, string Meta, string Status, bool Running);
+
+/// 一次确认请求。
+public sealed record ConfirmRequest
+{
+ /// 标题,如“删除 2 个容器?”。
+ public required string Title { get; init; }
+
+ /// 标题左侧的图标资源键。
+ public string Icon { get; init; } = "Icon.trash-2";
+
+ /// 档位。
+ public ConfirmTier Tier { get; init; } = ConfirmTier.Destructive;
+
+ ///
+ /// 目标主机名。一行都不能省 —— 同时开着生产与测试两台机器时,
+ /// 一个不写清主机的“确定删除 3 个卷吗”是这个面板能犯的最贵的错误。
+ ///
+ public required string HostName { get; init; }
+
+ /// 主机名后面那行小字(地址、传输方式)。
+ public string HostDetail { get; init; } = "";
+
+ /// 主机小字是否用警示色(生产环境提醒)。
+ public bool HostWarning { get; init; }
+
+ /// 将要执行的**那几条真请求**,不是一句概括。
+ public IReadOnlyList Commands { get; init; } = [];
+
+ /// 请求下面的等价命令行(给用户核对与复制)。
+ public string? CommandNote { get; init; }
+
+ /// 目标列表。
+ public IReadOnlyList Targets { get; init; } = [];
+
+ /// 后果说明。
+ public IReadOnlyList Consequences { get; init; } = [];
+
+ /// 确认按钮文字。
+ public required string ConfirmLabel { get; init; }
+
+ /// 确认按钮的图标。
+ public string ConfirmIcon { get; init; } = "Icon.trash-2";
+
+ ///
+ /// 档要求手打的确认串,如 delete / save。
+ ///
+ public string ConfirmWord { get; init; } = "delete";
+
+ /// 会丢数据那一档要额外醒目显示的一句话。
+ public string? DataLossHeadline { get; init; }
+
+ /// 会丢数据那一档的补充说明(逐条)。
+ public IReadOnlyList DataLossPoints { get; init; } = [];
+
+ ///
+ /// 闸门上那个可选的"先做一件事再执行"勾选(删卷之前先备份为 tar)。
+ /// 留空表示这次不提供这个选项。
+ ///
+ public string? PrecautionLabel { get; init; }
+
+ /// 勾选默认打开没有。删数据这一档默认**打开** —— 默认值该偏向不丢东西那一边。
+ public bool PrecautionDefault { get; init; } = true;
+}
+
+///
+/// 面板内的确认闸门。
+///
+/// 它**贴在这个面板上**,不是一个飘在屏幕中央的系统弹窗:同时开着两个 Docker 面板时,
+/// 一个居中的对话框根本说不清自己属于哪一台机器。
+///
+///
+public sealed class ConfirmGate : ObservableObject
+{
+ private TaskCompletionSource? _pending;
+
+ /// 建一个闸门。
+ public ConfirmGate()
+ {
+ ConfirmCommand = new RelayCommand(_ => Complete(true), _ => CanConfirm);
+ CancelCommand = new RelayCommand(_ => Complete(false));
+ }
+
+ ///
+ /// 那个可选的"先做一件事再执行"勾选是否打开。发起方在 AskAsync 返回之后读它。
+ ///
+ public bool Precaution
+ {
+ get;
+ set => SetField(ref field, value);
+ } = true;
+
+ /// 这次闸门有没有提供那个勾选。
+ public bool HasPrecaution => Request?.PrecautionLabel is { Length: > 0 };
+
+ /// 那个勾选的文字。
+ public string PrecautionLabel => Request?.PrecautionLabel ?? "";
+
+ /// 当前请求;没有待确认的事情时为 。
+ public ConfirmRequest? Request
+ {
+ get;
+ private set
+ {
+ if (SetField(ref field, value))
+ {
+ OnPropertiesChanged(nameof(IsOpen), nameof(IsDataLoss), nameof(HasTargets),
+ nameof(HasConsequences), nameof(HasCommandNote), nameof(CanConfirm), nameof(RemainingHint),
+ nameof(HasPrecaution), nameof(PrecautionLabel), nameof(Icon), nameof(Title),
+ nameof(HostName), nameof(HostDetail), nameof(CommandNote), nameof(DataLossHeadline),
+ nameof(ConfirmWord), nameof(ConfirmIcon), nameof(ConfirmLabel));
+ ConfirmCommand.RaiseCanExecuteChanged();
+ }
+ }
+ }
+
+ /// 闸门是否打开着。
+ public bool IsOpen => Request is not null;
+
+ // 下面这些都是 Request 的空安全转发。界面不直接绑 Request.Xxx ——
+ // 闸门关着时 Request 是 null,而 IsVisible 只是不画:子树照样建、绑定照样求值,
+ // 于是每关一次就刷一屏 “binding … Value is null”。转发一层,关着时给的是空串。
+
+ /// 标题左侧的图标资源键。
+ public string Icon => Request?.Icon ?? "Icon.trash-2";
+
+ /// 标题。
+ public string Title => Request?.Title ?? "";
+
+ /// 目标主机名。
+ public string HostName => Request?.HostName ?? "";
+
+ /// 主机名后面那行小字。
+ public string HostDetail => Request?.HostDetail ?? "";
+
+ ///
+ /// 目标不是本机时,主机那行小字要用警示色。
+ ///
+ /// 请求里早就带着这一位(),界面却一直没读 ——
+ /// 于是"在生产机上删 3 个卷"和"在本机删 3 个卷"长得一模一样。
+ ///
+ ///
+ public bool HostWarning => Request?.HostWarning == true;
+
+ /// 请求下面那行等价的命令行。
+ public string CommandNote => Request?.CommandNote ?? "";
+
+ /// 会丢数据那一档要额外醒目显示的一句话。
+ public string DataLossHeadline => Request?.DataLossHeadline ?? "";
+
+ /// 会丢数据那一档要手打的确认串。
+ public string ConfirmWord => Request?.ConfirmWord ?? "";
+
+ /// 确认按钮的图标。
+ public string ConfirmIcon => Request?.ConfirmIcon ?? "Icon.trash-2";
+
+ /// 确认按钮文字。
+ public string ConfirmLabel => Request?.ConfirmLabel ?? "";
+
+ /// 是不是"会丢数据"那一档。
+ public bool IsDataLoss => Request?.Tier == ConfirmTier.DataLoss;
+
+ /// 有没有目标列表。
+ public bool HasTargets => Request?.Targets.Count > 0;
+
+ /// 有没有后果说明。
+ public bool HasConsequences => Request?.Consequences.Count > 0;
+
+ /// 有没有等价命令。
+ public bool HasCommandNote => !string.IsNullOrEmpty(Request?.CommandNote);
+
+ /// 目标列表(绑定用)。
+ public ObservableCollection Targets { get; } = [];
+
+ /// 后果说明(绑定用)。
+ public ObservableCollection Consequences { get; } = [];
+
+ /// 将要执行的请求(绑定用)。
+ public ObservableCollection Commands { get; } = [];
+
+ /// 会丢数据那一档的补充说明(绑定用)。
+ public ObservableCollection DataLossPoints { get; } = [];
+
+ /// 用户手打的确认串。
+ public string TypedWord
+ {
+ get;
+ set
+ {
+ if (SetField(ref field, value))
+ {
+ OnPropertiesChanged(nameof(CanConfirm), nameof(RemainingHint));
+ ConfirmCommand.RaiseCanExecuteChanged();
+ }
+ }
+ } = "";
+
+ /// 确认按钮能不能按。
+ public bool CanConfirm =>
+ Request is not null &&
+ (Request.Tier != ConfirmTier.DataLoss ||
+ string.Equals(TypedWord.Trim(), Request.ConfirmWord, StringComparison.Ordinal));
+
+ /// 还差几个字符的提示。
+ public string RemainingHint
+ {
+ get
+ {
+ if (Request is not { Tier: ConfirmTier.DataLoss } request)
+ {
+ return "";
+ }
+ var typed = TypedWord.Trim();
+ return typed == request.ConfirmWord
+ ? "可以确认了"
+ : request.ConfirmWord.StartsWith(typed, StringComparison.Ordinal)
+ ? $"还差 {request.ConfirmWord.Length - typed.Length} 个字符"
+ : $"请输入 {request.ConfirmWord}";
+ }
+ }
+
+ /// 确认。
+ public RelayCommand ConfirmCommand { get; }
+
+ /// 取消。
+ public RelayCommand CancelCommand { get; }
+
+ ///
+ /// 打开闸门并等用户决定。已经有一个在等的时候,新请求直接被拒绝
+ /// (返回 false)—— 两层确认框叠在一起,用户不可能说清自己在确认哪一个。
+ ///
+ public Task AskAsync(ConfirmRequest request)
+ {
+ if (_pending is not null)
+ {
+ return Task.FromResult(false);
+ }
+ _pending = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ Ui.Post(() =>
+ {
+ TypedWord = "";
+ Precaution = request.PrecautionDefault;
+ Commands.Clear();
+ foreach (var command in request.Commands)
+ {
+ Commands.Add(command);
+ }
+ Targets.Clear();
+ foreach (var target in request.Targets)
+ {
+ Targets.Add(target);
+ }
+ Consequences.Clear();
+ foreach (var consequence in request.Consequences)
+ {
+ Consequences.Add(consequence);
+ }
+ DataLossPoints.Clear();
+ foreach (var point in request.DataLossPoints)
+ {
+ DataLossPoints.Add(point);
+ }
+ Request = request;
+ });
+ return _pending.Task;
+ }
+
+ /// 面板关闭时把等待中的请求当作取消。
+ public void CancelPending() => Complete(false);
+
+ private void Complete(bool confirmed)
+ {
+ var pending = _pending;
+ _pending = null;
+ Ui.Post(() =>
+ {
+ Request = null;
+ TypedWord = "";
+ });
+ pending?.TrySetResult(confirmed);
+ }
+}
diff --git a/plugins/VelaShell.Plugin.DockerPanel/Ui/ConfirmGateView.axaml b/plugins/VelaShell.Plugin.DockerPanel/Ui/ConfirmGateView.axaml
new file mode 100644
index 0000000..8fcc090
--- /dev/null
+++ b/plugins/VelaShell.Plugin.DockerPanel/Ui/ConfirmGateView.axaml
@@ -0,0 +1,211 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/plugins/VelaShell.Plugin.DockerPanel/Ui/ConfirmGateView.axaml.cs b/plugins/VelaShell.Plugin.DockerPanel/Ui/ConfirmGateView.axaml.cs
new file mode 100644
index 0000000..e16a87e
--- /dev/null
+++ b/plugins/VelaShell.Plugin.DockerPanel/Ui/ConfirmGateView.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace VelaShell.Plugin.DockerPanel.Ui;
+
+/// 确认闸门的视图。
+public sealed partial class ConfirmGateView : UserControl
+{
+ /// 建视图。
+ public ConfirmGateView() => AvaloniaXamlLoader.Load(this);
+}
diff --git a/plugins/VelaShell.Plugin.DockerPanel/Ui/Converters.cs b/plugins/VelaShell.Plugin.DockerPanel/Ui/Converters.cs
new file mode 100644
index 0000000..b8e99c3
--- /dev/null
+++ b/plugins/VelaShell.Plugin.DockerPanel/Ui/Converters.cs
@@ -0,0 +1,568 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Data.Converters;
+using Avalonia.Media;
+using System.Globalization;
+
+namespace VelaShell.Plugin.DockerPanel.Ui;
+
+///
+/// 面板用到的值转换器。
+///
+/// 全部走宿主的 Vela* 令牌:转换器只负责"这一行是什么状态",
+/// 具体是哪个颜色由主题字典决定。
+///
+///
+/// 取色一律经 —— 转换器只在**绑定源**变化时才跑,而主题不是绑定源,
+/// 所以换肤时这里的代码根本不会被再调一次。 的办法是让画刷对象
+/// 保持不变、只改它的颜色,于是绑定一次都不用重算。别在这个文件里直接去 TryFindResource
+/// 拿画刷:那样拿到的是宿主当前那一格字典里的实例,宿主换主题是整格替换,它会就此停在旧色上。
+///
+///
+public static class Converters
+{
+ /// 状态色 → 画刷。参数可选 dim,取半透明底色。
+ public static readonly IValueConverter ToneBrush = new ToneBrushConverter();
+
+ /// 状态色 → 图标资源键。
+ public static readonly IValueConverter ToneIcon = new FuncValueConverter(tone => tone switch
+ {
+ RowTone.Ok => "Icon.circle-check",
+ RowTone.Warn => "Icon.triangle-alert",
+ RowTone.Danger => "Docker.circle-x",
+ RowTone.Busy => "Icon.refresh-cw",
+ _ => "Icon.circle-help"
+ });
+
+ /// 后果说明的严重度 → 画刷。
+ public static readonly IValueConverter SeverityBrush = new SeverityBrushConverter();
+
+ /// 后果说明的严重度 → 图标。
+ public static readonly IValueConverter SeverityIcon = new FuncValueConverter(severity => severity switch
+ {
+ 1 => "Icon.shield",
+ 2 => "Icon.triangle-alert",
+ 3 => "Docker.circle-x",
+ _ => "Icon.info"
+ });
+
+ /// 反馈语气 → 画刷。
+ public static readonly IValueConverter FeedbackBrush = new FeedbackBrushConverter();
+
+ /// 0–1 的比例 → 星形宽度(进度条的已填充段)。
+ public static readonly IValueConverter RatioStar =
+ new FuncValueConverter(v => new(Math.Clamp(v, 0, 1), GridUnitType.Star));
+
+ /// 0–1 的比例 → 剩余段的星形宽度。
+ public static readonly IValueConverter RatioRestStar =
+ new FuncValueConverter(v => new(1 - Math.Clamp(v, 0, 1), GridUnitType.Star));
+
+ /// 把 0–100 的采样值换算成 sparkline 里的柱高(最高 16)。
+ public static readonly IValueConverter SampleHeight =
+ new FuncValueConverter(v => Math.Max(2, Math.Clamp(v, 0, 100) / 100 * 16));
+
+ /// 把 0–100 的采样值换算成抽屉里那张卡片的柱高(最高 30)。
+ public static readonly IValueConverter CardSampleHeight =
+ new FuncValueConverter(v => Math.Max(2, Math.Clamp(v, 0, 100) / 100 * 30));
+
+ /// 把 0–100 的采样值换算成趋势图里的柱高(最高 96)。
+ public static readonly IValueConverter TrendHeight =
+ new FuncValueConverter(v => Math.Max(2, Math.Clamp(v, 0, 100) / 100 * 96));
+
+ /// 0–1 的比例 → 剩下那一段的权重(配合 )。
+ public static readonly IValueConverter RemainingWeight =
+ new FuncValueConverter(v => Math.Max(0.0001, 1 - Math.Clamp(v, 0, 1)));
+
+ ///
+ /// 值为 0(含极小的浮点残留)为 true。进度条据此在"不知道分母"时退化成不确定型。
+ ///
+ public static readonly IValueConverter IsZero =
+ new FuncValueConverter(v => v <= 0.0001);
+
+ ///
+ /// 来源序号 → 一个稳定的颜色。
+ ///
+ /// 用宿主已有的语义色轮着来,不自造调色板 —— 它们在明暗两套主题下都已经调过对比度,
+ /// 而临时挑的六个 hex 在亮色模式下多半有一半看不清。
+ ///
+ ///
+ public static readonly IValueConverter SourceBrush = new SourceBrushConverter();
+
+ /// 差异行的底色:增绿、删红、改黄,没变透明。
+ public static readonly IValueConverter DiffBackground = new DiffBackgroundConverter();
+
+ /// 键盘选中项的底色。
+ public static readonly IValueConverter ActiveBackground = new ActiveBackgroundConverter();
+
+ /// 非空字符串为 true。
+ public static readonly IValueConverter NotEmpty =
+ new FuncValueConverter(v => !string.IsNullOrEmpty(v));
+
+ /// 取反。
+ public static readonly IValueConverter Not = new FuncValueConverter(v => !v);
+
+ /// 文件树里的图标色:目录用强调色,文件退到弱化色。
+ public static readonly IValueConverter FileIconBrush =
+ new FuncValueConverter(isDirectory => isDirectory
+ ? ThemeBrush("VelaAccent", Brushes.MediumPurple)
+ : ThemeBrush("VelaTextTertiary", Brushes.Gray));
+
+ /// 「这一行是当前打开的那个」→ 选中底色,否则透明。
+ public static readonly IValueConverter CurrentRowBackground =
+ new FuncValueConverter(current => current
+ ? ThemeBrush("VelaAccentDim", Brushes.Transparent)
+ : Brushes.Transparent);
+
+ /// 「换行」开关 → 文本换行模式。关掉时长行横向截断,不折成好几屏。
+ public static readonly IValueConverter WrapMode =
+ new FuncValueConverter(v => v ? TextWrapping.Wrap : TextWrapping.NoWrap);
+
+ ///
+ /// 「换行」开关 → 横向滚动条。
+ ///
+ /// 两者必须互斥:开着横向滚动,正文就是在无限宽里量的,TextWrapping.Wrap 永远折不了行;
+ /// 关掉横向滚动而又不折行,超出去的那半行则彻底没办法看到。
+ ///
+ ///
+ public static readonly IValueConverter WrapScroll =
+ new FuncValueConverter(wrap =>
+ wrap ? ScrollBarVisibility.Disabled : ScrollBarVisibility.Auto);
+
+ ///
+ /// 提示文字:空的就**不给提示**。
+ ///
+ /// ToolTip.Tip 只看是不是 :空字符串照样会弹一个空框出来。
+ /// 而绑上去的那些文字有一半天生可能是空的(没有端口的容器、没有禁用理由的选项、
+ /// 还没打开文件的编辑器),于是鼠标一悬停就冒出一个空白小方块。
+ ///
+ ///
+ public static readonly IValueConverter TipText =
+ new FuncValueConverter(text => string.IsNullOrWhiteSpace(text) ? null : text);
+
+ /// 最大化 / 还原是同一颗键,图标要跟着当前状态走 —— 否则铺开之后没人看得出怎么回去。
+ public static readonly IValueConverter MaximizeIcon =
+ new FuncValueConverter(max => max ? "Docker.minimize-2" : "Docker.maximize-2");
+
+ ///
+ /// 控件宽度 ≥ 参数(像素)才为真。
+ ///
+ /// 抽屉是用户拖出来的,同一条工具条要在 440 和 1400 两种宽度下都成立 ——
+ /// 靠它把次要的那几颗按钮按宽度逐级收起,而不是让它们溢出到屏幕外。
+ ///
+ ///
+ public static readonly IValueConverter WiderThan = new WiderThanConverter();
+
+ ///
+ /// CPU 高负载 → 警示色,否则强调色。
+ ///
+ /// 行内 sparkline 与它旁边那个百分比共用这一条:两者必须同时变色,
+ /// 否则会出现"柱子是黄的、数字是紫的"这种自相矛盾的一行。
+ ///
+ ///
+ public static readonly IValueConverter HotAccent =
+ new FuncValueConverter(hot => hot
+ ? ThemeBrush("VelaWarning", Brushes.Orange)
+ : ThemeBrush("VelaAccent", Brushes.MediumPurple));
+
+ /// CPU 高负载 → 警示色,否则常规数值色。给 sparkline 旁边那个百分比用。
+ public static readonly IValueConverter HotText =
+ new FuncValueConverter(hot => hot
+ ? ThemeBrush("VelaWarning", Brushes.Orange)
+ : ThemeBrush("VelaTextSecondary", Brushes.Gray));
+
+ ///
+ /// 按令牌取画刷。**全部**转换器的取色都必须走这一条。
+ ///
+ /// 拿到的是一支会跟着宿主换肤就地改色的画刷(见 )。
+ /// 直接把宿主资源里的画刷返回出去是不行的:转换器只在绑定源变化时才跑,主题不是绑定源,
+ /// 而宿主换主题是整格替换字典 —— 返回出去的那个对象会永远停在旧主题的颜色上。
+ ///
+ ///
+ internal static IBrush ThemeBrush(string key, IBrush fallback) => ThemeBrushes.Get(key, fallback);
+
+ ///
+ /// 按当前主题变体查一个**非颜色**资源(图标几何)。
+ ///
+ /// 颜色不要走这里 —— 走 。几何不随主题变,取到就一直有效。
+ ///
+ ///
+ /// 必须带上 ActualThemeVariant:宿主的资源分两族,不带主题的那个重载按
+ /// ThemeVariant.Default 查,写在 ThemeDictionaries 分支下的那一族一个都查不到。
+ ///
+ ///
+ internal static object? Lookup(string key)
+ {
+ return Application.Current is not { } app ? null : app.TryFindResource(key, app.ActualThemeVariant, out var value) ? value : null;
+ }
+
+ private sealed class ToneBrushConverter : IValueConverter
+ {
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ var dim = (parameter as string) == "dim";
+ var key = value switch
+ {
+ RowTone.Ok => dim ? "VelaShellGreenDim" : "VelaStatusConnected",
+ RowTone.Warn => dim ? "VelaShellYellowDim" : "VelaWarning",
+ RowTone.Danger => dim ? "VelaShellRedDim" : "VelaError",
+ RowTone.Busy => dim ? "VelaAccentDim" : "VelaAccent",
+ _ => dim ? "VelaShellSubtleDim" : "VelaTextTertiary"
+ };
+ return ThemeBrush(key, Brushes.Gray);
+ }
+
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
+ throw new NotSupportedException();
+ }
+
+ private sealed class WiderThanConverter : IValueConverter
+ {
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
+ value is double width && !double.IsNaN(width) &&
+ double.TryParse(parameter as string, NumberStyles.Float, CultureInfo.InvariantCulture, out var least) &&
+ width >= least;
+
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
+ throw new NotSupportedException();
+ }
+
+ private sealed class DiffBackgroundConverter : IValueConverter
+ {
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
+ value switch
+ {
+ RowTone.Ok => ThemeBrush("VelaShellGreenDim", Brushes.Transparent),
+ RowTone.Danger => ThemeBrush("VelaShellRedDim", Brushes.Transparent),
+ RowTone.Warn => ThemeBrush("VelaShellYellowDim", Brushes.Transparent),
+ _ => Brushes.Transparent
+ };
+
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
+ throw new NotSupportedException();
+ }
+
+ private sealed class ActiveBackgroundConverter : IValueConverter
+ {
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
+ value is true ? ThemeBrush("VelaAccentDim", Brushes.Transparent) : Brushes.Transparent;
+
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
+ throw new NotSupportedException();
+ }
+
+ private sealed class SourceBrushConverter : IValueConverter
+ {
+ /// 轮换用的令牌。六个之后回头 —— 同屏合并超过六条流本来就读不动了。
+ private static readonly string[] Palette =
+ [
+ "VelaAccent", "VelaStatusConnected", "VelaInfo",
+ "VelaWarning", "VelaShellMagenta", "VelaShellCyan"
+ ];
+
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ var index = value is int i && i >= 0 ? i : 0;
+ return ThemeBrush(Palette[index % Palette.Length], Brushes.Gray);
+ }
+
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
+ throw new NotSupportedException();
+ }
+
+ private sealed class SeverityBrushConverter : IValueConverter
+ {
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
+ ThemeBrush(value switch
+ {
+ 1 => "VelaStatusConnected",
+ 2 => "VelaWarning",
+ 3 => "VelaError",
+ _ => "VelaTextTertiary"
+ }, Brushes.Gray);
+
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
+ throw new NotSupportedException();
+ }
+
+ private sealed class FeedbackBrushConverter : IValueConverter
+ {
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ var dim = (parameter as string) == "dim";
+ var key = value switch
+ {
+ FeedbackKind.Success => dim ? "VelaShellGreenDim" : "VelaStatusConnected",
+ FeedbackKind.Warning => dim ? "VelaShellYellowDim" : "VelaWarning",
+ FeedbackKind.Error => dim ? "VelaShellRedDim" : "VelaError",
+ _ => dim ? "VelaAccentDim" : "VelaAccent"
+ };
+ return ThemeBrush(key, Brushes.Gray);
+ }
+
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
+ throw new NotSupportedException();
+ }
+}
+
+///
+/// 按资源键取图标几何。
+///
+/// 图标名是数据(视图模型给的字符串),而 StreamGeometry 在资源字典里 ——
+/// 这个转换器把两者接上,免得每处都写一个 DataTrigger 大表。
+///
+///
+public sealed class IconLookupConverter : IValueConverter
+{
+ /// 单例。
+ public static readonly IconLookupConverter Instance = new();
+
+ ///
+ public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ return value is not string key || key.Length == 0 ? null : Converters.Lookup(key);
+ }
+
+ ///
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
+ throw new NotSupportedException();
+}
+
+///
+/// 会丢数据那一档的闸门用红边框,一般破坏性用常规描边。
+///
+/// 边框颜色是两档之间**唯一**不靠文字的区分 —— 用户在读标题之前就该感觉到分量不同。
+///
+///
+public sealed class DataLossBorderConverter : IValueConverter
+{
+ ///
+ public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ var key = value is true ? "VelaError" : "VelaBorderSecondary";
+ return Converters.ThemeBrush(key, Brushes.Gray);
+ }
+
+ ///
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
+ throw new NotSupportedException();
+}
+
+/// 只读字段的底色比可编辑的暗一档 —— 不用读提示就知道这一格改不了。
+public sealed class ReadOnlyBackgroundConverter : IValueConverter
+{
+ ///
+ public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ var key = value is true ? "VelaBgSurface" : "VelaBgInput";
+ return Converters.ThemeBrush(key, Brushes.Transparent);
+ }
+
+ ///
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
+ throw new NotSupportedException();
+}
+
+/// 危险开关的说明文字用警示色。
+public sealed class DangerTextConverter : IValueConverter
+{
+ ///
+ public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ var key = value is true ? "VelaError" : "VelaTextTertiary";
+ return Converters.ThemeBrush(key, Brushes.Gray);
+ }
+
+ ///
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
+ throw new NotSupportedException();
+}
+
+/// 跟随中的按钮用绿底,停了就是透明。
+public sealed class FollowBackgroundConverter : IValueConverter
+{
+ ///
+ public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ return value is not true ? Brushes.Transparent : (object)(Converters.ThemeBrush("VelaShellGreenDim", Brushes.Transparent));
+ }
+
+ ///
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
+ throw new NotSupportedException();
+}
+
+/// 跟随中的文字与图标用绿色。
+public sealed class FollowForegroundConverter : IValueConverter
+{
+ ///
+ public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ var key = value is true ? "VelaStatusConnected" : "VelaTextSecondary";
+ return Converters.ThemeBrush(key, Brushes.Gray);
+ }
+
+ ///
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
+ throw new NotSupportedException();
+}
+
+///
+/// 搜索命中的行整行加一层淡黄底。
+///
+/// 不做行内高亮是有意的:日志行是纯文本(SelectableTextBlock 才能复制),
+/// 把它拆成几段富文本会同时毁掉选中与复制 —— 而那两件事在日志里比高亮更常用。
+///
+///
+public sealed class MatchBackgroundConverter : IValueConverter
+{
+ ///
+ public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ return value is not true ? Brushes.Transparent : (object)(Converters.ThemeBrush("VelaShellYellowDim", Brushes.Transparent));
+ }
+
+ ///
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
+ throw new NotSupportedException();
+}
+
+/// 标准错误的行用红色,标准输出用终端前景色。
+public sealed class LogLineForegroundConverter : IValueConverter
+{
+ ///
+ public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ var key = value is true ? "VelaShellRed" : "VelaShellWhite";
+ return Converters.ThemeBrush(key, Brushes.Gray);
+ }
+
+ ///
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
+ throw new NotSupportedException();
+}
+
+///
+/// 合并日志正文的颜色:按级别分色。
+///
+/// 刻意不把 INFO 染成绿色。日志里绝大多数行都是 INFO,整屏绿字既刺眼又等于没分色 ——
+/// 分色的意义是让少数几行跳出来。所以只有 WARN 与 ERROR 换色,DEBUG 压暗,
+/// 其余保持正文色。stderr 一律按错误算:它走的是哪条流,这件事本身就是信息。
+///
+///
+public sealed class LogBodyBrushConverter : IMultiValueConverter
+{
+ ///
+ public object? Convert(IList