diff --git a/plugins/VelaShell.Plugin.Mongo/Loc.Design.cs b/plugins/VelaShell.Plugin.Mongo/Loc.Design.cs index 33ddd99..d406189 100644 --- a/plugins/VelaShell.Plugin.Mongo/Loc.Design.cs +++ b/plugins/VelaShell.Plugin.Mongo/Loc.Design.cs @@ -106,7 +106,7 @@ private static readonly (string Key, string En, string Zh)[] DesignTexts = // ── 新建索引 ── ("Design_Fields", "Fields", "字段"), - ("Design_DragHint", "Drag to reorder", "拖动调整顺序"), + ("Design_DragHint", "Key order is the prefix order · ↑ ↓ or drag ⠿", "键序即前缀顺序 · ↑ ↓ 或拖动 ⠿ 调整"), ("Design_FieldPlaceholder", "field.path", "字段路径"), ("Design_AddField", "Add field", "添加字段"), ("Design_Kind", "Type", "类型"), @@ -130,6 +130,21 @@ private static readonly (string Key, string En, string Zh)[] DesignTexts = ("Design_RoleSort", "sort", "排序"), ("Design_RoleRange", "range", "范围"), ("Design_CreateIndex", "Create index", "创建索引"), + ("Design_EditorTarget", "Runs createIndexes on {0}", "将在 {0} 上执行 createIndexes"), + ("Design_DraftBadge", "new", "新建"), + ("Design_DraftUsage", "not created yet", "待创建"), + ("Design_ColOrder", "Order / type", "排序 / 类型"), + ("Design_ColRole", "ESR role", "ESR 角色"), + ("Design_ColSampled", "Sampled type", "抽样类型"), + ("Design_MoveUp", "Move up", "上移"), + ("Design_MoveDown", "Move down", "下移"), + ("Design_RemoveField", "Remove field", "移除字段"), + ("Design_TypeOptions", "Type & options", "类型与选项"), + ("Design_Preview", "Command preview", "命令预览"), + ("Design_OptUniqueHint", "rejects duplicate keys", "拒绝重复键值"), + ("Design_OptSparseHint", "skips docs without the field", "跳过缺该字段的文档"), + ("Design_OptPartialHint", "only docs matching the filter", "只索引满足条件的文档"), + ("Design_CloseEditor", "Close the editor", "关闭编辑器"), ("Design_NoSampleFields", "No sampled fields — type a field path", "没有抽样字段 · 直接输入字段路径"), ("Design_ErrNoFields", "Add at least one field", "至少添加一个字段"), ("Design_ErrUniqueKind", "Text, hashed, wildcard and 2dsphere indexes can't be unique", "文本、哈希、通配符与 2dsphere 索引不能设为唯一"), diff --git a/plugins/VelaShell.Plugin.Mongo/Loc.GridFs.cs b/plugins/VelaShell.Plugin.Mongo/Loc.GridFs.cs index 5cd6508..e577e71 100644 --- a/plugins/VelaShell.Plugin.Mongo/Loc.GridFs.cs +++ b/plugins/VelaShell.Plugin.Mongo/Loc.GridFs.cs @@ -39,7 +39,10 @@ private static readonly (string Key, string En, string Zh)[] GridFsTexts = ("Fs_NoMatch", "No files match “{0}”", "没有匹配 “{0}” 的文件"), ("Fs_Truncated", "Showing the first {0} items — narrow it down with search", "仅显示前 {0} 项 · 用搜索缩小范围"), ("Fs_OpenFolder", "Open", "打开"), - ("Fs_DropHere", "Drop files here to upload to {0}", "拖拽文件到此处上传到 {0}"), + ("Fs_DropHere", "Drop files here, or click to choose files — they go to {0}", "拖拽文件到此处,或点击选择文件 · 上传到 {0}"), + ("Fs_DropPickFiles", "Choose files…", "选择文件…"), + ("Fs_DropPickFolder", "Choose a folder…", "选择文件夹…"), + ("Fs_DropClickTip", "Click to choose files to upload", "点击选择要上传的文件"), ("Fs_DropHint", "No 16 MB per-file limit · chunkSize 255 KB · metadata can be edited before upload", "单文件上限不受 16 MB 限制 · chunkSize 255 KB · 可在上传前编辑 metadata"), diff --git a/plugins/VelaShell.Plugin.Mongo/Loc.Monitor.cs b/plugins/VelaShell.Plugin.Mongo/Loc.Monitor.cs index 681ed25..9a3c7e1 100644 --- a/plugins/VelaShell.Plugin.Mongo/Loc.Monitor.cs +++ b/plugins/VelaShell.Plugin.Mongo/Loc.Monitor.cs @@ -90,6 +90,9 @@ private static readonly (string Key, string En, string Zh)[] MonitorTexts = ("Mon_StorageIndex", "Indexes", "索引"), ("Mon_StorageTip", "data {0} · indexes {1}", "数据 {0} · 索引 {1}"), ("Mon_StorageEmpty", "No user collections yet", "还没有用户集合"), + ("Mon_StorageColName", "Collection", "集合"), + ("Mon_StorageColBar", "Data + indexes", "数据 + 索引"), + ("Mon_StorageColSize", "Size", "占用"), // ── 事件 ── ("Mon_EventsTitle", "Events", "事件"), diff --git a/plugins/VelaShell.Plugin.Mongo/Loc.Query.cs b/plugins/VelaShell.Plugin.Mongo/Loc.Query.cs index fa7e11c..f76876a 100644 --- a/plugins/VelaShell.Plugin.Mongo/Loc.Query.cs +++ b/plugins/VelaShell.Plugin.Mongo/Loc.Query.cs @@ -17,7 +17,19 @@ private static readonly (string Key, string En, string Zh)[] QueryTexts = ("Query_Unlimited", "unlimited", "不限"), ("Query_MaxTimeTip", "Default server-side time limit for each statement (a .maxTimeMS() in the statement wins)", "每条语句默认的服务器端超时(语句里写了 .maxTimeMS() 以语句为准)"), - ("Query_DatabaseTip", "Current database — same as running use", "当前库 —— 切换等同于执行 use"), + ("Query_DatabaseTip", "Database the statements run on — same as running use; db. resolves here", "语句在哪个库上执行 —— 切换等同于执行 use;db.<集合> 在这个库里解析"), + ("Query_ConnectionLabel", "Connection", "连接"), + ("Query_DatabaseLabel", "Database", "数据库"), + ("Query_ConnectionTip", "Connection the statements run on — switching keeps the script", "语句在哪条连接上执行 —— 切换时脚本原样带过去"), + ("Query_ConnNotConnected", "not connected", "未连接"), + ("Query_ConnFailed", "failed", "连接失败"), + ("Query_SwitchingTo", "Connecting {0}…", "正在连接 {0}…"), + ("Query_TargetHint", "Runs on {0} / {1}", "在 {0} / {1} 上执行"), + ("Query_SwitchWhileRunning", "Stop the running statements before switching connections", "先停止正在运行的语句,再切换连接"), + ("Query_SwitchFailed", "Couldn't connect {0}", "无法连接 {0}"), + ("Query_SwitchedTo", "The query now runs on {0} · {1}", "查询已切换到 {0} · {1}"), + ("Query_SwitchedDbMissing", "{0} has no database {1}; using {2}", "{0} 上没有库 {1},改用 {2}"), + ("Query_CollectionMissing", "No collection {0} in database {1} — check the database picker or use()", "库 {1} 里没有集合 {0} —— 核对上方的数据库或 use()"), // ── 状态 ── ("Query_StatusUnsaved", "Query {0} · unsaved", "查询 {0} · 未保存"), diff --git a/plugins/VelaShell.Plugin.Mongo/Loc.Workspace.cs b/plugins/VelaShell.Plugin.Mongo/Loc.Workspace.cs index 758bdc7..1730385 100644 --- a/plugins/VelaShell.Plugin.Mongo/Loc.Workspace.cs +++ b/plugins/VelaShell.Plugin.Mongo/Loc.Workspace.cs @@ -96,9 +96,9 @@ private static readonly (string Key, string En, string Zh)[] WorkspaceTexts = ("Detail_Anonymous", "(no auth)", "(未认证)"), // ── 标签页 ── - ("Tab_CloseModifiedTitle", "Discard pending changes?", "放弃未提交的修改?"), - ("Tab_CloseModifiedBody", "{0} has changes that were not applied. Closing the tab discards them.", - "{0} 有尚未应用的修改,关闭标签页会丢掉它们。"), + ("Tab_CloseModifiedTitle", "Discard unsaved changes?", "放弃未保存的修改?"), + ("Tab_CloseModifiedBody", "{0} has unsaved changes. Closing the tab discards them and can't be undone.", + "{0} 有未保存的修改,关闭标签页将放弃这些更改,且无法恢复。"), ("Tab_CloseDiscard", "Discard and close", "放弃并关闭"), ("Tab_Close", "Close", "关闭"), ("Tab_CloseOthers", "Close others", "关闭其他"), diff --git a/plugins/VelaShell.Plugin.Mongo/README.md b/plugins/VelaShell.Plugin.Mongo/README.md index 21f2a57..a3d6b16 100644 --- a/plugins/VelaShell.Plugin.Mongo/README.md +++ b/plugins/VelaShell.Plugin.Mongo/README.md @@ -2,7 +2,7 @@ > id `velashell.mongo` · 进程内装载 · 命令面板入口(`contributes.commands`,与 Docker 面板同一个路数) · `minSdkVersion` 2.0.2 -照设计稿 `velashell-plugin-mongo.pen`(仓库根,23 个画板)实现的 MongoDB 客户端: +照设计稿 `velashell-plugin-mongo.pen`(仓库根,24 个画板)实现的 MongoDB 客户端: Navicat 式的对象树与大图标工具栏,补齐 Compass 的聚合管道、Schema 分析、索引建议与 GridFS; 网格 / 树 / JSON 三视图共用一份数据,所有编辑先进暂存区,提交前可预览将执行的命令。 @@ -48,7 +48,7 @@ Navicat 式的对象树与大图标工具栏,补齐 Compass 的聚合管道、Sc | (稿外)系统库 | admin / config / local 默认不列(`showSystemDatabases`,高级选项);对象树表头的眼睛按钮在本次会话里切换;连接里点名的默认库照列 | Navicat 的默认;日常几乎不进这三个库,列着只会把用户库往下挤 | | 10 的「分组」 | 对话框里一栏(可选已有分组,也可新写);对象树里分了组的连接挂在可收起的组名节下面,不分组的在最上面 | — | | 01 / 13 对象树根上只画了一条连接 | 根上是**全部**已保存的连接(Navicat 的习惯):灰点没连、绿点连着、红点没连上;双击连上,连着的那条下面挂库;右键里有连接 / 断开 / 编辑 / 复制 / 删除 | 连接由插件自己管之后,树根就是连接列表 | -| 14 执行计划时编辑器收矮到 230 | 不动分界:切到执行计划页,编辑器与结果区的高度照用户拖过的分隔条 | 结果页与执行计划页是同一块面板的两个页签;自动收矮再在切回时重置成默认值,用户拖好的高度就白拖了 | +| 14 执行计划时编辑器收矮到 194 | 不动分界:切到执行计划页,编辑器与结果区的高度照用户拖过的分隔条 | 结果页与执行计划页是同一块面板的两个页签;自动收矮再在切回时重置成默认值,用户拖好的高度就白拖了 | | 01 对象树的分组文件夹右键只有刷新 | 「集合 / 视图 / GridFS 存储桶」分组(及其中对象、库行)右键可新建对应对象;桶行右键可上传文件 / 文件夹、删除存储桶 | 不打开对象列表也能就地新建;新建桶原先只能从已打开的桶标签里进 | | 会话标签上的连接名(「mongo-inner-01」) | 宿主标签条上是一个「MongoDB」面板;连接名写在对象树根、对象标签与状态条上 | 一个面板装着全部连接(与 Docker 面板一样);对象标签按连接分开,同名集合在两条连接里是两个标签 | | 22「连接中 / 连接失败」两张卡 | 照稿实现在插件里:连接时先开一个占位标签(「正在连接 X」+ 取消),连上了换成默认库的对象列表,没连上就原地变成「无法连接 X」+ 等宽字的原因 + 编辑连接 / 关闭标签页 / 重新连接;证书不受信任时多一个「信任此证书并重连」(只信这一张的指纹)。连接**中途断开**时内容区顶部一条横幅 | 证书信任原先是宿主的流程,连接自己管之后由插件给 | @@ -75,6 +75,24 @@ Navicat 式的对象树与大图标工具栏,补齐 Compass 的聚合管道、Sc 日期不再给"现在"那一条文字候选。 - **筛选框的 Enter**:补全弹层开着时是「接受补全」,关着才是「查找」。筛选框在编辑区上另挂了隧道处理器, 而同一元素上的隧道处理器按**注册的逆序**调用 —— 它比 `CodeEditor` 自己的先拿到 Enter,所以要先看 `CodeEditor.IsCompletionOpen`。 +- **新建索引**(设计稿 07b,`DesignIndexesView` + `DesignTabViewModel.NewIndex.cs`):Navicat 设计表的做法 —— 点「新建索引」后索引页下半部分 + 换成整宽编辑器(字段表 / 类型与选项 / 命令预览与预估),索引表末尾钉一行「新建」草稿(`DraftIndex`,与表里的行同一个 `IndexRow` 模型, + 不进 `Indexes`,所以重名检查、删除、隐藏都碰不到它)。字段表的行距是 28,`DesignIndexesView.RowPitch` 跟着它算拖动落点; + 上移 / 下移按钮走 `MoveKeyUpCommand / MoveKeyDownCommand`,与拖动是同一个 `MoveKey`。编辑器开着时索引表的滚动区 + (`ScrollViewer#TableScroll.compact`)最多 180 高。 +- **查询的执行目标**(`QueryTabViewModel.Target.cs`):工具行下行的「连接 ▾ 数据库 ▾」。标签的连接定死在构造里, + 所以切换连接是 `MongoWorkspaceViewModel.ReplaceTab` 原位换一个新连接上的查询标签(`CarryOver` 带过文本、未保存基线、光标、 + maxTimeMS);没连着的连接走 `ConnectAsync(entry, quiet: true)`(不开占位标签、不动树的选中、不开对象列表)。 + 诊断的第二段(`CheckCollectionsAsync`,异步)查 `db.集合` 在生效的库里有没有,没有就是一条 Warning; + 脚本里建删了集合(`ChangesCatalog`)时 `ForgetCollections` 丢掉那个库的缓存。 +- **GridFS 拖放区可点**:`DropZone` 的 Tapped 走 `UploadFilesCommand`;源头在框里那两个链接按钮里时不再重复弹(它们自己处理)。 +- **服务器监控**(`MonitorTabView`):两行图表 `*` : `*` 分高度,中间 `hsplit` 可拖;两张柱状图 `MinSlots` = 满窗口的柱数 + (`ChartSlots`),刚开始采样时柱子靠右、保持最终宽度。存储 Top 的集合名列是 `Width="Auto" SharedSizeGroup="StorageName"` + (各行对齐、默认放下最长的名字)再挂 `ui:TableColumns` 组 `storage`(拖宽、双击自适应);名字是两个 `Run`(库名淡色 + 集合名), + 两个 `Run` 必须写在同一行,中间的换行会被画成一个空格。 +- **有未保存修改的标签**:标签上是橙点,鼠标移到标签上换成 ×(`MongoWorkspaceView` 里 `Ellipse.dirty` / `Button.tabclose` 两组样式, + 别再给它们绑 `IsVisible` —— 本地值会压过 `:pointerover` 样式,× 就永远出不来,有修改的标签就关不掉了)。 + 点 × 或 Ctrl+W 都先过 `WorkspaceTab.ConfirmCloseAsync`:「放弃未保存的修改?」,确认才关。 - **代码里建的右键菜单**一律走 `MenuKit.Command`:普通项不设 `Foreground` —— 哪怕设成 `null`,本地值也会压过宿主 `ContextMenu MenuItem` 样式,文字透明到悬停才露出来。 - **绑定不穿过可空的中段**:`{Binding Dialog.Title}`、`{Binding Editor.HasError}` 这类路径在中段为 null 时 diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Charts/BarChart.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Charts/BarChart.cs index 6373c83..af794e6 100644 --- a/plugins/VelaShell.Plugin.Mongo/Ui/Charts/BarChart.cs +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Charts/BarChart.cs @@ -49,11 +49,18 @@ public sealed class BarChart : Control public static readonly StyledProperty XLabelCountProperty = AvaloniaProperty.Register(nameof(XLabelCount), 5); + /// + /// 至少按这么多根柱分格(0 = 有几根分几格)。时间序列刚开始采样时只有寥寥几根:不设它,每根柱都被拉得和整张图一样宽; + /// 设成满窗口的根数,柱子从一开始就是最终的宽度,靠右排(最新的贴着「现在」),左边空着等数据填进来。 + /// + public static readonly StyledProperty MinSlotsProperty = + AvaloniaProperty.Register(nameof(MinSlots)); + private int _hover = -1; static BarChart() { - AffectsRender(SeriesProperty, LabelsProperty, MaxValueProperty, ShowAxesProperty, GapRatioProperty); + AffectsRender(SeriesProperty, LabelsProperty, MaxValueProperty, ShowAxesProperty, GapRatioProperty, MinSlotsProperty); } /// 数据。 @@ -91,6 +98,13 @@ public double GapRatio set => SetValue(GapRatioProperty, value); } + /// 至少分几格。 + public int MinSlots + { + get => GetValue(MinSlotsProperty); + set => SetValue(MinSlotsProperty, value); + } + /// 横轴标签个数。 public int XLabelCount { @@ -114,6 +128,12 @@ public bool ShowTooltip private int Count => Series is { Count: > 0 } s ? s.Max(static x => x.Values.Count) : 0; + /// 分几格(柱数与 取大)。 + private int Slots => Math.Max(Count, Math.Max(0, MinSlots)); + + /// 第一根柱前面空着的格数(柱靠右排)。 + private int Offset => Slots - Count; + /// protected override void OnPointerMoved(PointerEventArgs e) { @@ -121,7 +141,7 @@ protected override void OnPointerMoved(PointerEventArgs e) int count = Count; Rect plot = Plot; double x = e.GetPosition(this).X; - int index = count == 0 || plot.Width <= 0 ? -1 : (int)((x - plot.X) / (plot.Width / count)); + int index = count == 0 || plot.Width <= 0 ? -1 : (int)Math.Floor((x - plot.X) / (plot.Width / Slots)) - Offset; index = index < 0 || index >= count ? -1 : index; if (index != _hover) { @@ -173,11 +193,13 @@ public override void Render(DrawingContext context) } } - double slot = plot.Width / count; + double slot = plot.Width / Slots; + // 柱靠右排:origin 是第 0 根柱所在格的左边。 + double origin = plot.Left + (slot * Offset); double barWidth = Math.Max(1, slot * (1 - Math.Clamp(GapRatio, 0, 0.9))); for (int i = 0; i < count; i++) { - double x = plot.Left + (slot * i) + ((slot - barWidth) / 2); + double x = origin + (slot * i) + ((slot - barWidth) / 2); double bottom = plot.Bottom; int top = -1; for (int k = series.Count - 1; k >= 0; k--) @@ -212,30 +234,44 @@ public override void Render(DrawingContext context) } if (i == _hover) { - context.FillRectangle(ThemeBrushes.GetDim("VelaTextPrimary", 0.08), new Rect(plot.Left + (slot * i), plot.Top, slot, plot.Height)); + context.FillRectangle(ThemeBrushes.GetDim("VelaTextPrimary", 0.08), new Rect(origin + (slot * i), plot.Top, slot, plot.Height)); } } if (ShowAxes && Labels is { Count: > 0 } labels) { int n = Math.Clamp(XLabelCount, 2, labels.Count); - IEnumerable picks = Enumerable.Range(0, n).Select(k => (int)Math.Round((double)k * (labels.Count - 1) / (n - 1))).Distinct(); - foreach (int i in picks) + var placed = new List<(FormattedText Text, double X)>(); + foreach (int i in Enumerable.Range(0, n).Select(k => (int)Math.Round((double)k * (labels.Count - 1) / (n - 1))).Distinct()) { var text = new FormattedText(labels[i], CultureInfo.InvariantCulture, FlowDirection.LeftToRight, typeface, 9, muted); - double cx = plot.Left + (slot * i) + (slot / 2) - (text.Width / 2); - cx = Math.Clamp(cx, plot.Left, Math.Max(plot.Left, plot.Right - text.Width)); - context.DrawText(text, new Point(cx, plot.Bottom + 3)); + double cx = origin + (slot * i) + (slot / 2) - (text.Width / 2); + placed.Add((text, Math.Clamp(cx, plot.Left, Math.Max(plot.Left, plot.Right - text.Width)))); + } + // 图窄时标签会叠在一起(最后那个还带着「现在」):首尾一定画,中间的挨不下就跳过。 + const double labelGap = 8; + double right = double.NegativeInfinity; + double lastLeft = placed.Count > 1 ? placed[^1].X : double.PositiveInfinity; + for (int k = 0; k < placed.Count; k++) + { + (FormattedText text, double x) = placed[k]; + bool last = k == placed.Count - 1; + if (!last && k > 0 && (x < right + labelGap || x + text.Width > lastLeft - labelGap)) + { + continue; + } + context.DrawText(text, new Point(x, plot.Bottom + 3)); + right = x + text.Width; } } if (ShowTooltip && _hover >= 0) { - DrawTooltip(context, typeface, series, plot, slot); + DrawTooltip(context, typeface, series, plot, slot, origin); } } - private void DrawTooltip(DrawingContext context, Typeface typeface, IReadOnlyList series, Rect plot, double slot) + private void DrawTooltip(DrawingContext context, Typeface typeface, IReadOnlyList series, Rect plot, double slot, double origin) { IBrush primary = ThemeBrushes.Get("VelaTextPrimary", Brushes.White); IBrush secondary = ThemeBrushes.Get("VelaTextSecondary", Brushes.LightGray); @@ -249,10 +285,10 @@ private void DrawTooltip(DrawingContext context, Typeface typeface, IReadOnlyLis const double row = 15; double width = 150; double height = 10 + (title.Length > 0 ? row : 0) + (lines.Count * row); - double x = plot.Left + (slot * _hover) + slot + 6; + double x = origin + (slot * _hover) + slot + 6; if (x + width > Bounds.Width) { - x = plot.Left + (slot * _hover) - width - 6; + x = origin + (slot * _hover) - width - 6; } double y = plot.Top + 4; var box = new Rect(Math.Max(0, x), y, width, height); diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignIndexesView.axaml b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignIndexesView.axaml index 7ac8bf7..47c2012 100644 --- a/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignIndexesView.axaml +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignIndexesView.axaml @@ -5,343 +5,502 @@ x:DataType="ui:DesignTabViewModel"> + + + + + + + - - - - - - - - - - - - + + + + + + + + + + + - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + - - - + + + + + + + - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - + + + + + - - + + + + + + + + + + + - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + - - - - - - - + + + + + + + + + + + + + + + + + + + + + + - - - + + + + + + + + + diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignIndexesView.axaml.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignIndexesView.axaml.cs index 76a128d..d871e3f 100644 --- a/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignIndexesView.axaml.cs +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignIndexesView.axaml.cs @@ -11,13 +11,13 @@ namespace VelaShell.Plugin.Mongo.Ui; /// /// 集合设计 · 索引页。代码里只做两件 AXAML 不好做的事: -/// 字段 / 方向下拉的菜单(弹出层在独立可视树里,菜单项要直达行模型,现建最省事), -/// 以及字段行按住左侧把手拖动排序。 +/// 索引编辑器字段表里字段 / 方向下拉的菜单(弹出层在独立可视树里,菜单项要直达行模型,现建最省事), +/// 以及字段行按住序号列的把手拖动排序(行尾的上移 / 下移按钮走视图模型命令)。 /// public sealed partial class DesignIndexesView : UserControl { - /// 字段行的行距(26 高 + 6 间距)。 - private const double RowPitch = 32; + /// 字段表的行距(28 高、行间无空隙)。 + private const double RowPitch = 28; private NewKeyRow? _dragging; diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignModels.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignModels.cs index 13c346d..88db60e 100644 --- a/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignModels.cs +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignModels.cs @@ -225,32 +225,34 @@ internal sealed class AdviceCard public required Func Action { get; init; } } -/// 新建索引面板里的一行字段。 +/// 索引编辑器「字段」表里的一行(键序即这一行在表里的位置)。 internal sealed class NewKeyRow : ObservableObject { private readonly Action _changed; - private readonly Func _tokenOf; + private readonly Func _optionOf; private readonly Func _label; private string _field; private int _direction; private string _role; private string _kind = "normal"; private bool _isFirst; + private bool _isLast; + private int _number; /// 构造。 /// 字段。 /// 方向(1 / -1)。 /// ESR 角色文字(等值 / 排序 / 范围);未知为空。 - /// 字段 → 类型色令牌(来自抽样)。 - /// 任何改动后通知面板重算命令预览。 + /// 字段 → 抽样到的那一项(类型色、类型名);抽样里没有为 。 + /// 任何改动后通知编辑器重算命令预览。 /// 键值 → 方向下拉上的字(1 升序)。 - public NewKeyRow(string field, int direction, string role, Func tokenOf, Action changed, Func label) + public NewKeyRow(string field, int direction, string role, Func optionOf, Action changed, Func label) { _label = label; _field = field; _direction = direction; _role = role; - _tokenOf = tokenOf; + _optionOf = optionOf; _changed = changed; } @@ -262,12 +264,38 @@ public string Field { if (SetProperty(ref _field, value ?? "")) { - RaisePropertyChanged(nameof(SwatchToken)); + RaisePropertiesChanged(nameof(SwatchToken), nameof(KindText)); _changed(); } } } + /// 序号(1 起;表里第一列)。 + public int Number + { + get => _number; + set => SetProperty(ref _number, value); + } + + /// 是最后一行(「下移」不可用)。 + public bool IsLast + { + get => _isLast; + set + { + if (SetProperty(ref _isLast, value)) + { + RaisePropertyChanged(nameof(CanMoveDown)); + } + } + } + + /// 能上移。 + public bool CanMoveUp => !_isFirst; + + /// 能下移。 + public bool CanMoveDown => !_isLast; + /// 方向(1 / -1)。 public int Direction { @@ -298,11 +326,14 @@ public string Role /// 有角色小标签。 public bool HasRole => _role.Length > 0; - /// 字段类型色。 - public string SwatchToken => _tokenOf(_field); + /// 字段类型色(抽样里没有就是灰的)。 + public string SwatchToken => _optionOf(_field)?.Token ?? "VelaTextMuted"; + + /// 抽样到的类型名(String);抽样里没有为 —。 + public string KindText => _optionOf(_field)?.KindName ?? "—"; - /// 抽样结果到了:类型色要重算。 - internal void RefreshSwatch() => RaisePropertyChanged(nameof(SwatchToken)); + /// 抽样结果到了:类型色与类型名要重算。 + internal void RefreshSwatch() => RaisePropertiesChanged(nameof(SwatchToken), nameof(KindText)); /// 当前索引类型(面板的分段:normal / text / 2dsphere / hashed / wildcard)。 public string Kind @@ -325,7 +356,7 @@ public bool IsFirst { if (SetProperty(ref _isFirst, value)) { - RaisePropertiesChanged(nameof(DirectionValue), nameof(HasDirection), nameof(DirectionText)); + RaisePropertiesChanged(nameof(DirectionValue), nameof(HasDirection), nameof(DirectionText), nameof(CanMoveUp)); } } } diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignTabViewModel.NewIndex.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignTabViewModel.NewIndex.cs index 4b18ad8..28db521 100644 --- a/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignTabViewModel.NewIndex.cs +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignTabViewModel.NewIndex.cs @@ -6,10 +6,18 @@ namespace VelaShell.Plugin.Mongo.Ui; -/// 集合设计 · 右侧「新建索引」面板(设计稿 07)。 +/// +/// 集合设计 · 索引页下半部分的「新建索引」编辑器(设计稿 07,Navicat 的设计表做法)。 +/// +/// 编辑器开着时索引表末尾多一行「新建」草稿,跟着表单实时变(键、类型、属性、预估大小), +/// 下半部分整宽分三栏:字段表(序号 / 字段 / 排序 / ESR / 抽样类型 / 上移下移删除)、类型与选项、命令预览与预估。 +/// 原先挤在右侧 340px 面板里,字段行一窄就点错 —— 摊开之后每一列都有自己的位置。 +/// +/// internal sealed partial class DesignTabViewModel { private bool _isCreateOpen; + private IndexRow? _draftIndex; private string _newKind = "normal"; private bool _newUnique; private bool _newSparse; @@ -28,13 +36,38 @@ internal sealed partial class DesignTabViewModel private bool _isCreating; private IReadOnlyList _fieldOptions = []; - /// 面板开着。 + /// 编辑器开着(下半部分从索引建议换成编辑器,索引表收矮、末尾挂草稿行)。 public bool IsCreateOpen { get => _isCreateOpen; - set => SetProperty(ref _isCreateOpen, value); + set + { + if (SetProperty(ref _isCreateOpen, value)) + { + RaisePropertyChanged(nameof(HasDraft)); + } + } + } + + /// 索引表末尾的「新建」草稿行;还没有字段时为 。 + public IndexRow? DraftIndex + { + get => _draftIndex; + private set + { + if (SetProperty(ref _draftIndex, value)) + { + RaisePropertyChanged(nameof(HasDraft)); + } + } } + /// 草稿行可见(创建期间表里挂的是「构建中」那一行,草稿让位)。 + public bool HasDraft => _isCreateOpen && _draftIndex is not null && !_isCreating; + + /// 编辑器底栏左侧那句「将在 shop.orders 上执行 createIndexes」。 + public string NewTarget => Loc.Format("Design_EditorTarget", Namespace); + /// 键字段行。 public ObservableCollection NewKeys { get; } = []; @@ -214,6 +247,7 @@ private set { if (SetProperty(ref _isCreating, value)) { + RaisePropertyChanged(nameof(HasDraft)); CreateIndexCommand.RaiseCanExecuteChanged(); } } @@ -247,6 +281,12 @@ private set /// 删除一个字段行。 public RelayCommand RemoveKeyCommand { get; private set; } = null!; + /// 字段行上移一位(键序前移)。 + public RelayCommand MoveKeyUpCommand { get; private set; } = null!; + + /// 字段行下移一位。 + public RelayCommand MoveKeyDownCommand { get; private set; } = null!; + /// 创建索引。 public AsyncCommand CreateIndexCommand { get; private set; } = null!; @@ -261,6 +301,8 @@ private void InitializeNewIndex() RenumberKeys(); RecomputeNewIndex(); }); + MoveKeyUpCommand = new(row => MoveKey(NewKeys.IndexOf(row), NewKeys.IndexOf(row) - 1)); + MoveKeyDownCommand = new(row => MoveKey(NewKeys.IndexOf(row), NewKeys.IndexOf(row) + 1)); CreateIndexCommand = new(CreateIndexAsync, () => !_isCreating && _newError.Length == 0 && NewKeys.Any(static k => k.Field.Length > 0)); } @@ -303,7 +345,7 @@ internal void OpenCreatePanel(BsonDocument? key, EsrShape? shape) /// 加一行字段。 internal void AddKey(string field, int direction, string role, bool recompute = true) { - var row = new NewKeyRow(field, direction, role.Length > 0 ? role : GuessRole(field), TokenOf, RecomputeNewIndex, DirectionLabel) + var row = new NewKeyRow(field, direction, role.Length > 0 ? role : GuessRole(field), OptionOf, RecomputeNewIndex, DirectionLabel) { Kind = _newKind }; @@ -331,7 +373,9 @@ private void RenumberKeys() { for (int i = 0; i < NewKeys.Count; i++) { + NewKeys[i].Number = i + 1; NewKeys[i].IsFirst = i == 0; + NewKeys[i].IsLast = i == NewKeys.Count - 1; } } @@ -341,9 +385,8 @@ internal string DirectionLabel(BsonValue value) => ? Loc[value.ToDouble() < 0 ? "Design_DirDesc" : "Design_DirAsc"] : value.ToString() ?? ""; - /// 字段 → 类型色(抽样里没有就是灰的)。 - private string TokenOf(string field) => - _fieldOptions.FirstOrDefault(f => f.Path == field)?.Token ?? "VelaTextMuted"; + /// 字段 → 抽样到的那一项;抽样里没有为 。 + private FieldOption? OptionOf(string field) => _fieldOptions.FirstOrDefault(f => f.Path == field); /// ESR 角色 → 小标签文字。 private string RoleText(string? role) => role switch @@ -492,15 +535,35 @@ private void RecomputeNewIndex() NewCommand = key.ElementCount == 0 ? "" : $"{ShellRef}.createIndex(\n {BsonText.Literal(key)}" + (options.ElementCount > 0 ? $",\n {BsonText.Literal(options)}" : "") + "\n)"; - UpdateImpact(key); + long size = UpdateImpact(key); + DraftIndex = key.ElementCount == 0 ? null : Draft(key, options, size); CreateIndexCommand?.RaiseCanExecuteChanged(); } + /// 草稿行:与表里的行同一个模型,大小列写预估,使用列写「待创建」。 + private IndexRow Draft(BsonDocument key, BsonDocument options, long size) + { + string name = _newName.Trim().Length > 0 ? _newName.Trim() : IndexAdvisor.DefaultName(key); + var spec = new BsonDocument { { "key", key }, { "name", name } }; + spec.Merge(options, overwriteExistingElements: false); + IndexForm form = IndexAdvisor.ShapeOf(spec); + return new IndexRow + { + Name = name, + Spec = spec, + Keys = [.. IndexAdvisor.DisplayKeys(spec).Select(static k => new IndexKeyChip(k.Field, k.Value))], + Form = form, + TypeText = FormText(form), + Attributes = Attributes(spec, 0), + SizeText = "≈ " + BsonText.Bytes(size) + }; + } + /// /// 预估:大小 ≈ 文档数 ×(各键字段平均值长 + 每项开销),构建耗时按数据量粗估; - /// 再写一句当前版本的构建方式(4.4 起的优化构建只在首尾短暂持排他锁)。 + /// 再写一句当前版本的构建方式(4.4 起的优化构建只在首尾短暂持排他锁)。返回预估大小(字节)。 /// - private void UpdateImpact(BsonDocument key) + private long UpdateImpact(BsonDocument key) { long count = _indexStats?.Count ?? _stats?.Count ?? 0; long data = _indexStats?.Size ?? _stats?.Size ?? 0; @@ -525,6 +588,7 @@ private void UpdateImpact(BsonDocument key) detail += " " + Loc["Design_ImpactPartial"]; } NewImpactDetail = detail; + return size; } /// diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/GridFs/GridFsTabView.axaml b/plugins/VelaShell.Plugin.Mongo/Ui/GridFs/GridFsTabView.axaml index 2c35a2d..a2af11a 100644 --- a/plugins/VelaShell.Plugin.Mongo/Ui/GridFs/GridFsTabView.axaml +++ b/plugins/VelaShell.Plugin.Mongo/Ui/GridFs/GridFsTabView.axaml @@ -121,7 +121,14 @@ - + + + + + + + + + + + + @@ -366,9 +385,9 @@ - - diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Shell/MongoWorkspaceViewModel.Connections.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Shell/MongoWorkspaceViewModel.Connections.cs index 5a21976..100a180 100644 --- a/plugins/VelaShell.Plugin.Mongo/Ui/Shell/MongoWorkspaceViewModel.Connections.cs +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Shell/MongoWorkspaceViewModel.Connections.cs @@ -124,8 +124,12 @@ internal async Task SaveProfileAsync(MongoProfile profile, string? password, boo /// 连一条。已连着就选中它;正在连就什么都不做。 /// 连接。 + /// + /// 安静地连(查询编辑器切换连接时):不开「正在连接」占位标签、连上后不改对象树的选中、不开对象列表 —— + /// 调用方自己接着用这条连接;失败只记在 上,由调用方报。 + /// /// 任务。 - internal async Task ConnectAsync(ConnectionEntry entry) + internal async Task ConnectAsync(ConnectionEntry entry, bool quiet = false) { if (entry.State == ConnectionState.Connecting) { @@ -133,14 +137,17 @@ internal async Task ConnectAsync(ConnectionEntry entry) } if (entry.State == ConnectionState.Connected) { - SelectedNode = entry.Root; + if (!quiet) + { + SelectedNode = entry.Root; + } return; } using var cts = new CancellationTokenSource(); entry.Connecting = cts; entry.Failure = null; entry.State = ConnectionState.Connecting; - ConnectionStateTabViewModel stateTab = Activate(new ConnectionStateTabViewModel(this, entry)); + ConnectionStateTabViewModel? stateTab = quiet ? null : Activate(new ConnectionStateTabViewModel(this, entry)); MongoLink link; try { @@ -171,7 +178,7 @@ internal async Task ConnectAsync(ConnectionEntry entry) return; } CloseTabsWhere(t => ReferenceEquals(t, stateTab)); - await AttachAsync(entry, link).ConfigureAwait(true); + await AttachAsync(entry, link, quiet).ConfigureAwait(true); entry.Profile.LastConnectedAt = DateTimeOffset.Now; try { @@ -184,16 +191,21 @@ internal async Task ConnectAsync(ConnectionEntry entry) } } - /// 连上之后:建会话、读库列表、展开默认库,开它的对象列表。 + /// 连上之后:建会话、读库列表、展开默认库,开它的对象列表( 时只建会话、读库列表)。 /// 连接。 /// 连上的一条。 + /// 不动对象树的选中、不开对象列表。 /// 会话。 - internal async Task AttachAsync(ConnectionEntry entry, MongoLink link) + internal async Task AttachAsync(ConnectionEntry entry, MongoLink link, bool quiet = false) { var session = new MongoSession(this, entry, link); entry.Session = session; entry.State = ConnectionState.Connected; await session.ReloadTreeAsync().ConfigureAwait(true); + if (quiet) + { + return session; + } string? db = session.DefaultDatabase(); if (db is not null && session.Root.Children.FirstOrDefault(n => n.Name == db) is { } dbNode) { diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Shell/MongoWorkspaceViewModel.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Shell/MongoWorkspaceViewModel.cs index 529bd2b..d25a7ca 100644 --- a/plugins/VelaShell.Plugin.Mongo/Ui/Shell/MongoWorkspaceViewModel.cs +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Shell/MongoWorkspaceViewModel.cs @@ -277,6 +277,30 @@ internal T Activate(T tab) where T : WorkspaceTab return tab; } + /// + /// 原位换掉一个标签:查询编辑器切换连接时,新标签属于另一条连接(标签的连接定死在构造里), + /// 位置与"是不是活动标签"照旧,旧标签直接释放(不问未保存 —— 文本已经带进了新标签)。 + /// + internal void ReplaceTab(WorkspaceTab old, WorkspaceTab replacement) + { + int index = Tabs.IndexOf(old); + if (index < 0) + { + Activate(replacement); + return; + } + replacement.Closer = CloseTabAsync; + bool wasActive = ReferenceEquals(_activeTab, old); + Tabs[index] = replacement; + if (wasActive) + { + ActiveTab = replacement; + } + old.Dispose(); + UpdateCurrentSession(); + _ = LoadTabAsync(replacement); + } + private async Task LoadTabAsync(WorkspaceTab tab) { try diff --git a/tests/VelaShell.Plugin.Mongo.Tests/DesignTests.cs b/tests/VelaShell.Plugin.Mongo.Tests/DesignTests.cs index 5cbbf01..10cc0c8 100644 --- a/tests/VelaShell.Plugin.Mongo.Tests/DesignTests.cs +++ b/tests/VelaShell.Plugin.Mongo.Tests/DesignTests.cs @@ -87,12 +87,32 @@ public void Indexes_rules_and_options_round_trip_on_a_scratch_database() => Scre tab.MoveKey(1, 0); Assert.AreEqual("price_-1_kind_1", tab.NewName, "the name follows the key order until edited by hand"); tab.MoveKey(0, 1); + + // ── 编辑器的字段表与草稿行:序号、上移 / 下移按钮、末尾的「新建」行跟着表单变 ── + Assert.AreEqual(1, tab.NewKeys[0].Number); + Assert.IsFalse(tab.NewKeys[0].CanMoveUp); + Assert.IsFalse(tab.NewKeys[1].CanMoveDown); + tab.MoveKeyUpCommand.Execute(tab.NewKeys[1]); + Assert.AreEqual("price", tab.NewKeys[0].Field); + Assert.AreEqual(1, tab.NewKeys[0].Number); + Assert.AreEqual("price_-1_kind_1", tab.DraftIndex?.Name); + tab.MoveKeyDownCommand.Execute(tab.NewKeys[0]); + Assert.AreEqual("kind", tab.NewKeys[0].Field); + Assert.IsTrue(tab.HasDraft); + Assert.AreEqual("kind_1_price_-1", tab.DraftIndex!.Name); + CollectionAssert.AreEqual(new[] { "kind", "price" }, tab.DraftIndex.Keys.Select(static k => k.Field).ToArray()); + Assert.AreEqual("复合", tab.DraftIndex.TypeText); + Assert.IsTrue(tab.DraftIndex.Attributes.Any(static a => a.Text == "部分")); + StringAssert.StartsWith(tab.DraftIndex.SizeText, "≈ "); + Assert.IsFalse(tab.Indexes.Any(static r => r.Name == "kind_1_price_-1"), "the draft row is not an index yet"); + Assert.AreEqual($"将在 {db}.items 上执行 createIndexes", tab.NewTarget); tab.CreateIndexCommand.Execute(null); await WaitAsync(() => tab.Indexes.Any(static r => r.Name == "kind_1_price_-1" && !r.IsBuilding) && !tab.IsCreating); IndexRow created = tab.Indexes.Single(static r => r.Name == "kind_1_price_-1"); Assert.AreEqual("复合", created.TypeText); Assert.IsTrue(created.Attributes.Any(static a => a.Text == "部分")); Assert.IsFalse(tab.IsCreateOpen); + Assert.IsFalse(tab.HasDraft); // 多键:tags 是数组,执行计划里 isMultiKey 为真。 await items.Indexes.CreateOneAsync(new CreateIndexModel(new BsonDocument("tags", 1))); diff --git a/tests/VelaShell.Plugin.Mongo.Tests/MonitorTests.cs b/tests/VelaShell.Plugin.Mongo.Tests/MonitorTests.cs index ab33abb..6fe1911 100644 --- a/tests/VelaShell.Plugin.Mongo.Tests/MonitorTests.cs +++ b/tests/VelaShell.Plugin.Mongo.Tests/MonitorTests.cs @@ -1,5 +1,7 @@ using System.Diagnostics; +using Avalonia.Controls; using Avalonia.Media.Imaging; +using Avalonia.VisualTree; using MongoDB.Bson; using MongoDB.Driver; using VelaShell.Plugin.Mongo.Analysis; @@ -250,6 +252,23 @@ await load.Collection(db, "orders").Indexes.CreateOneAsync( Assert.IsTrue(tab.OpsSeries[0].Values.Count >= 15, "one bar per sample while the window fills"); Assert.IsTrue(tab.Events.Any(e => e.Kind == "slow"), "the slow query in our temp database shows up as an event"); Assert.IsTrue(tab.HasMembers, "the local test server is a one-member replica set"); + + // 大窗口(用户报的 2000 × 1370 那种):两行图表平分高度,存储 Top 的集合名列宽够放下最长的名字,不被省略。 + bench.Window.Width = 1990; + bench.Window.Height = 1360; + await Screens.PumpAsync(40); + Screens.Capture(bench.Window, "11-monitor-large"); + MonitorTabView view = bench.Window.GetVisualDescendants().OfType().First(static v => v.IsEffectivelyVisible); + BarChart[] charts = [.. view.GetVisualDescendants().OfType()]; + Assert.HasCount(2, charts); + Assert.IsLessThan(40, Math.Abs(charts[0].Bounds.Height - charts[1].Bounds.Height), "the two chart rows share the height"); + Grid[] storageRows = [.. view.GetVisualDescendants().OfType().Where(static g => TableColumns.GetRow(g) == "storage")]; + Assert.IsNotEmpty(storageRows); + foreach (Grid row in storageRows) + { + TextBlock name = row.Children.OfType().First(); + Assert.IsFalse(name.TextLayout.TextLines.Any(static l => l.HasCollapsed), $"collection name trimmed: {tab.Storage[0].Name}"); + } } finally { diff --git a/tests/VelaShell.Plugin.Mongo.Tests/QueryTests.cs b/tests/VelaShell.Plugin.Mongo.Tests/QueryTests.cs index 824f7f5..cd64da8 100644 --- a/tests/VelaShell.Plugin.Mongo.Tests/QueryTests.cs +++ b/tests/VelaShell.Plugin.Mongo.Tests/QueryTests.cs @@ -1,8 +1,13 @@ +using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Primitives; +using Avalonia.Headless; +using Avalonia.Input; using Avalonia.Media.Imaging; using Avalonia.VisualTree; using MongoDB.Bson; +using MongoDB.Driver; +using VelaShell.Plugin.Mongo.Core; using VelaShell.Plugin.Mongo.Shell; using VelaShell.Plugin.Mongo.Ui; @@ -55,6 +60,132 @@ private static QueryTabViewModel Open(Workbench bench, string text, string datab return tab; } + private static async Task WaitAsync(Func condition, int rounds = 300) + { + for (int i = 0; i < rounds && !condition(); i++) + { + await Screens.PumpAsync(2); + } + Assert.IsTrue(condition(), "timed out waiting for the condition"); + } + + /// + /// 有未保存修改的标签照样能关:平时标签上是橙点、× 藏着,鼠标移到标签上换成 ×; + /// 点 × 先弹「放弃未保存的修改?」—— 取消就留着,确认「放弃并关闭」就关掉。 + /// + [TestMethod] + public void Modified_tab_closes_after_confirming_discard() => Screens.OnUi(async () => + { + await TestServer.RequireAsync(); + await using Workbench bench = await Screens.OpenWorkbenchAsync(); + bench.Session.OpenQuery("shop", "db.orders.find({})"); + await Screens.PumpAsync(); + QueryTabViewModel tab = bench.ViewModel.Tabs.OfType().Last(); + tab.Text += " "; + await Screens.PumpAsync(5); + Assert.IsTrue(tab.IsModified); + + ListBox strip = bench.View.GetVisualDescendants().OfType().Single(static l => l.Name == "TabStrip"); + var item = (ListBoxItem)strip.ContainerFromItem(tab)!; + Button close = item.GetVisualDescendants().OfType