diff --git a/AGENTS.md b/AGENTS.md index a31757e..f1c5440 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,7 +62,8 @@ dotnet build build/PluginBundle.proj -c Release -t:PackAllVpx -p:VelaSigningKey= 各插件的设计取证也在 velashell-docs:Redis 见 [`zh/host/Redis客户端插件化调研与设计.md`](https://github.com/VelaShellLabs/velashell-docs/blob/main/zh/host/Redis客户端插件化调研与设计.md), S3 见 [`zh/host/S3协议插件化设计.md`](https://github.com/VelaShellLabs/velashell-docs/blob/main/zh/host/S3协议插件化设计.md), -Telnet / 串口见 [`zh/host/Telnet与串口可行性调研.md`](https://github.com/VelaShellLabs/velashell-docs/blob/main/zh/host/Telnet与串口可行性调研.md)。 +Telnet / 串口见 [`zh/host/Telnet与串口可行性调研.md`](https://github.com/VelaShellLabs/velashell-docs/blob/main/zh/host/Telnet与串口可行性调研.md), +MongoDB 见 [`zh/host/MongoDB工作台插件设计.md`](https://github.com/VelaShellLabs/velashell-docs/blob/main/zh/host/MongoDB工作台插件设计.md)。 **改了这些插件的设计取舍,要回写对应那篇。** Docker 面板是个例外:它从独立仓库 `VelaShell.Plugin.DockerPanel` 并进来(2026-09-11), diff --git a/Directory.Packages.props b/Directory.Packages.props index 8c332c9..8aac9b3 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,7 +1,7 @@ - + - + + + - + + 漂了**在本仓库一个测试都不会红**:要等用户装上插件、打开一个 compose.yaml 才炸。 + MongoDB 插件的查询编辑器 / JSON 视图同样只在编译期引它;它的测试工程要自己带运行时那份(测试宿主扮演装载方)。 --> + + + + + + + + + + + + + + "\$[A-Za-z]\w*"(?=\s*:) + "(?:[^"\\]|\\.)*"(?=\s*:) + + " + " + + + + + + true + false + + + null + + -?\b\d+(\.\d+)?([eE][+-]?\d+)?\b + + diff --git a/plugins/VelaShell.Plugin.Mongo/Syntax/Mongosh.xshd b/plugins/VelaShell.Plugin.Mongo/Syntax/Mongosh.xshd new file mode 100644 index 0000000..bd7b2e2 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Syntax/Mongosh.xshd @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + "\$[A-Za-z_][\w.]*" + '\$[A-Za-z_][\w.]*' + + + " + " + + + + + + ' + ' + + + + + + + use + db + show + const + let + var + new + return + function + + + true + false + + + null + undefined + + + \bObjectId\b + \b(ISODate|Date|NumberDecimal|NumberLong|NumberInt|NumberDouble|Decimal128|Long|Int32|Double|BinData|UUID|Timestamp|MinKey|MaxKey|RegExp|HexData|MD5)\b + \$[A-Za-z][\w]* + \.[A-Za-z_]\w*(?=\s*\() + \b-?(0x[0-9a-fA-F]+|\d+(\.\d+)?([eE][+-]?\d+)?)\b + + diff --git a/plugins/VelaShell.Plugin.Mongo/Transfer/BsonDump.cs b/plugins/VelaShell.Plugin.Mongo/Transfer/BsonDump.cs new file mode 100644 index 0000000..b8d645f --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Transfer/BsonDump.cs @@ -0,0 +1,159 @@ +using System.Buffers.Binary; +using System.IO.Compression; +using MongoDB.Bson; +using MongoDB.Bson.IO; +using MongoDB.Bson.Serialization; +using VelaShell.Plugin.Mongo.Core; + +namespace VelaShell.Plugin.Mongo.Transfer; + +/// +/// mongodump 兼容的转储布局:<目录>/<库>/<集合>.bson(裸 BSON 文档首尾相接) +/// + <集合>.metadata.json({options, indexes, uuid, collectionName, type},Canonical EJSON)。 +/// gzip 模式下两个文件都带 .gz 后缀 —— 与 mongodump --gzip 一致,mongorestore --gzip 直接认。 +/// +internal static class BsonDump +{ + /// 某个集合在转储目录里的数据文件路径。 + public static string DataPath(string root, string database, string collection, bool gzip) => + Path.Combine(root, TransferText.SafeFileName(database), TransferText.SafeFileName(collection) + (gzip ? ".bson.gz" : ".bson")); + + /// 某个集合的元数据文件路径。 + public static string MetadataPath(string root, string database, string collection, bool gzip) => + Path.Combine(root, TransferText.SafeFileName(database), TransferText.SafeFileName(collection) + (gzip ? ".metadata.json.gz" : ".metadata.json")); + + /// + /// 拼元数据。索引规格去掉 ns(4.4 之前的服务器会带,mongorestore 到新版本时会被拒)。 + /// 视图的 type 是 view,没有索引,也没有数据文件。 + /// + /// 集合信息(listCollections 的一行)。 + /// 索引规格(listIndexes 原文)。 + /// 集合 UUID(listCollections 的 info.uuid);没有为 。 + public static BsonDocument Metadata(CollectionInfo info, IReadOnlyList indexes, Guid? uuid) + { + var cleaned = new BsonArray(); + foreach (BsonDocument index in indexes) + { + BsonDocument copy = index.DeepClone().AsBsonDocument; + copy.Remove("ns"); + cleaned.Add(copy); + } + var metadata = new BsonDocument + { + { "indexes", cleaned }, + { "collectionName", info.Name }, + { "type", info.Kind switch + { + CollectionKind.View => "view", + CollectionKind.TimeSeries => "timeseries", + _ => "collection" + } + } + }; + if (uuid is { } id) + { + metadata.InsertAt(1, new BsonElement("uuid", id.ToString("N"))); + } + metadata.InsertAt(0, new BsonElement("options", info.Options.DeepClone())); + return metadata; + } + + /// 元数据 → 文本(Canonical EJSON,单行,与 mongodump 的写法一致)。 + public static string MetadataJson(BsonDocument metadata) => + metadata.ToJson(new JsonWriterSettings { OutputMode = JsonOutputMode.CanonicalExtendedJson, Indent = false }); + + /// 打开一个写出流(gzip 时套一层压缩)。 + public static Stream OpenWrite(string path, bool gzip) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + Stream file = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, 1 << 20); + return gzip ? new GZipStream(file, CompressionLevel.Fastest, leaveOpen: false) : file; + } + + /// 打开一个读入流(按扩展名认 gzip)。 + public static Stream OpenRead(string path) + { + Stream file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 1 << 20); + return path.EndsWith(".gz", StringComparison.OrdinalIgnoreCase) ? new GZipStream(file, CompressionMode.Decompress) : file; + } + + /// 把一份文档的 BSON 字节写进流。 + public static int Write(Stream stream, BsonDocument document) + { + byte[] bytes = document.ToBson(); + stream.Write(bytes); + return bytes.Length; + } + + /// + /// 从裸 BSON 流里读一份文档;到流尾返回 。 + /// 文档长度前缀不合法(截断、不是 BSON)时抛 。 + /// + public static BsonDocument? Read(Stream stream) + { + byte[]? bytes = ReadRaw(stream); + return bytes is null ? null : BsonSerializer.Deserialize(bytes); + } + + /// 读一份文档的原始字节。 + public static byte[]? ReadRaw(Stream stream) + { + Span prefix = stackalloc byte[4]; + int got = stream.ReadAtLeast(prefix, 4, throwOnEndOfStream: false); + if (got == 0) + { + return null; + } + if (got < 4) + { + throw new InvalidDataException("Truncated BSON document length."); + } + int length = BinaryPrimitives.ReadInt32LittleEndian(prefix); + // BSON 单文档上限 16 MB,留点余量;小于 5 字节的不可能是文档。 + if (length < 5 || length > 48 * 1024 * 1024) + { + throw new InvalidDataException($"Invalid BSON document length {length}."); + } + byte[] bytes = new byte[length]; + prefix.CopyTo(bytes); + int read = stream.ReadAtLeast(bytes.AsSpan(4), length - 4, throwOnEndOfStream: false); + if (read < length - 4) + { + throw new InvalidDataException("Truncated BSON document."); + } + return bytes; + } + + /// 数一个 .bson 文件里有多少份文档(只读长度前缀,未压缩时直接跳着走)。 + public static long Count(string path, CancellationToken cancellationToken = default) + { + using Stream stream = OpenRead(path); + long count = 0; + if (stream.CanSeek) + { + Span prefix = stackalloc byte[4]; + while (stream.Position < stream.Length) + { + cancellationToken.ThrowIfCancellationRequested(); + if (stream.ReadAtLeast(prefix, 4, throwOnEndOfStream: false) < 4) + { + break; + } + int length = BinaryPrimitives.ReadInt32LittleEndian(prefix); + if (length < 5) + { + break; + } + stream.Seek(length - 4, SeekOrigin.Current); + count++; + } + return count; + } + while (ReadRaw(stream) is not null) + { + cancellationToken.ThrowIfCancellationRequested(); + count++; + } + return count; + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Transfer/CellFormatter.cs b/plugins/VelaShell.Plugin.Mongo/Transfer/CellFormatter.cs new file mode 100644 index 0000000..92a140e --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Transfer/CellFormatter.cs @@ -0,0 +1,103 @@ +using System.Globalization; +using MongoDB.Bson; +using VelaShell.Plugin.Mongo.Bson; + +namespace VelaShell.Plugin.Mongo.Transfer; + +/// +/// BSON 值 → CSV / Excel 单元格文本。与网格的 不同: +/// 这里的文本是**给下游程序读的**,所以数值不带千分位、字符串不截断不压行、容器写完整的 JSON。 +/// +internal static class CellFormatter +{ + /// 本地时间格式。 + public const string LocalFormat = "yyyy-MM-dd HH:mm:ss"; + + /// 格式化一个单元格。 + /// 值; = 字段缺失。 + /// 换算。 + /// null 写成空(否则写 null)。 + public static string Format(BsonValue? value, CellConversion conversion, bool nullAsEmpty) + { + switch (BsonKinds.Of(value)) + { + case BsonKind.Missing: + return ""; + case BsonKind.Null: + return nullAsEmpty ? "" : "null"; + case BsonKind.ObjectId: + return value!.AsObjectId.ToString(); + case BsonKind.String: + return value!.AsString; + case BsonKind.Int32: + return conversion == CellConversion.Fixed2 + ? value!.AsInt32.ToString("0.00", CultureInfo.InvariantCulture) + : value!.AsInt32.ToString(CultureInfo.InvariantCulture); + case BsonKind.Int64: + return conversion == CellConversion.Fixed2 + ? value!.AsInt64.ToString("0.00", CultureInfo.InvariantCulture) + : value!.AsInt64.ToString(CultureInfo.InvariantCulture); + case BsonKind.Double: + return conversion == CellConversion.Fixed2 && double.IsFinite(value!.AsDouble) + ? value.AsDouble.ToString("0.00", CultureInfo.InvariantCulture) + : BsonText.FormatDouble(value!.AsDouble, forceDecimalPoint: false); + case BsonKind.Decimal128: + return FormatDecimal(value!.AsDecimal128, conversion == CellConversion.Fixed2); + case BsonKind.Boolean: + return value!.AsBoolean ? "true" : "false"; + case BsonKind.Date: + return conversion == CellConversion.IsoTime ? BsonText.IsoDate(value!) : LocalDate(value!); + case BsonKind.Object or BsonKind.Array: + return BsonText.Compact(value!, EjsonMode.Relaxed); + case BsonKind.Uuid: + return value!.AsBsonBinaryData.ToGuid().ToString(); + case BsonKind.Binary: + return Convert.ToBase64String(value!.AsBsonBinaryData.Bytes); + case BsonKind.Regex: + return value!.AsBsonRegularExpression.ToString(); + case BsonKind.Timestamp: + return $"Timestamp({value!.AsBsonTimestamp.Timestamp}, {value.AsBsonTimestamp.Increment})"; + default: + return BsonText.Literal(value!); + } + } + + /// Decimal128 → 文本; 时四舍五入到两位(远离零)。 + public static string FormatDecimal(Decimal128 value, bool fixed2) + { + string raw = value.ToString(); + if (!fixed2) + { + return raw; + } + return decimal.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out decimal parsed) + ? Math.Round(parsed, 2, MidpointRounding.AwayFromZero).ToString("0.00", CultureInfo.InvariantCulture) + : raw; + } + + /// 本地时间;超出 .NET 可表示范围的毫秒值给 ISO 串。 + public static string LocalDate(BsonValue value) + { + long ms = value.AsBsonDateTime.MillisecondsSinceEpoch; + if (ms < -62135596800000L || ms > 253402300799999L) + { + return ms.ToString(CultureInfo.InvariantCulture); + } + return DateTimeOffset.FromUnixTimeMilliseconds(ms).ToLocalTime().ToString(LocalFormat, CultureInfo.InvariantCulture); + } + + /// + /// 数值单元格(Excel 里写成数字而不是文本,才能求和排序)。不是数值或换算成文本时返回 。 + /// + public static double? Number(BsonValue? value, CellConversion conversion) => BsonKinds.Of(value) switch + { + BsonKind.Int32 => value!.AsInt32, + BsonKind.Int64 => value!.AsInt64, + BsonKind.Double when double.IsFinite(value!.AsDouble) => conversion == CellConversion.Fixed2 + ? Math.Round(value.AsDouble, 2, MidpointRounding.AwayFromZero) + : value.AsDouble, + BsonKind.Decimal128 when decimal.TryParse(value!.AsDecimal128.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture, out decimal d) => + (double)(conversion == CellConversion.Fixed2 ? Math.Round(d, 2, MidpointRounding.AwayFromZero) : d), + _ => null + }; +} diff --git a/plugins/VelaShell.Plugin.Mongo/Transfer/CollectionCopier.cs b/plugins/VelaShell.Plugin.Mongo/Transfer/CollectionCopier.cs new file mode 100644 index 0000000..50b43a0 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Transfer/CollectionCopier.cs @@ -0,0 +1,452 @@ +using MongoDB.Bson; +using MongoDB.Driver; +using VelaShell.Plugin.Mongo.Core; + +namespace VelaShell.Plugin.Mongo.Transfer; + +/// 目标上同名对象的处理(设计稿 21「目标动作」)。 +internal enum XferAction +{ + /// 覆盖:drop + 按源的选项重建。 + Overwrite, + + /// 追加:目标有就往里插,没有就建。 + Append, + + /// 新建:目标上不存在时建。 + Create, + + /// 跳过。 + Skip +} + +/// 传输对象的种类。 +internal enum XferObjectKind +{ + /// 集合(含固定集合、时序集合、聚簇集合)。 + Collection, + + /// 视图(只传定义)。 + View, + + /// GridFS 桶(.files + .chunks 两个集合)。 + Bucket +} + +/// 传输选项(设计稿 21 右栏「选项」)。 +internal sealed record XferOptions +{ + /// 保留 _id(否则由目标重新生成 —— 追加到已有集合时避免撞键)。 + public bool KeepId { get; init; } = true; + + /// 传输索引(数据写完后在目标上建)。 + public bool Indexes { get; init; } = true; + + /// 传输验证规则(建集合时带上 validator)。 + public bool Validation { get; init; } = true; + + /// 批大小。 + public int BatchSize { get; init; } = 1000; + + /// 同时在途的批数。 + public int Concurrency { get; init; } = 4; + + /// 源端读偏好(默认 secondaryPreferred:别把主节点读满)。 + public string ReadPreference { get; init; } = "secondaryPreferred"; +} + +/// 要传的一个对象。 +/// 名字(桶是桶名)。 +/// 种类。 +/// 集合信息(桶为 )。 +/// 目标动作。 +internal sealed record XferItem(string Name, XferObjectKind Kind, CollectionInfo? Info, XferAction Action); + +/// 一个对象传完的结果。 +/// 写进去的文档数。 +/// 写失败的文档数(重复键等)。 +/// 建了几个索引。 +internal sealed record XferOutcome(long Copied, long Failed, int Indexes); + +/// +/// 暂停闸:批与批之间检查一次。暂停时在途的批照常写完 —— 半截的批没有意义, +/// 停在批边界上,恢复时游标从原处接着读。 +/// +internal sealed class XferPauseGate +{ + private readonly Lock _gate = new(); + private TaskCompletionSource? _paused; + + /// 是否暂停中。 + public bool IsPaused + { + get + { + lock (_gate) + { + return _paused is not null; + } + } + } + + /// 暂停。 + public void Pause() + { + lock (_gate) + { + _paused ??= new(TaskCreationOptions.RunContinuationsAsynchronously); + } + } + + /// 恢复。 + public void Resume() + { + TaskCompletionSource? paused; + lock (_gate) + { + paused = _paused; + _paused = null; + } + paused?.TrySetResult(); + } + + /// 暂停中就等到恢复(或取消)。 + public Task WaitAsync(CancellationToken cancellationToken) + { + lock (_gate) + { + return _paused?.Task.WaitAsync(cancellationToken) ?? Task.CompletedTask; + } + } +} + +/// +/// 把一个集合 / 视图 / GridFS 桶从源连接复制到目标连接。 +/// +/// 顺序是**先建集合(带选项与验证规则)→ 灌数据 → 最后建索引**:边写边维护索引比一次性建慢得多, +/// 这也是 mongorestore 的做法。数据走源端读偏好(默认 secondaryPreferred)的游标, +/// 按批 insertMany(ordered: false),最多 批同时在途。 +/// +/// +internal static class CollectionCopier +{ + /// 传一个对象。 + /// 源连接。 + /// 源库。 + /// 目标连接。 + /// 目标库。 + /// 对象。 + /// 选项。 + /// 暂停闸。 + /// 每写完一批回报一次条数(在线程池线程上)。 + /// 日志。 + /// 文案表(日志用)。 + /// 取消令牌。 + public static async Task CopyAsync( + MongoConnection source, + string sourceDb, + MongoConnection target, + string targetDb, + XferItem item, + XferOptions options, + XferPauseGate gate, + Action onDocuments, + Action log, + Loc loc, + CancellationToken cancellationToken) + { + if (item.Action == XferAction.Skip) + { + return new(0, 0, 0); + } + switch (item.Kind) + { + case XferObjectKind.View: + await CopyViewAsync(target, targetDb, item, cancellationToken).ConfigureAwait(false); + return new(0, 0, 0); + case XferObjectKind.Bucket: + { + // 先 chunks 后 files:读者从 files 找文件,files 晚到就不会看见"有文件没数据"的半截桶。 + XferOutcome chunks = await CopyCollectionAsync(source, sourceDb, target, targetDb, $"{item.Name}.chunks", + new BsonDocument(), item.Action, options, gate, onDocuments, log, loc, cancellationToken).ConfigureAwait(false); + XferOutcome files = await CopyCollectionAsync(source, sourceDb, target, targetDb, $"{item.Name}.files", + new BsonDocument(), item.Action, options, gate, onDocuments, log, loc, cancellationToken).ConfigureAwait(false); + return new(chunks.Copied + files.Copied, chunks.Failed + files.Failed, chunks.Indexes + files.Indexes); + } + default: + return await CopyCollectionAsync(source, sourceDb, target, targetDb, item.Name, item.Info?.Options ?? [], + item.Action, options, gate, onDocuments, log, loc, cancellationToken).ConfigureAwait(false); + } + } + + /// 目标库里已有的集合 / 视图名。 + public static async Task> ExistingNamesAsync(MongoConnection target, string database, CancellationToken cancellationToken) + { + using IAsyncCursor cursor = await target.Database(database).ListCollectionNamesAsync(cancellationToken: cancellationToken) + .ConfigureAwait(false); + List names = await cursor.ToListAsync(cancellationToken).ConfigureAwait(false); + return [.. names]; + } + + private static async Task CopyCollectionAsync( + MongoConnection source, + string sourceDb, + MongoConnection target, + string targetDb, + string name, + BsonDocument sourceOptions, + XferAction action, + XferOptions options, + XferPauseGate gate, + Action onDocuments, + Action log, + Loc loc, + CancellationToken cancellationToken) + { + IMongoDatabase targetDatabase = target.Database(targetDb); + bool exists = (await ExistingNamesAsync(target, targetDb, cancellationToken).ConfigureAwait(false)).Contains(name); + bool created = false; + switch (action) + { + case XferAction.Overwrite: + if (exists) + { + await targetDatabase.DropCollectionAsync(name, cancellationToken).ConfigureAwait(false); + } + await CreateAsync(target, targetDb, name, sourceOptions, options, cancellationToken).ConfigureAwait(false); + created = true; + break; + case XferAction.Create when exists: + throw new InvalidOperationException($"{targetDb}.{name} already exists."); + case XferAction.Create: + case XferAction.Append when !exists: + await CreateAsync(target, targetDb, name, sourceOptions, options, cancellationToken).ConfigureAwait(false); + created = true; + break; + } + + IMongoCollection from = source.Collection(sourceDb, name) + .WithReadPreference(MongoConnection.ParseReadPreference(options.ReadPreference)); + IMongoCollection to = target.Collection(targetDb, name); + long copied = 0, failed = 0; + int concurrency = Math.Clamp(options.Concurrency, 1, 16); + using var slots = new SemaphoreSlim(concurrency, concurrency); + var inflight = new List(); + var errors = new List(); + + async Task InsertAsync(List documents) + { + try + { + await to.InsertManyAsync(documents, new InsertManyOptions { IsOrdered = false }, cancellationToken).ConfigureAwait(false); + Interlocked.Add(ref copied, documents.Count); + onDocuments(documents.Count); + } + catch (MongoBulkWriteException ex) + { + long bad = ex.WriteErrors.Count; + Interlocked.Add(ref copied, documents.Count - bad); + Interlocked.Add(ref failed, bad); + onDocuments(documents.Count); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + lock (errors) + { + errors.Add(ex); + } + } + finally + { + slots.Release(); + } + } + + try + { + using IAsyncCursor cursor = await from.FindAsync(FilterDefinition.Empty, + new FindOptions { BatchSize = options.BatchSize, Comment = "velashell-transfer" }, cancellationToken) + .ConfigureAwait(false); + var buffer = new List(options.BatchSize); + while (await cursor.MoveNextAsync(cancellationToken).ConfigureAwait(false)) + { + foreach (BsonDocument document in cursor.Current) + { + if (!options.KeepId) + { + document.Remove("_id"); + } + buffer.Add(document); + if (buffer.Count >= options.BatchSize) + { + await gate.WaitAsync(cancellationToken).ConfigureAwait(false); + await slots.WaitAsync(cancellationToken).ConfigureAwait(false); + inflight.Add(InsertAsync(buffer)); + buffer = new List(options.BatchSize); + inflight.RemoveAll(static t => t.IsCompleted); + ThrowIfFailed(errors); + } + } + } + if (buffer.Count > 0) + { + await gate.WaitAsync(cancellationToken).ConfigureAwait(false); + await slots.WaitAsync(cancellationToken).ConfigureAwait(false); + inflight.Add(InsertAsync(buffer)); + } + await Task.WhenAll(inflight).ConfigureAwait(false); + ThrowIfFailed(errors); + } + finally + { + // 取消或出错时,在途的批稍后还会回来 Release 信号量 —— 等它们落地再离开(释放信号量), + // 否则那几次 Release 撞上已释放的对象,在线程池里抛一个没人接的异常。 + try + { + await Task.WhenAll(inflight).ConfigureAwait(false); + } + catch (Exception) + { + // 真正的失败已经由上面的 try 抛出去了。 + } + } + + int indexes = 0; + if (options.Indexes) + { + indexes = await CopyIndexesAsync(source, sourceDb, target, targetDb, name, log, loc, cancellationToken).ConfigureAwait(false); + } + if (!created && options.Validation && sourceOptions.Contains("validator")) + { + log(loc.Format("Xfer_LogValidatorKept", name), XferTone.Muted); + } + return new(copied, failed, indexes); + } + + private static void ThrowIfFailed(List errors) + { + lock (errors) + { + if (errors.Count > 0) + { + throw errors[0]; + } + } + } + + /// + /// 按源的选项建集合:固定集合、时序、聚簇、排序规则原样带上;验证规则看选项。 + /// 时序集合的 bucketMaxSpanSeconds 与 granularity 同时给会被服务器拒,有 granularity 时去掉前者。 + /// + internal static BsonDocument CreateCommand(string name, BsonDocument sourceOptions, XferOptions options) + { + var command = new BsonDocument("create", name); + foreach (BsonElement element in sourceOptions) + { + switch (element.Name) + { + case "validator" or "validationLevel" or "validationAction" when !options.Validation: + case "viewOn" or "pipeline": + continue; + case "timeseries" when element.Value is BsonDocument series: + { + BsonDocument copy = series.DeepClone().AsBsonDocument; + if (copy.Contains("granularity")) + { + copy.Remove("bucketMaxSpanSeconds"); + copy.Remove("bucketRoundingSeconds"); + } + command[element.Name] = copy; + continue; + } + case "clusteredIndex" when element.Value is BsonDocument clustered: + { + var copy = new BsonDocument + { + { "key", clustered.GetValue("key", new BsonDocument("_id", 1)) }, + { "unique", clustered.GetValue("unique", true) } + }; + if (clustered.TryGetValue("name", out BsonValue clusteredName)) + { + copy["name"] = clusteredName; + } + command[element.Name] = copy; + continue; + } + default: + command[element.Name] = element.Value; + continue; + } + } + return command; + } + + private static Task CreateAsync(MongoConnection target, string database, string name, BsonDocument sourceOptions, XferOptions options, + CancellationToken cancellationToken) => + target.RunCommandAsync(database, CreateCommand(name, sourceOptions, options), cancellationToken); + + private static async Task CopyViewAsync(MongoConnection target, string database, XferItem item, CancellationToken cancellationToken) + { + bool exists = (await ExistingNamesAsync(target, database, cancellationToken).ConfigureAwait(false)).Contains(item.Name); + if (exists) + { + if (item.Action != XferAction.Overwrite) + { + return; + } + await target.Database(database).DropCollectionAsync(item.Name, cancellationToken).ConfigureAwait(false); + } + BsonDocument options = item.Info?.Options ?? []; + var command = new BsonDocument + { + { "create", item.Name }, + { "viewOn", options.GetValue("viewOn", "") }, + { "pipeline", options.GetValue("pipeline", new BsonArray()) } + }; + if (options.TryGetValue("collation", out BsonValue collation)) + { + command["collation"] = collation; + } + await target.RunCommandAsync(database, command, cancellationToken).ConfigureAwait(false); + } + + private static async Task CopyIndexesAsync( + MongoConnection source, + string sourceDb, + MongoConnection target, + string targetDb, + string name, + Action log, + Loc loc, + CancellationToken cancellationToken) + { + IReadOnlyList indexes = await source.ListIndexesAsync(sourceDb, name, cancellationToken).ConfigureAwait(false); + var specs = new BsonArray(); + foreach (BsonDocument index in indexes) + { + if (index.GetValue("name", "").AsString == "_id_" || index.Contains("clustered")) + { + continue; + } + BsonDocument spec = index.DeepClone().AsBsonDocument; + spec.Remove("ns"); + spec.Remove("v"); + specs.Add(spec); + } + if (specs.Count == 0) + { + return 0; + } + try + { + await target.RunCommandAsync(targetDb, new BsonDocument { { "createIndexes", name }, { "indexes", specs } }, cancellationToken) + .ConfigureAwait(false); + return specs.Count; + } + catch (MongoCommandException ex) + { + log(loc.Format("Xfer_LogIndexFailed", name, MongoConnector.Describe(ex)), XferTone.Warn); + return 0; + } + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Transfer/Csv.cs b/plugins/VelaShell.Plugin.Mongo/Transfer/Csv.cs new file mode 100644 index 0000000..4b006d4 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Transfer/Csv.cs @@ -0,0 +1,326 @@ +using System.Text; + +namespace VelaShell.Plugin.Mongo.Transfer; + +/// +/// RFC 4180 的 CSV 写出:含分隔符、双引号、回车或换行的字段整个加双引号,字段里的双引号写两遍; +/// 行尾一律 CRLF(RFC 原文如此,Excel 也只认这个才不会把最后一列粘上 \r)。 +/// 首尾带空格的字段也加引号 —— 不加的话不少读者会顺手 Trim 掉。 +/// +/// 目标。 +/// 分隔符。 +internal sealed class CsvWriter(TextWriter writer, char delimiter) +{ + /// 写一行。 + public void WriteRow(IReadOnlyList cells) + { + for (int i = 0; i < cells.Count; i++) + { + if (i > 0) + { + writer.Write(delimiter); + } + WriteField(cells[i]); + } + writer.Write("\r\n"); + } + + private void WriteField(string? value) + { + if (string.IsNullOrEmpty(value)) + { + return; + } + if (!NeedsQuotes(value, delimiter)) + { + writer.Write(value); + return; + } + writer.Write('"'); + foreach (char c in value) + { + if (c == '"') + { + writer.Write("\"\""); + } + else + { + writer.Write(c); + } + } + writer.Write('"'); + } + + /// 这个字段要不要加引号。 + internal static bool NeedsQuotes(string value, char delimiter) + { + if (value.Length == 0) + { + return false; + } + if (value[0] == ' ' || value[^1] == ' ' || value[0] == '\t' || value[^1] == '\t') + { + return true; + } + foreach (char c in value) + { + if (c == delimiter || c == '"' || c == '\r' || c == '\n') + { + return true; + } + } + return false; + } + + /// 把一行写成字符串(预览用)。 + public static string FormatRow(IReadOnlyList cells, char delimiter) + { + using var text = new StringWriter(); + new CsvWriter(text, delimiter).WriteRow(cells); + return text.ToString().TrimEnd('\r', '\n'); + } +} + +/// +/// 流式 CSV 读者(RFC 4180,对常见的不规范写法宽容):引号字段里可以有分隔符、换行与 ""; +/// 行尾认 CRLF / LF / CR;未加引号的字段中间出现的引号按字面字符处理(Excel 导出的脏数据常见)。 +/// +/// 不把整个文件读进内存:导入向导对一个 1,246,302 行的文件计数、dry-run、正式导入都是一遍流。 +/// +/// +internal sealed class CsvReader +{ + private readonly TextReader _reader; + private readonly char _delimiter; + private readonly char[] _buffer = new char[64 * 1024]; + private readonly StringBuilder _field = new(); + private int _length; + private int _position; + private long _line = 1; + + /// 构造。 + public CsvReader(TextReader reader, char delimiter) + { + _reader = reader; + _delimiter = delimiter; + } + + /// 上一条记录开始的物理行号(从 1 起,引号字段里的换行也算行)。 + public long RecordLine { get; private set; } + + /// 读一条记录;到文件尾返回 。空行跳过。 + public bool TryRead(out string[] record) + { + var fields = new List(); + while (true) + { + if (!ReadRecord(fields, out bool blank)) + { + record = []; + return false; + } + if (!blank) + { + record = [.. fields]; + return true; + } + fields.Clear(); + } + } + + private bool ReadRecord(List fields, out bool blank) + { + blank = false; + if (!Fill()) + { + return false; + } + RecordLine = _line; + _field.Clear(); + bool quoted = false; + bool fieldStart = true; + bool sawQuote = false; + while (true) + { + if (_position >= _length && !Fill()) + { + // 文件尾:最后一条记录没有换行结尾。 + fields.Add(_field.ToString()); + blank = fields.Count == 1 && fields[0].Length == 0 && !sawQuote; + return true; + } + char c = _buffer[_position++]; + if (quoted) + { + if (c == '"') + { + if (Peek() == '"') + { + _position++; + _field.Append('"'); + } + else + { + quoted = false; + } + } + else + { + if (c == '\n' || (c == '\r' && Peek() != '\n')) + { + _line++; + } + _field.Append(c); + } + continue; + } + if (c == '"' && fieldStart) + { + quoted = true; + sawQuote = true; + fieldStart = false; + continue; + } + if (c == _delimiter) + { + fields.Add(_field.ToString()); + _field.Clear(); + fieldStart = true; + continue; + } + if (c == '\r' || c == '\n') + { + if (c == '\r' && Peek() == '\n') + { + _position++; + } + _line++; + fields.Add(_field.ToString()); + blank = fields.Count == 1 && fields[0].Length == 0 && !sawQuote; + return true; + } + fieldStart = false; + _field.Append(c); + } + } + + private int Peek() + { + if (_position >= _length && !Fill()) + { + return -1; + } + return _buffer[_position]; + } + + private bool Fill() + { + if (_position < _length) + { + return true; + } + _length = _reader.Read(_buffer, 0, _buffer.Length); + _position = 0; + return _length > 0; + } + + /// + /// 快速数记录(文件头那句「1,246,302 行」):只扫引号与换行,不切字段、不分配字符串。 + /// 返回的是**非空记录数**(含表头)。 + /// + public static long CountRecords(TextReader reader, CancellationToken cancellationToken = default) + { + char[] buffer = new char[128 * 1024]; + long records = 0; + bool quoted = false; + bool content = false; + char previous = '\0'; + int read; + while ((read = reader.Read(buffer, 0, buffer.Length)) > 0) + { + cancellationToken.ThrowIfCancellationRequested(); + for (int i = 0; i < read; i++) + { + char c = buffer[i]; + if (c == '"') + { + quoted = !quoted; + content = true; + } + else if (!quoted && (c == '\n' || c == '\r')) + { + if (!(c == '\n' && previous == '\r') && content) + { + records++; + } + content = false; + } + else + { + content = true; + } + previous = c; + } + } + if (content) + { + records++; + } + return records; + } + + /// + /// 认分隔符:对前若干行逐个候选数"引号外的出现次数",挑**每一行都出现且次数一致**的那个; + /// 都不一致时挑总数最多的。全没有就当逗号(单列文件)。 + /// + public static char DetectDelimiter(IReadOnlyList lines) + { + char[] candidates = [',', '\t', ';', '|']; + char best = ','; + int bestScore = -1; + foreach (char candidate in candidates) + { + int[] counts = [.. lines.Where(static l => l.Length > 0).Take(20).Select(l => CountOutsideQuotes(l, candidate))]; + if (counts.Length == 0 || counts.All(static c => c == 0)) + { + continue; + } + bool consistent = counts.All(c => c == counts[0]) && counts[0] > 0; + int score = (consistent ? 1_000_000 : 0) + counts.Sum(); + if (score > bestScore) + { + bestScore = score; + best = candidate; + } + } + return best; + } + + private static int CountOutsideQuotes(string line, char delimiter) + { + int count = 0; + bool quoted = false; + foreach (char c in line) + { + if (c == '"') + { + quoted = !quoted; + } + else if (c == delimiter && !quoted) + { + count++; + } + } + return count; + } + + /// 分隔符给人看的名字。 + public static string DelimiterName(char delimiter, Loc loc) => delimiter switch + { + ',' => loc["Xfer_DelimComma"], + ';' => loc["Xfer_DelimSemicolon"], + '\t' => loc["Xfer_DelimTab"], + '|' => loc["Xfer_DelimPipe"], + _ => delimiter.ToString() + }; +} diff --git a/plugins/VelaShell.Plugin.Mongo/Transfer/ExportRunner.cs b/plugins/VelaShell.Plugin.Mongo/Transfer/ExportRunner.cs new file mode 100644 index 0000000..0d57a6d --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Transfer/ExportRunner.cs @@ -0,0 +1,351 @@ +using System.Diagnostics; +using MongoDB.Bson; +using MongoDB.Driver; +using VelaShell.Plugin.Mongo.Core; +using VelaShell.Plugin.Mongo.Ui; + +namespace VelaShell.Plugin.Mongo.Transfer; + +/// 导出的一个来源集合。 +/// 集合信息。 +/// CSV / Excel 的列; = 运行时按抽样自动生成(多集合导出)。 +internal sealed record ExportSource(CollectionInfo Info, IReadOnlyList? Columns = null); + +/// 一次导出要做的事。 +internal sealed record ExportJob +{ + /// 库名。 + public required string Database { get; init; } + + /// 来源集合。 + public required IReadOnlyList Sources { get; init; } + + /// 格式。 + public required ExportFormat Format { get; init; } + + /// 目标:单文件路径,或目录()。 + public required string Target { get; init; } + + /// 目标是目录(BSON 转储、多集合的 JSON / CSV:一个集合一个文件)。 + public bool TargetIsFolder { get; init; } + + /// 筛选条件。 + public BsonDocument Filter { get; init; } = []; + + /// 排序。 + public BsonDocument? Sort { get; init; } + + /// 投影(JSON / Shell 只导出勾选的字段时用)。 + public BsonDocument? Projection { get; init; } + + /// 条数上限;0 = 不限。 + public int Limit { get; init; } + + /// CSV 选项。 + public CsvOptions Csv { get; init; } = new(); + + /// JSON 选项。 + public JsonOptions Json { get; init; } = new(); + + /// Excel 选项。 + public ExcelOptions Excel { get; init; } = new(); + + /// 转储选项。 + public DumpOptions Dump { get; init; } = new(); + + /// Shell 脚本选项。 + public ShellOptions Shell { get; init; } = new(); + + /// 预计总文档数(进度条分母;未知为 0)。 + public long EstimatedTotal { get; init; } + + /// 某种格式的扩展名。 + public static string Extension(ExportFormat format, JsonOptions json) => format switch + { + ExportFormat.Json => json.Lines ? ".jsonl" : ".json", + ExportFormat.Csv => ".csv", + ExportFormat.Excel => ".xlsx", + ExportFormat.Shell => ".js", + _ => ".bson" + }; +} + +/// +/// 执行导出:游标逐批拉、写出器逐份写,**从不把整个集合装进内存**。 +/// +/// 单文件先写到 目标.part,成功后再改名覆盖 —— 中途取消或失败时,旧文件原封不动, +/// 不会留下一个看起来完整、其实只写了一半的导出文件。 +/// +/// +internal static class ExportRunner +{ + /// 打在导出游标上的 comment(在 currentOp 里认得出是谁)。 + public const string Comment = "velashell-export"; + + /// 跑一次导出。 + public static async Task RunAsync( + MongoConnection connection, + ExportJob job, + IProgress? progress, + CancellationToken cancellationToken) + { + var watch = Stopwatch.StartNew(); + var state = new RunState(job, progress, watch); + var files = new List(); + var created = new List(); + try + { + if (job.Format == ExportFormat.BsonDump) + { + await DumpAsync(connection, job, state, files, created, cancellationToken).ConfigureAwait(false); + } + else if (job.TargetIsFolder) + { + Directory.CreateDirectory(job.Target); + foreach (ExportSource source in job.Sources) + { + string path = Path.Combine(job.Target, TransferText.SafeFileName(source.Info.Name) + ExportJob.Extension(job.Format, job.Json)); + await WriteFileAsync(connection, job, [source], path, state, created, cancellationToken).ConfigureAwait(false); + files.Add(path); + } + } + else + { + await WriteFileAsync(connection, job, job.Sources, job.Target, state, created, cancellationToken).ConfigureAwait(false); + files.Add(job.Target); + } + } + catch + { + // 失败或取消:删掉这一次新建的文件(.part 与转储文件);已经改名落定的完整文件保留。 + foreach (string path in created) + { + TryDelete(path); + } + throw; + } + state.Report(force: true); + return new(state.Documents, state.Bytes, watch.Elapsed, files); + } + + private static async Task WriteFileAsync( + MongoConnection connection, + ExportJob job, + IReadOnlyList sources, + string path, + RunState state, + List created, + CancellationToken cancellationToken) + { + string directory = Path.GetDirectoryName(Path.GetFullPath(path))!; + Directory.CreateDirectory(directory); + string part = path + ".part"; + created.Add(part); + var counting = new CountingStream(new FileStream(part, FileMode.Create, FileAccess.Write, FileShare.None, 1 << 20)); + state.Attach(counting); + using (ExportWriter writer = CreateWriter(job, counting)) + { + foreach (ExportSource source in sources) + { + IReadOnlyList columns = source.Columns + ?? await AutoColumnsAsync(connection, job, source.Info.Name, cancellationToken).ConfigureAwait(false); + state.Collection = source.Info.Name; + writer.Begin(source.Info.Name, columns); + await StreamAsync(connection.Collection(job.Database, source.Info.Name), job, writer.Write, state, cancellationToken) + .ConfigureAwait(false); + writer.End(); + } + writer.Complete(); + } + state.Detach(); + File.Move(part, path, overwrite: true); + created.Remove(part); + } + + private static async Task DumpAsync( + MongoConnection connection, + ExportJob job, + RunState state, + List files, + List created, + CancellationToken cancellationToken) + { + bool gzip = job.Dump.Gzip; + foreach (ExportSource source in job.Sources) + { + CollectionInfo info = source.Info; + state.Collection = info.Name; + IReadOnlyList indexes = info.Kind == CollectionKind.View + ? [] + : await connection.ListIndexesAsync(job.Database, info.Name, cancellationToken).ConfigureAwait(false); + Guid? uuid = await UuidAsync(connection, job.Database, info.Name, cancellationToken).ConfigureAwait(false); + if (info.Kind != CollectionKind.View) + { + string data = BsonDump.DataPath(job.Target, job.Database, info.Name, gzip); + created.Add(data); + var counting = new CountingStream(BsonDump.OpenWrite(data, gzip)); + state.Attach(counting); + using (var writer = new BsonExportWriter(counting)) + { + await StreamAsync(connection.Collection(job.Database, info.Name), job with { Projection = null }, writer.Write, state, cancellationToken) + .ConfigureAwait(false); + writer.End(); + } + state.Detach(); + files.Add(data); + } + string metadataPath = BsonDump.MetadataPath(job.Target, job.Database, info.Name, gzip); + created.Add(metadataPath); + await using (Stream metadata = BsonDump.OpenWrite(metadataPath, gzip)) + await using (var text = new StreamWriter(metadata, new System.Text.UTF8Encoding(false))) + { + await text.WriteAsync(BsonDump.MetadataJson(BsonDump.Metadata(info, indexes, uuid))).ConfigureAwait(false); + } + files.Add(metadataPath); + // 这个集合完整落盘了:之后再取消也不删它(用户拿到的是"转储到一半"的目录,但每个文件都是好的)。 + created.Clear(); + } + } + + private static async Task UuidAsync(MongoConnection connection, string database, string collection, CancellationToken cancellationToken) + { + try + { + using IAsyncCursor cursor = await connection.Database(database).ListCollectionsAsync( + new ListCollectionsOptions { Filter = new BsonDocument("name", collection) }, cancellationToken).ConfigureAwait(false); + BsonDocument? first = await cursor.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + if (first?.GetValue("info", new BsonDocument()) is BsonDocument info + && info.TryGetValue("uuid", out BsonValue uuid) && uuid is BsonBinaryData binary + && binary.SubType is BsonBinarySubType.UuidStandard or BsonBinarySubType.UuidLegacy) + { + return new Guid(binary.Bytes, bigEndian: true); + } + } + catch (MongoException) + { + } + return null; + } + + private static async Task StreamAsync( + IMongoCollection collection, + ExportJob job, + Action write, + RunState state, + CancellationToken cancellationToken) + { + var options = new FindOptions + { + BatchSize = 1000, + Comment = Comment + }; + if (job.Sort is { ElementCount: > 0 } sort) + { + options.Sort = sort; + } + if (job.Projection is { ElementCount: > 0 } projection) + { + options.Projection = projection; + } + if (job.Limit > 0) + { + options.Limit = job.Limit; + } + using IAsyncCursor cursor = await collection.FindAsync(job.Filter, options, cancellationToken).ConfigureAwait(false); + while (await cursor.MoveNextAsync(cancellationToken).ConfigureAwait(false)) + { + foreach (BsonDocument document in cursor.Current) + { + write(document); + state.Documents++; + } + cancellationToken.ThrowIfCancellationRequested(); + state.Report(force: false); + } + } + + /// 多集合导出时每个集合的列:抽样 200 份,按 CSV 的嵌套选项展开。 + private static async Task> AutoColumnsAsync( + MongoConnection connection, + ExportJob job, + string collection, + CancellationToken cancellationToken) + { + if (job.Format is not (ExportFormat.Csv or ExportFormat.Excel)) + { + return []; + } + IReadOnlyList sample = await SchemaSampler.SampleAsync( + connection.Collection(job.Database, collection), job.Filter, 200, cancellationToken).ConfigureAwait(false); + bool flatten = job.Format == ExportFormat.Excel || job.Csv.Nested == NestedMode.Flatten; + return + [ + .. SchemaSampler.Analyze(sample, flatten) + .Select(static f => new ExportColumn(f.Path, f.Path, f.DefaultConversion)) + ]; + } + + /// 按格式建写出器。 + internal static ExportWriter CreateWriter(ExportJob job, Stream output) => job.Format switch + { + ExportFormat.Json => new JsonExportWriter(output, job.Json), + ExportFormat.Csv => new CsvExportWriter(output, job.Csv), + ExportFormat.Excel => new ExcelExportWriter(output, job.Excel), + ExportFormat.Shell => new ShellExportWriter(output, job.Database, job.Shell), + _ => new BsonExportWriter(output) + }; + + private static void TryDelete(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } + + /// 一次运行的计数与节流上报。 + private sealed class RunState(ExportJob job, IProgress? progress, Stopwatch watch) + { + private CountingStream? _current; + private long _finishedBytes; + private TimeSpan _lastReport = TimeSpan.FromSeconds(-1); + + public long Documents { get; set; } + + public string Collection { get; set; } = ""; + + public long Bytes => _finishedBytes + (_current?.Written ?? 0); + + public void Attach(CountingStream stream) => _current = stream; + + public void Detach() + { + _finishedBytes += _current?.Written ?? 0; + _current = null; + } + + public void Report(bool force) + { + if (progress is null) + { + return; + } + TimeSpan now = watch.Elapsed; + if (!force && now - _lastReport < TimeSpan.FromMilliseconds(120)) + { + return; + } + _lastReport = now; + progress.Report(new(Documents, job.EstimatedTotal, Bytes, now, Collection)); + } + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Transfer/ExportWriters.cs b/plugins/VelaShell.Plugin.Mongo/Transfer/ExportWriters.cs new file mode 100644 index 0000000..1a2931c --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Transfer/ExportWriters.cs @@ -0,0 +1,347 @@ +using System.Text; +using MongoDB.Bson; +using VelaShell.Plugin.Mongo.Bson; + +namespace VelaShell.Plugin.Mongo.Transfer; + +/// +/// 一种导出格式的写出器:一个文件、一个(或多个)集合、一份一份文档地写。 +/// 整条链路是**流式**的 —— 游标给一份写一份,内存里从不攒整张表。 +/// +internal abstract class ExportWriter : IDisposable +{ + /// 开始一个集合。 + /// 集合名。 + /// CSV / Excel 的列(其余格式忽略)。 + public abstract void Begin(string collection, IReadOnlyList columns); + + /// 写一份文档。 + public abstract void Write(BsonDocument document); + + /// 收尾当前集合。 + public abstract void End(); + + /// 收尾整个文件。 + public virtual void Complete() + { + } + + /// + public abstract void Dispose(); +} + +/// JSON(行分隔或数组)。 +/// 输出流。 +/// 选项。 +internal sealed class JsonExportWriter(Stream output, JsonOptions options) : ExportWriter +{ + private readonly StreamWriter _writer = new(output, new UTF8Encoding(false), 1 << 16); + private bool _first = true; + + /// + public override void Begin(string collection, IReadOnlyList columns) + { + _first = true; + if (!options.Lines) + { + _writer.Write('['); + } + } + + /// + public override void Write(BsonDocument document) + { + string json = BsonText.Compact(document, options.Mode); + if (options.Lines) + { + _writer.Write(json); + _writer.Write('\n'); + return; + } + _writer.Write(_first ? "\n" : ",\n"); + _writer.Write(json); + _first = false; + } + + /// + public override void End() + { + if (!options.Lines) + { + _writer.Write(_first ? "]\n" : "\n]\n"); + } + _writer.Flush(); + } + + /// + public override void Dispose() => _writer.Dispose(); +} + +/// CSV。 +internal sealed class CsvExportWriter : ExportWriter +{ + private readonly StreamWriter _writer; + private readonly CsvOptions _options; + private readonly CsvWriter _csv; + private IReadOnlyList _columns = []; + private string[] _cells = []; + + /// 构造。 + public CsvExportWriter(Stream output, CsvOptions options) + { + _options = options; + _writer = new StreamWriter(output, TextEncodings.Get(options.Encoding), 1 << 16); + _csv = new CsvWriter(_writer, options.Delimiter); + } + + /// + public override void Begin(string collection, IReadOnlyList columns) + { + _columns = columns; + _cells = new string[columns.Count]; + if (_options.WriteHeader) + { + _csv.WriteRow([.. columns.Select(static c => c.Header)]); + } + } + + /// + public override void Write(BsonDocument document) + { + for (int i = 0; i < _columns.Count; i++) + { + _cells[i] = CellFormatter.Format(BsonPath.Get(document, _columns[i].Path), _columns[i].Conversion, _options.NullAsEmpty); + } + _csv.WriteRow(_cells); + } + + /// + public override void End() => _writer.Flush(); + + /// + public override void Dispose() => _writer.Dispose(); +} + +/// Excel(每个集合一张表;超过单表行数上限时自动续一张)。 +internal sealed class ExcelExportWriter : ExportWriter +{ + private readonly XlsxWriter _xlsx; + private readonly ExcelOptions _options; + private IReadOnlyList _columns = []; + private XlsxCell[] _cells = []; + private string _sheetName = ""; + + /// 构造。 + public ExcelExportWriter(Stream output, ExcelOptions options) + { + _options = options; + _xlsx = new XlsxWriter(output, options.FreezeHeader && options.WriteHeader); + } + + /// + public override void Begin(string collection, IReadOnlyList columns) + { + _columns = columns; + _cells = new XlsxCell[columns.Count]; + _sheetName = _options.SheetName.Trim().Length > 0 && _xlsx.SheetCount == 0 ? _options.SheetName.Trim() : collection; + StartSheet(); + } + + private void StartSheet() + { + double[] widths = [.. _columns.Select(static c => Math.Max(10, WidthOf(c.Header) + 2))]; + _xlsx.BeginSheet(_sheetName, widths); + if (_options.WriteHeader) + { + _xlsx.WriteRow([.. _columns.Select(static c => XlsxCell.OfText(c.Header))], header: true); + } + } + + /// + public override void Write(BsonDocument document) + { + if (_xlsx.RowsInSheet >= XlsxWriter.MaxRows) + { + StartSheet(); + } + for (int i = 0; i < _columns.Count; i++) + { + ExportColumn column = _columns[i]; + BsonValue? value = BsonPath.Get(document, column.Path); + _cells[i] = CellFormatter.Number(value, column.Conversion) is { } number + ? XlsxCell.OfNumber(number, column.Conversion == CellConversion.Fixed2) + : value is BsonBoolean flag + ? XlsxCell.OfFlag(flag.Value) + : XlsxCell.OfText(CellFormatter.Format(value, column.Conversion, nullAsEmpty: true)); + } + _xlsx.WriteRow(_cells); + } + + /// + public override void End() + { + } + + /// + public override void Complete() => _xlsx.Complete(); + + /// + public override void Dispose() => _xlsx.Dispose(); + + /// 列宽按字符估(中日韩字符算两个)。 + private static double WidthOf(string text) => text.Sum(static c => c > 0x2E80 ? 2 : 1); +} + +/// +/// mongosh 脚本:db.getSiblingDB("shop").getCollection("orders").insertMany([ … ]);, +/// 每 份一条语句。值用 mongosh 字面量 +/// (ObjectId("…")、ISODate("…")、NumberDecimal("…")),粘进 mongosh 原样可跑。 +/// +/// 输出流。 +/// 库名。 +/// 选项。 +internal sealed class ShellExportWriter(Stream output, string database, ShellOptions options) : ExportWriter +{ + private readonly StreamWriter _writer = new(output, new UTF8Encoding(false), 1 << 16); + private string _target = ""; + private int _inBatch; + private bool _header; + + /// + public override void Begin(string collection, IReadOnlyList columns) + { + if (!_header) + { + _writer.Write("// VelaShell MongoDB export · "); + _writer.Write(DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss zzz", System.Globalization.CultureInfo.InvariantCulture)); + _writer.Write('\n'); + _header = true; + } + _target = $"db.getSiblingDB({BsonText.Quote(database)}).getCollection({BsonText.Quote(collection)})"; + _inBatch = 0; + } + + /// + public override void Write(BsonDocument document) + { + if (_inBatch == 0) + { + _writer.Write(_target); + _writer.Write(".insertMany([\n"); + } + else + { + _writer.Write(",\n"); + } + _writer.Write(" "); + _writer.Write(BsonText.Literal(document)); + _inBatch++; + if (_inBatch >= Math.Max(1, options.BatchSize)) + { + CloseBatch(); + } + } + + private void CloseBatch() + { + if (_inBatch > 0) + { + _writer.Write("\n]);\n"); + _inBatch = 0; + } + } + + /// + public override void End() + { + CloseBatch(); + _writer.Flush(); + } + + /// + public override void Dispose() => _writer.Dispose(); +} + +/// BSON 转储里一个集合的数据文件(裸 BSON 首尾相接)。 +/// 输出流(gzip 时已经套好压缩)。 +internal sealed class BsonExportWriter(Stream output) : ExportWriter +{ + /// + public override void Begin(string collection, IReadOnlyList columns) + { + } + + /// + public override void Write(BsonDocument document) => BsonDump.Write(output, document); + + /// + public override void End() => output.Flush(); + + /// + public override void Dispose() => output.Dispose(); +} + +/// 给流计字节数(进度里的「已写出 38 KB」)。 +/// 被包的流。 +internal sealed class CountingStream(Stream inner) : Stream +{ + private long _written; + + /// 已写字节。 + public long Written => Interlocked.Read(ref _written); + + /// + public override bool CanRead => false; + + /// + public override bool CanSeek => false; + + /// + public override bool CanWrite => true; + + /// + public override long Length => Written; + + /// + public override long Position + { + get => Written; + set => throw new NotSupportedException(); + } + + /// + public override void Flush() => inner.Flush(); + + /// + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + /// + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + /// + public override void SetLength(long value) => throw new NotSupportedException(); + + /// + public override void Write(byte[] buffer, int offset, int count) + { + inner.Write(buffer, offset, count); + Interlocked.Add(ref _written, count); + } + + /// + public override void Write(ReadOnlySpan buffer) + { + inner.Write(buffer); + Interlocked.Add(ref _written, buffer.Length); + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + inner.Dispose(); + } + base.Dispose(disposing); + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Transfer/ImportPlan.cs b/plugins/VelaShell.Plugin.Mongo/Transfer/ImportPlan.cs new file mode 100644 index 0000000..73144bc --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Transfer/ImportPlan.cs @@ -0,0 +1,336 @@ +using MongoDB.Bson; +using VelaShell.Plugin.Mongo.Analysis; +using VelaShell.Plugin.Mongo.Bson; + +namespace VelaShell.Plugin.Mongo.Transfer; + +/// 导入的写入方式(设计稿 20「写入方式」分段)。 +internal enum ImportWriteMode +{ + /// 插入(重复键算错误)。 + Insert, + + /// 按键 upsert:命中则 $set 合并,不命中则插入。 + Upsert, + + /// 替换:命中则整份替换,不命中则插入。 + Replace +} + +/// 一行的状态。 +internal enum ImportRowStatus +{ + /// 可导入。 + Ok, + + /// 可导入,但做过自动修正。 + Warning, + + /// 不能导入。 + Error +} + +/// 源列 → 目标字段的一条映射。 +internal sealed class ImportColumn +{ + /// 源列名(CSV 表头 / JSON 顶层字段名)。 + public required string Source { get; init; } + + /// CSV 的列下标;JSON / BSON 为 -1。 + public int Index { get; init; } = -1; + + /// 导入这一列(否则跳过)。 + public bool Include { get; set; } = true; + + /// 目标字段路径(可以是 customer.name)。 + public string Target { get; set; } = ""; + + /// 目标类型; = 保持原值(JSON / BSON 来源)。 + public BsonKind? Kind { get; set; } +} + +/// 一个问题。 +/// 种类。 +/// 错误(这一行不导入)还是警告。 +/// 字段路径。 +/// 在预览表里是第几列(已导入的列里的下标);不对应某一列为 -1。 +/// 细节。 +internal sealed record ImportIssue(ImportIssueKind Kind, bool IsError, string Field, int Column, string Detail); + +/// 换算好的一行。 +/// 源文件行号。 +/// 要写的文档。 +/// 问题。 +/// 各导入列换算后的值(预览表按列显示;缺失为 )。 +/// 各导入列的原文(出错的单元格显示原文)。 +internal sealed record ConvertedRow(long Line, BsonDocument Document, IReadOnlyList Issues, BsonValue?[] Values, string?[] Raw) +{ + /// 状态。 + public ImportRowStatus Status => Issues.Any(static i => i.IsError) ? ImportRowStatus.Error + : Issues.Count > 0 ? ImportRowStatus.Warning + : ImportRowStatus.Ok; +} + +/// +/// 导入要守的规则:来自目标集合的验证规则(required、enum、其余关键字), +/// 加上写入方式带来的约束(upsert / 替换时匹配键必填)。 +/// +internal sealed record ImportRules +{ + /// 必填字段(顶层名或点路径)。 + public IReadOnlyList Required { get; init; } = []; + + /// 枚举(路径 → 允许的值)。 + public IReadOnlyDictionary Enums { get; init; } = new Dictionary(); + + /// 属性声明(路径 → $jsonSchema 的那一节;默认目标类型用)。 + public IReadOnlyDictionary Properties { get; init; } = new Dictionary(); + + /// 整个 validator(交给客户端预检查其余关键字)。 + public BsonDocument? Validator { get; init; } + + /// validator 违规算错误(validationAction: error)还是警告(warn)。 + public bool ValidationErrors { get; init; } = true; + + /// 用户指定的日期格式。 + public string? DateFormat { get; init; } + + /// 写入方式。 + public ImportWriteMode Mode { get; init; } = ImportWriteMode.Insert; + + /// upsert / 替换的匹配键。 + public string? MatchKey { get; init; } + + /// 空单元格不写字段(否则写空字符串)。 + public bool IgnoreBlanks { get; init; } = true; + + /// 从集合的 validator 拆出规则。只认 $jsonSchema(查询表达式式的 validator 只交给服务器判)。 + public static ImportRules FromValidator(BsonDocument? validator, string validationAction) + { + if (validator is null || !validator.TryGetValue("$jsonSchema", out BsonValue raw) || raw is not BsonDocument schema) + { + return new() { Validator = validator, ValidationErrors = validationAction != "warn" }; + } + var required = new List(); + var enums = new Dictionary(StringComparer.Ordinal); + var properties = new Dictionary(StringComparer.Ordinal); + Collect(schema, null, required, enums, properties, depth: 0); + return new() + { + Required = required, + Enums = enums, + Properties = properties, + Validator = validator, + ValidationErrors = validationAction != "warn" + }; + } + + private static void Collect(BsonDocument schema, string? prefix, List required, Dictionary enums, + Dictionary properties, int depth) + { + if (schema.TryGetValue("required", out BsonValue req) && req is BsonArray names) + { + // 嵌套对象的 required 只在父对象存在时才生效 —— 这里只收顶层,避免误报。 + if (prefix is null) + { + required.AddRange(names.Where(static n => n.IsString).Select(static n => n.AsString)); + } + } + if (schema.TryGetValue("properties", out BsonValue props) && props is BsonDocument map && depth < 4) + { + foreach (BsonElement property in map) + { + if (property.Value is not BsonDocument definition) + { + continue; + } + string path = BsonPath.Join(prefix, property.Name); + properties[path] = definition; + if (definition.TryGetValue("enum", out BsonValue values) && values is BsonArray array) + { + enums[path] = array; + } + Collect(definition, path, required, enums, properties, depth + 1); + } + } + } +} + +/// +/// 把读出来的一条记录换算成要写的文档,并把一路上的问题记下来(dry-run 与正式导入用同一个)。 +/// +/// 映射。 +/// 规则。 +/// 文案表(客户端 $jsonSchema 预检的消息)。 +internal sealed class ImportConverter(IReadOnlyList columns, ImportRules rules, Loc loc) +{ + private readonly ImportColumn[] _included = [.. columns.Where(static c => c.Include && c.Target.Trim().Length > 0)]; + private readonly HashSet _excludedSources = [.. columns.Where(static c => !c.Include).Select(static c => c.Source)]; + + /// 导入的列(预览表的列)。 + public IReadOnlyList Included => _included; + + /// 换算一条。 + public ConvertedRow Convert(ImportRecord record) + { + var issues = new List(); + var values = new BsonValue?[_included.Length]; + var raw = new string?[_included.Length]; + if (record.Error is { } parseError) + { + issues.Add(new(ImportIssueKind.Parse, true, "", -1, parseError)); + return new(record.Line, [], issues, values, raw); + } + BsonDocument document = record.Cells is { } cells ? FromCells(cells, issues, values, raw) : FromDocument(record.Document!, issues, values, raw); + Check(document, issues); + return new(record.Line, document, issues, values, raw); + } + + private BsonDocument FromCells(string[] cells, List issues, BsonValue?[] values, string?[] raw) + { + var document = new BsonDocument(); + for (int i = 0; i < _included.Length; i++) + { + ImportColumn column = _included[i]; + string text = column.Index >= 0 && column.Index < cells.Length ? cells[column.Index] : ""; + raw[i] = text; + if (text.Length == 0 && rules.IgnoreBlanks) + { + continue; + } + ConvertOutcome outcome = ImportValues.FromText(text, column.Kind ?? BsonKind.String, rules.DateFormat); + Apply(document, column, i, outcome, issues, values); + } + return document; + } + + private BsonDocument FromDocument(BsonDocument source, List issues, BsonValue?[] values, string?[] raw) + { + BsonDocument document = source.DeepClone().AsBsonDocument; + foreach (string excluded in _excludedSources) + { + document.Remove(excluded); + } + for (int i = 0; i < _included.Length; i++) + { + ImportColumn column = _included[i]; + if (!source.TryGetValue(column.Source, out BsonValue value)) + { + continue; + } + raw[i] = value is BsonString s ? s.Value : BsonText.Inline(value); + string target = column.Target.Trim(); + if (target != column.Source) + { + document.Remove(column.Source); + } + ConvertOutcome outcome = column.Kind is { } kind ? ImportValues.FromValue(value, kind, rules.DateFormat) : ConvertOutcome.Ok(value); + if (outcome.Value is null) + { + document.Remove(target); + } + Apply(document, column, i, outcome, issues, values); + } + return document; + } + + private void Apply(BsonDocument document, ImportColumn column, int index, ConvertOutcome outcome, List issues, BsonValue?[] values) + { + string target = column.Target.Trim(); + if (outcome.Issue is { } kind) + { + string detail = kind == ImportIssueKind.NumberFromText && column.Kind is { } k ? $"{outcome.Detail}|{BsonKinds.Name(k)}" : outcome.Detail; + issues.Add(new(kind, outcome.IsError, target, index, detail)); + } + if (outcome.Value is not { } value) + { + return; + } + // 枚举:不在列表里但忽略大小写能对上 → 规范成列表里的写法(警告);完全对不上 → 错误。 + if (value is BsonString text && rules.Enums.TryGetValue(target, out BsonArray? allowed) && !allowed.Contains(text)) + { + BsonValue? match = allowed.FirstOrDefault(a => a.IsString && string.Equals(a.AsString, text.Value, StringComparison.OrdinalIgnoreCase)); + if (match is not null) + { + issues.Add(new(ImportIssueKind.EnumCase, false, target, index, $"{text.Value} → {match.AsString}")); + value = match; + } + else + { + issues.Add(new(ImportIssueKind.EnumMismatch, rules.ValidationErrors, target, index, text.Value)); + } + } + values[index] = value; + BsonPath.Set(document, target, value); + } + + private void Check(BsonDocument document, List issues) + { + IEnumerable required = rules.Required; + if (rules.Mode != ImportWriteMode.Insert && rules.MatchKey is { Length: > 0 } key && !rules.Required.Contains(key)) + { + required = required.Append(key); + } + foreach (string field in required) + { + if (BsonPath.Get(document, field) is null or BsonNull) + { + int column = Array.FindIndex(_included, c => c.Target.Trim() == field); + issues.Add(new(ImportIssueKind.MissingRequired, true, field, column, "")); + } + } + if (rules.Validator is { } validator && !issues.Any(static i => i.IsError)) + { + foreach (SchemaViolation violation in JsonSchemaValidator.Validate(validator, document, loc)) + { + if (violation.Keyword is "required" or "enum") + { + continue; + } + int column = Array.FindIndex(_included, c => c.Target.Trim() == violation.Path); + issues.Add(new(ImportIssueKind.Schema, rules.ValidationErrors, violation.Path, column, violation.Message)); + } + } + } +} + +/// 右栏「问题」的一组。 +/// 种类。 +/// 错误还是警告。 +/// 字段。 +/// 涉及多少行。 +/// 第一处的行号。 +/// 第一处的细节。 +internal sealed record ImportIssueGroup(ImportIssueKind Kind, bool IsError, string Field, int Rows, long FirstLine, string Detail); + +/// dry-run 的统计。 +internal static class ImportDryRun +{ + /// 把若干行的问题按(种类, 字段)分组;错误在前。 + public static IReadOnlyList Group(IEnumerable rows) + { + var groups = new Dictionary<(ImportIssueKind, string, bool), (int Rows, long First, string Detail)>(); + var order = new List<(ImportIssueKind, string, bool)>(); + foreach (ConvertedRow row in rows) + { + foreach (ImportIssue issue in row.Issues.DistinctBy(static i => (i.Kind, i.Field, i.IsError))) + { + var key = (issue.Kind, issue.Field, issue.IsError); + if (groups.TryGetValue(key, out var existing)) + { + groups[key] = (existing.Rows + 1, existing.First, existing.Detail); + } + else + { + groups[key] = (1, row.Line, issue.Detail); + order.Add(key); + } + } + } + return + [ + .. order.Select(k => new ImportIssueGroup(k.Item1, k.Item3, k.Item2, groups[k].Rows, groups[k].First, groups[k].Detail)) + .OrderBy(static g => g.IsError ? 0 : 1) + ]; + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Transfer/ImportReaders.cs b/plugins/VelaShell.Plugin.Mongo/Transfer/ImportReaders.cs new file mode 100644 index 0000000..a44d0e1 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Transfer/ImportReaders.cs @@ -0,0 +1,378 @@ +using System.Text; +using MongoDB.Bson; + +namespace VelaShell.Plugin.Mongo.Transfer; + +/// 导入文件的格式。 +internal enum ImportFormat +{ + /// CSV / TSV。 + Csv, + + /// JSON:一行一份(JSONL / mongoexport 默认)、一个大数组、或若干文档首尾相接 —— 读者都认。 + Json, + + /// 裸 BSON(mongodump 的 .bson,可带 .gz)。 + Bson +} + +/// 认出来的导入文件。 +internal sealed record ImportSource +{ + /// 路径。 + public required string Path { get; init; } + + /// 字节数。 + public long Size { get; init; } + + /// 格式。 + public ImportFormat Format { get; init; } + + /// 文本编码(BSON 无意义)。 + public TextEncodingKind Encoding { get; init; } = TextEncodingKind.Utf8; + + /// CSV 分隔符。 + public char Delimiter { get; init; } = ','; + + /// CSV 首行是列名。 + public bool HasHeader { get; init; } = true; + + /// + /// 认文件:扩展名优先(.bson / .gz 一定是 BSON;.csv / .tsv 一定是 CSV), + /// 认不出时看第一个非空字符 —— { / [ 是 JSON,其余当 CSV。 + /// + public static ImportSource Sniff(string path) + { + var file = new FileInfo(path); + string ext = System.IO.Path.GetExtension(path).ToLowerInvariant(); + if (ext is ".bson" or ".gz") + { + return new() { Path = path, Size = file.Length, Format = ImportFormat.Bson }; + } + TextEncodingKind encoding = TextEncodings.Detect(path); + IReadOnlyList head = HeadLines(path, encoding, 20); + ImportFormat format = ext switch + { + ".csv" or ".tsv" => ImportFormat.Csv, + ".json" or ".jsonl" or ".ndjson" => ImportFormat.Json, + _ => head.FirstOrDefault(static l => l.Trim().Length > 0)?.TrimStart() is { Length: > 0 } first && first[0] is '{' or '[' + ? ImportFormat.Json + : ImportFormat.Csv + }; + char delimiter = ext == ".tsv" ? '\t' : CsvReader.DetectDelimiter(head); + return new() + { + Path = path, + Size = file.Length, + Format = format, + Encoding = encoding, + Delimiter = delimiter + }; + } + + /// 读文件开头若干行(第一步的原文预览、分隔符探测)。 + public static IReadOnlyList HeadLines(string path, TextEncodingKind encoding, int count) + { + var lines = new List(); + using FileStream stream = File.OpenRead(path); + using StreamReader reader = TextEncodings.OpenReader(stream, encoding); + while (lines.Count < count && reader.ReadLine() is { } line) + { + lines.Add(line.Length > 400 ? line[..400] + "…" : line); + } + return lines; + } + + /// 开读者。 + public IImportReader Open() => Format switch + { + ImportFormat.Csv => new CsvImportReader(this), + ImportFormat.Json => new JsonImportReader(this), + _ => new BsonImportReader(this) + }; + + /// 流式数记录数(文件头里那句「1,246,302 行」;CSV 不含表头)。 + public long CountRecords(CancellationToken cancellationToken) + { + switch (Format) + { + case ImportFormat.Bson: + return BsonDump.Count(Path, cancellationToken); + case ImportFormat.Json: + { + using FileStream stream = File.OpenRead(Path); + using StreamReader reader = TextEncodings.OpenReader(stream, Encoding); + var scanner = new JsonDocumentScanner(reader); + long count = 0; + while (scanner.Skip()) + { + if ((++count & 0xFFF) == 0) + { + cancellationToken.ThrowIfCancellationRequested(); + } + } + return count; + } + default: + { + using FileStream stream = File.OpenRead(Path); + using StreamReader reader = TextEncodings.OpenReader(stream, Encoding); + long records = CsvReader.CountRecords(reader, cancellationToken); + return HasHeader ? Math.Max(0, records - 1) : records; + } + } + } +} + +/// 读出来的一条记录。 +/// 在文件里开始的物理行号(BSON 是第几份文档)。 +/// CSV 的单元格。 +/// JSON / BSON 的文档。 +/// 这一条本身就解析不了(坏 JSON)时的原因。 +internal sealed record ImportRecord(long Line, string[]? Cells, BsonDocument? Document, string? Error = null); + +/// 导入读者:一条一条地读,不把文件装进内存。 +internal interface IImportReader : IDisposable +{ + /// CSV 的列名(无表头时是 column1…);JSON / BSON 为空(列由抽样决定)。 + IReadOnlyList Header { get; } + + /// 读一条;读完返回 。 + bool TryRead(out ImportRecord record); +} + +/// CSV 读者。 +internal sealed class CsvImportReader : IImportReader +{ + private readonly FileStream _stream; + private readonly StreamReader _text; + private readonly CsvReader _csv; + private string[]? _pending; + private long _pendingLine; + + /// 构造(读掉表头)。 + public CsvImportReader(ImportSource source) + { + _stream = File.OpenRead(source.Path); + _text = TextEncodings.OpenReader(_stream, source.Encoding); + _csv = new CsvReader(_text, source.Delimiter); + if (_csv.TryRead(out string[] first)) + { + if (source.HasHeader) + { + Header = [.. first.Select(static (h, i) => h.Trim().Length > 0 ? h.Trim() : $"column{i + 1}")]; + } + else + { + Header = [.. first.Select(static (_, i) => $"column{i + 1}")]; + _pending = first; + _pendingLine = _csv.RecordLine; + } + } + else + { + Header = []; + } + } + + /// + public IReadOnlyList Header { get; } + + /// + public bool TryRead(out ImportRecord record) + { + if (_pending is { } pending) + { + _pending = null; + record = new(_pendingLine, pending, null); + return true; + } + if (_csv.TryRead(out string[] cells)) + { + record = new(_csv.RecordLine, cells, null); + return true; + } + record = new(0, null, null); + return false; + } + + /// + public void Dispose() + { + _text.Dispose(); + _stream.Dispose(); + } +} + +/// JSON 读者(JSONL / 数组 / 首尾相接的文档流都认;值的写法认 Relaxed / Canonical / mongosh)。 +internal sealed class JsonImportReader : IImportReader +{ + private readonly FileStream _stream; + private readonly StreamReader _text; + private readonly JsonDocumentScanner _scanner; + + /// 构造。 + public JsonImportReader(ImportSource source) + { + _stream = File.OpenRead(source.Path); + _text = TextEncodings.OpenReader(_stream, source.Encoding); + _scanner = new JsonDocumentScanner(_text); + } + + /// + public IReadOnlyList Header => []; + + /// + public bool TryRead(out ImportRecord record) + { + if (!_scanner.Next(out string json, out long line)) + { + record = new(0, null, null); + return false; + } + try + { + record = new(line, null, BsonDocument.Parse(json)); + } + catch (Exception ex) when (ex is FormatException or BsonSerializationException or ArgumentException or InvalidOperationException) + { + record = new(line, null, null, ex.Message); + } + return true; + } + + /// + public void Dispose() + { + _text.Dispose(); + _stream.Dispose(); + } +} + +/// BSON 读者。 +/// 来源。 +internal sealed class BsonImportReader(ImportSource source) : IImportReader +{ + private readonly Stream _stream = BsonDump.OpenRead(source.Path); + private long _index; + + /// + public IReadOnlyList Header => []; + + /// + public bool TryRead(out ImportRecord record) + { + BsonDocument? document = BsonDump.Read(_stream); + if (document is null) + { + record = new(0, null, null); + return false; + } + record = new(++_index, null, document); + return true; + } + + /// + public void Dispose() => _stream.Dispose(); +} + +/// +/// 从字符流里切出一份一份的**顶层 JSON 文档**:不管它们是一行一份、包在一个大数组里, +/// 还是美化过、跨了很多行首尾相接。只认括号深度与字符串,不解析值 —— +/// 解析交给驱动的 (它认 mongosh 的 ObjectId("…") 那些写法)。 +/// 这样一个几百 MB 的数组文件也只需要一份文档大小的内存。 +/// +internal sealed class JsonDocumentScanner(TextReader reader) +{ + private readonly char[] _buffer = new char[64 * 1024]; + private readonly StringBuilder _current = new(); + private int _length; + private int _position; + private long _line = 1; + + /// 取下一份文档的原文。 + public bool Next(out string json, out long line) => Scan(capture: true, out json, out line); + + /// 跳过下一份文档(计数用)。 + public bool Skip() => Scan(capture: false, out _, out _); + + private bool Scan(bool capture, out string json, out long line) + { + json = ""; + line = 0; + _current.Clear(); + int depth = 0; + bool inString = false; + bool escape = false; + char quote = '"'; + while (true) + { + if (_position >= _length) + { + _length = reader.Read(_buffer, 0, _buffer.Length); + _position = 0; + if (_length <= 0) + { + return false; + } + } + char c = _buffer[_position++]; + if (c == '\n') + { + _line++; + } + if (depth == 0) + { + // 文档之间:跳过空白、数组的 [ ] 与逗号、行注释之外的一切杂质。 + if (c == '{') + { + depth = 1; + line = _line; + if (capture) + { + _current.Append(c); + } + } + continue; + } + if (capture) + { + _current.Append(c); + } + if (inString) + { + if (escape) + { + escape = false; + } + else if (c == '\\') + { + escape = true; + } + else if (c == quote) + { + inString = false; + } + continue; + } + switch (c) + { + case '"' or '\'': + inString = true; + quote = c; + break; + case '{' or '[': + depth++; + break; + case '}' or ']': + depth--; + if (depth == 0) + { + json = capture ? _current.ToString() : ""; + return true; + } + break; + } + } + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Transfer/ImportRunner.cs b/plugins/VelaShell.Plugin.Mongo/Transfer/ImportRunner.cs new file mode 100644 index 0000000..46eecd3 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Transfer/ImportRunner.cs @@ -0,0 +1,357 @@ +using System.Diagnostics; +using System.Globalization; +using System.Text; +using MongoDB.Bson; +using MongoDB.Bson.IO; +using MongoDB.Driver; +using VelaShell.Plugin.Mongo.Bson; +using VelaShell.Plugin.Mongo.Core; + +namespace VelaShell.Plugin.Mongo.Transfer; + +/// 一次导入要做的事。 +internal sealed record ImportJob +{ + /// 库名。 + public required string Database { get; init; } + + /// 集合名。 + public required string Collection { get; init; } + + /// 源文件。 + public required ImportSource Source { get; init; } + + /// 映射。 + public required IReadOnlyList Columns { get; init; } + + /// 规则(含写入方式与匹配键)。 + public required ImportRules Rules { get; init; } + + /// 出错即停(否则跳过该行并写进错误报告)。 + public bool StopOnError { get; init; } + + /// 批大小。 + public int BatchSize { get; init; } = 1000; + + /// + /// 批内按顺序写(ordered: true):第一处服务器错误之后的同批文档不再写。 + /// 默认 —— 一行撞了唯一索引不该连累同一批的其余行。 + /// + public bool Ordered { get; init; } + + /// upsert / 替换前,把将被覆盖的文档备份到 JSONL。 + public bool Backup { get; init; } + + /// 预计总行数(进度条分母)。 + public long EstimatedTotal { get; init; } + + /// 错误报告与备份文件放哪儿。 + public required string ReportDirectory { get; init; } +} + +/// 导入进度。 +internal sealed record ImportProgress(long Rows, long Total, long Inserted, long Updated, long Skipped, TimeSpan Elapsed); + +/// 导入结果。 +internal sealed record ImportResult( + long Rows, + long Inserted, + long Updated, + long Skipped, + TimeSpan Elapsed, + string? ErrorReport, + string? BackupFile, + bool Stopped); + +/// +/// 执行导入:读一条换算一条,攒满一批 bulkWrite 一次(默认 ordered: false —— +/// 一行撞了唯一索引不该连累同一批的其余 999 行)。 +/// +/// 写不进去的行(换算失败、服务器拒绝)逐行落进一份 JSONL 错误报告:行号、原因、原始内容, +/// 修好了能直接拿那份文件再导一次。 +/// +/// +internal static class ImportRunner +{ + /// 跑一次导入。 + public static async Task RunAsync( + MongoConnection connection, + ImportJob job, + Loc loc, + IProgress? progress, + CancellationToken cancellationToken) + { + var watch = Stopwatch.StartNew(); + IMongoCollection collection = connection.Collection(job.Database, job.Collection); + var converter = new ImportConverter(job.Columns, job.Rules, loc); + var report = new ReportFiles(job); + var batch = new List<(ConvertedRow Row, ImportRecord Record, WriteModel Model)>(job.BatchSize); + long rows = 0, inserted = 0, updated = 0, skipped = 0; + bool stopped = false; + TimeSpan lastReport = TimeSpan.Zero; + using IImportReader reader = job.Source.Open(); + try + { + while (!stopped && reader.TryRead(out ImportRecord record)) + { + rows++; + ConvertedRow row = converter.Convert(record); + if (row.Status == ImportRowStatus.Error) + { + skipped++; + report.Error(row.Line, [.. row.Issues.Where(static i => i.IsError).Select(i => ImportIssueText.Describe(i, loc))], record, reader.Header); + if (job.StopOnError) + { + stopped = true; + break; + } + continue; + } + batch.Add((row, record, Model(row.Document, job.Rules))); + if (batch.Count >= job.BatchSize) + { + (long i, long u, long s, bool stop) = await FlushAsync(collection, job, batch, report, reader.Header, loc, cancellationToken).ConfigureAwait(false); + inserted += i; + updated += u; + skipped += s; + stopped = stop; + } + if (watch.Elapsed - lastReport > TimeSpan.FromMilliseconds(150)) + { + lastReport = watch.Elapsed; + progress?.Report(new(rows, job.EstimatedTotal, inserted, updated, skipped, watch.Elapsed)); + cancellationToken.ThrowIfCancellationRequested(); + } + } + if (!stopped && batch.Count > 0) + { + (long i, long u, long s, bool stop) = await FlushAsync(collection, job, batch, report, reader.Header, loc, cancellationToken).ConfigureAwait(false); + inserted += i; + updated += u; + skipped += s; + stopped = stop; + } + } + finally + { + report.Dispose(); + } + progress?.Report(new(rows, job.EstimatedTotal, inserted, updated, skipped, watch.Elapsed)); + return new(rows, inserted, updated, skipped, watch.Elapsed, report.ErrorPath, report.BackupPath, stopped); + } + + /// 一份文档 → 写模型。 + internal static WriteModel Model(BsonDocument document, ImportRules rules) + { + if (rules.Mode == ImportWriteMode.Insert || rules.MatchKey is not { Length: > 0 } key) + { + return new InsertOneModel(document); + } + BsonValue keyValue = BsonPath.Get(document, key) ?? BsonNull.Value; + var filter = new BsonDocument(key, keyValue); + if (rules.Mode == ImportWriteMode.Replace) + { + BsonDocument replacement = document; + if (key != "_id" && document.Contains("_id")) + { + // 命中的文档保留它自己的 _id(改 _id 会被服务器拒:ImmutableField)。 + replacement = document.DeepClone().AsBsonDocument; + replacement.Remove("_id"); + } + return new ReplaceOneModel(filter, replacement) { IsUpsert = true }; + } + var set = new BsonDocument(document.Where(e => e.Name != "_id" && e.Name != key)); + var update = new BsonDocument(); + if (set.ElementCount > 0) + { + update["$set"] = set; + } + var onInsert = new BsonDocument(); + if (key != "_id" && document.TryGetValue("_id", out BsonValue id)) + { + onInsert["_id"] = id; + } + if (set.ElementCount == 0 && onInsert.ElementCount == 0) + { + onInsert[key] = keyValue; + } + if (onInsert.ElementCount > 0) + { + update["$setOnInsert"] = onInsert; + } + return new UpdateOneModel(filter, update) { IsUpsert = true }; + } + + private static async Task<(long Inserted, long Updated, long Skipped, bool Stop)> FlushAsync( + IMongoCollection collection, + ImportJob job, + List<(ConvertedRow Row, ImportRecord Record, WriteModel Model)> batch, + ReportFiles report, + IReadOnlyList header, + Loc loc, + CancellationToken cancellationToken) + { + if (batch.Count == 0) + { + return (0, 0, 0, false); + } + if (job.Backup && job.Rules.Mode != ImportWriteMode.Insert && job.Rules.MatchKey is { Length: > 0 } key) + { + var keys = new BsonArray(batch.Select(b => BsonPath.Get(b.Row.Document, key)).OfType().Distinct()); + if (keys.Count > 0) + { + using IAsyncCursor cursor = await collection.FindAsync( + new BsonDocument(key, new BsonDocument("$in", keys)), cancellationToken: cancellationToken).ConfigureAwait(false); + while (await cursor.MoveNextAsync(cancellationToken).ConfigureAwait(false)) + { + foreach (BsonDocument existing in cursor.Current) + { + report.Backup(existing); + } + } + } + } + long inserted = 0, updated = 0, skipped = 0; + bool stop = false; + try + { + BulkWriteResult result = await collection.BulkWriteAsync( + batch.Select(static b => b.Model), + new BulkWriteOptions { IsOrdered = job.Ordered || job.StopOnError }, + cancellationToken).ConfigureAwait(false); + (inserted, updated) = Count(result); + } + catch (MongoBulkWriteException ex) + { + if (ex.Result is { IsAcknowledged: true } partial) + { + (inserted, updated) = Count(partial); + } + foreach (BulkWriteError error in ex.WriteErrors) + { + if (error.Index >= 0 && error.Index < batch.Count) + { + (ConvertedRow row, ImportRecord record, _) = batch[error.Index]; + report.Error(row.Line, [$"E{error.Code.ToString(CultureInfo.InvariantCulture)}: {error.Message}"], record, header); + } + skipped++; + } + if (job.Ordered || job.StopOnError) + { + // ordered 批次在第一个错误处停下:它之后的那些也没写进去 —— 同样记进错误报告,修好了能补导。 + int firstError = ex.WriteErrors.Count > 0 ? ex.WriteErrors.Min(static e => e.Index) : batch.Count; + for (int i = firstError + 1; i < batch.Count; i++) + { + if (ex.WriteErrors.Any(e => e.Index == i)) + { + continue; + } + (ConvertedRow row, ImportRecord record, _) = batch[i]; + report.Error(row.Line, [loc.Format("Imp_NotWritten", batch[firstError].Row.Line)], record, header); + skipped++; + } + } + stop = job.StopOnError; + } + batch.Clear(); + return (inserted, updated, skipped, stop); + } + + private static (long Inserted, long Updated) Count(BulkWriteResult result) => + result.IsAcknowledged + ? (result.InsertedCount + result.Upserts.Count, result.MatchedCount) + : (0, 0); + + /// 错误报告与备份文件(惰性创建:没有错误就不留一个空文件)。 + private sealed class ReportFiles(ImportJob job) : IDisposable + { + private readonly string _stamp = DateTime.Now.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture); + private StreamWriter? _errors; + private StreamWriter? _backup; + + public string? ErrorPath { get; private set; } + + public string? BackupPath { get; private set; } + + public void Error(long line, IReadOnlyList reasons, ImportRecord record, IReadOnlyList header) + { + if (_errors is null) + { + ErrorPath = Path.Combine(job.ReportDirectory, $"{Path.GetFileNameWithoutExtension(job.Source.Path)}.errors-{_stamp}.jsonl"); + Directory.CreateDirectory(job.ReportDirectory); + _errors = new StreamWriter(ErrorPath, append: false, new UTF8Encoding(false)); + } + var entry = new BsonDocument + { + { "line", line }, + { "errors", new BsonArray(reasons) } + }; + if (record.Cells is { } cells) + { + var row = new BsonDocument(); + for (int i = 0; i < cells.Length; i++) + { + string name = i < header.Count ? header[i] : $"column{i + 1}"; + if (!row.Contains(name)) + { + row[name] = cells[i]; + } + } + entry["row"] = row; + } + else if (record.Document is { } document) + { + entry["row"] = document; + } + _errors.WriteLine(entry.ToJson(new JsonWriterSettings { OutputMode = JsonOutputMode.RelaxedExtendedJson })); + } + + public void Backup(BsonDocument document) + { + if (_backup is null) + { + BackupPath = Path.Combine(job.ReportDirectory, $"{job.Database}.{job.Collection}.backup-{_stamp}.jsonl"); + Directory.CreateDirectory(job.ReportDirectory); + _backup = new StreamWriter(BackupPath, append: false, new UTF8Encoding(false)); + } + // Canonical:备份是要能原样恢复的,类型一个都不能丢(Int64 / Decimal128 / 日期)。 + _backup.WriteLine(document.ToJson(new JsonWriterSettings { OutputMode = JsonOutputMode.CanonicalExtendedJson })); + } + + public void Dispose() + { + _errors?.Dispose(); + _backup?.Dispose(); + } + } +} + +/// 问题 → 一句人话(错误报告与右栏共用)。 +internal static class ImportIssueText +{ + /// 组标题(缺少必填字段 orderNo、日期无法解析)。 + public static string Title(ImportIssueKind kind, string field, string detail, Loc loc) => kind switch + { + ImportIssueKind.MissingRequired => loc.Format("Imp_IssueMissing", field), + ImportIssueKind.BadDate => loc["Imp_IssueBadDate"], + ImportIssueKind.BadNumber => loc.Format("Imp_IssueBadNumber", field), + ImportIssueKind.BadValue => loc.Format("Imp_IssueBadValue", field), + ImportIssueKind.NumberFromText => loc.Format("Imp_IssueNumberText", detail.Split('|') is [_, var type] ? type : "Number"), + ImportIssueKind.EnumCase => loc["Imp_IssueEnumCase"], + ImportIssueKind.EnumMismatch => loc.Format("Imp_IssueEnumMismatch", field), + ImportIssueKind.Parse => loc["Imp_IssueParse"], + _ => detail.Length > 0 ? detail : loc.Format("Imp_IssueSchema", field) + }; + + /// 一条问题的完整描述(错误报告里的一行)。 + public static string Describe(ImportIssue issue, Loc loc) + { + string title = Title(issue.Kind, issue.Field, issue.Detail, loc); + return issue.Kind switch + { + ImportIssueKind.BadDate => $"{title}: {issue.Field} ({issue.Detail})", + ImportIssueKind.BadNumber or ImportIssueKind.BadValue or ImportIssueKind.EnumMismatch or ImportIssueKind.Parse => $"{title}: {issue.Detail}", + _ => title + }; + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Transfer/ImportValues.cs b/plugins/VelaShell.Plugin.Mongo/Transfer/ImportValues.cs new file mode 100644 index 0000000..b792843 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Transfer/ImportValues.cs @@ -0,0 +1,328 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using MongoDB.Bson; +using VelaShell.Plugin.Mongo.Bson; + +namespace VelaShell.Plugin.Mongo.Transfer; + +/// 导入时一个单元格 / 字段上发现的问题种类(设计稿 20 右栏「问题」的分组键)。 +internal enum ImportIssueKind +{ + /// 缺少必填字段(集合 validator 的 required,或 upsert / 替换的匹配键)。 + MissingRequired, + + /// 日期无法解析。 + BadDate, + + /// 数值无法解析。 + BadNumber, + + /// 其他类型无法转换(布尔、ObjectId、JSON…)。 + BadValue, + + /// 字符串自动转成了数值(去掉了千分位 / 引号 / 货币符号)—— 警告。 + NumberFromText, + + /// 枚举值大小写已按 validator 的 enum 规范化 —— 警告。 + EnumCase, + + /// 值不在 validator 的 enum 里。 + EnumMismatch, + + /// 其他验证规则(minimum、pattern…,来自客户端 $jsonSchema 预检)。 + Schema, + + /// 这一条本身解析不了(坏 JSON)。 + Parse +} + +/// 一个值的换算结果。 +/// 换算后的值;失败为 。 +/// 附带的问题(警告或错误);没有为 。 +/// 问题是不是错误(这一行不能导入)。 +/// 问题的细节(MM/dd/yy、Paid → paid)。 +internal readonly record struct ConvertOutcome(BsonValue? Value, ImportIssueKind? Issue, bool IsError, string Detail) +{ + /// 成功、无问题。 + public static ConvertOutcome Ok(BsonValue value) => new(value, null, false, ""); + + /// 成功但带警告。 + public static ConvertOutcome Warn(BsonValue value, ImportIssueKind kind, string detail) => new(value, kind, false, detail); + + /// 失败。 + public static ConvertOutcome Fail(ImportIssueKind kind, string detail) => new(null, kind, true, detail); +} + +/// +/// 导入的值换算:文本(CSV)或已有的 BSON 值(JSON / BSON)→ 目标类型。 +/// +/// 与网格编辑的 刻意**不同**的两点: +/// 1. 日期只认**无歧义**的写法(ISO 8601、yyyy-MM-dd、yyyy/MM/dd)。 +/// 09/01/26 是 9 月 1 日还是 1 月 9 日,这里不替用户猜 —— 报"日期无法解析", +/// 由用户在右栏「指定日期格式」里给一个格式再重算; +/// 2. 带千分位、引号、货币符号的数字**能导入但要报警告**:那往往说明源数据是从报表里抄出来的, +/// 用户该知道这件事。 +/// +/// +internal static partial class ImportValues +{ + private static readonly string[] IsoFormats = + [ + "yyyy-MM-dd'T'HH:mm:ss.FFFFFFFK", "yyyy-MM-dd'T'HH:mm:ssK", "yyyy-MM-dd'T'HH:mmK", + "yyyy-MM-dd'T'HH:mm:ss.FFFFFFF", "yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd'T'HH:mm", + "yyyy-MM-dd HH:mm:ss.FFFFFFF", "yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm", "yyyy-MM-dd", + "yyyy/MM/dd HH:mm:ss", "yyyy/MM/dd HH:mm", "yyyy/MM/dd", "yyyy-MM-dd HH:mm:ssK" + ]; + + /// 文本 → 目标类型。 + /// 单元格原文。 + /// 目标类型。 + /// 用户指定的日期格式(先按它试)。 + public static ConvertOutcome FromText(string text, BsonKind kind, string? dateFormat) + { + string trimmed = text.Trim(); + switch (kind) + { + case BsonKind.String: + return ConvertOutcome.Ok(new BsonString(text)); + case BsonKind.Null: + return ConvertOutcome.Ok(BsonNull.Value); + case BsonKind.Int32 or BsonKind.Int64 or BsonKind.Double or BsonKind.Decimal128: + return Number(trimmed, kind); + case BsonKind.Boolean: + return trimmed.ToLowerInvariant() switch + { + "true" or "1" or "yes" or "y" or "是" or "t" => ConvertOutcome.Ok(BsonBoolean.True), + "false" or "0" or "no" or "n" or "否" or "f" => ConvertOutcome.Ok(BsonBoolean.False), + _ => ConvertOutcome.Fail(ImportIssueKind.BadValue, trimmed) + }; + case BsonKind.Date: + return TryDate(trimmed, dateFormat, out DateTime utc) + ? ConvertOutcome.Ok(new BsonDateTime(utc)) + : ConvertOutcome.Fail(ImportIssueKind.BadDate, GuessDateFormat(trimmed, includeTime: false) ?? trimmed); + case BsonKind.ObjectId: + { + string hex = Unwrap(trimmed, "ObjectId"); + return ObjectId.TryParse(hex, out ObjectId id) + ? ConvertOutcome.Ok(id) + : ConvertOutcome.Fail(ImportIssueKind.BadValue, trimmed); + } + case BsonKind.Uuid: + { + string guid = Unwrap(trimmed, "UUID"); + return Guid.TryParse(guid, out Guid g) + ? ConvertOutcome.Ok(new BsonBinaryData(g, GuidRepresentation.Standard)) + : ConvertOutcome.Fail(ImportIssueKind.BadValue, trimmed); + } + case BsonKind.Object or BsonKind.Array: + try + { + BsonValue parsed = ShellJson.ParseValue(trimmed); + return (kind == BsonKind.Object ? parsed is BsonDocument : parsed is BsonArray) + ? ConvertOutcome.Ok(parsed) + : ConvertOutcome.Fail(ImportIssueKind.BadValue, Shorten(trimmed)); + } + catch (ShellJsonException) + { + return ConvertOutcome.Fail(ImportIssueKind.BadValue, Shorten(trimmed)); + } + default: + return ConvertOutcome.Ok(new BsonString(text)); + } + } + + /// 已有的 BSON 值 → 目标类型(JSON / BSON 来源)。 + public static ConvertOutcome FromValue(BsonValue value, BsonKind kind, string? dateFormat) + { + BsonKind current = BsonKinds.Of(value); + if (current == kind || current == BsonKind.Null) + { + return ConvertOutcome.Ok(value); + } + if (value is BsonString text) + { + ConvertOutcome converted = FromText(text.Value, kind, dateFormat); + // JSON 里的字符串变成数值:能导入,但这是源数据类型不对的信号。 + return converted.Value is not null && BsonKinds.IsNumeric(kind) + ? ConvertOutcome.Warn(converted.Value, ImportIssueKind.NumberFromText, "") + : converted; + } + if (kind == BsonKind.String) + { + return ConvertOutcome.Ok(new BsonString(BsonEdit.EditText(value))); + } + if (value.IsNumeric && BsonKinds.IsNumeric(kind)) + { + BsonValue number = BsonEdit.Convert(value, kind, out bool reset); + return reset ? ConvertOutcome.Fail(ImportIssueKind.BadNumber, BsonText.Inline(value)) : ConvertOutcome.Ok(number); + } + if (kind == BsonKind.Date && value is BsonInt64 or BsonInt32 or BsonDouble) + { + long ms = value.ToInt64(); + return ConvertOutcome.Ok(new BsonDateTime(ms)); + } + return ConvertOutcome.Fail(ImportIssueKind.BadValue, BsonText.Inline(value)); + } + + private static ConvertOutcome Number(string text, BsonKind kind) + { + if (TryNumber(text, kind, out BsonValue? value, out bool fraction)) + { + return ConvertOutcome.Ok(value!); + } + if (fraction) + { + return ConvertOutcome.Fail(ImportIssueKind.BadNumber, text); + } + string cleaned = CleanNumber(text, out bool grouping); + if (cleaned.Length > 0 && cleaned != text && TryNumber(cleaned, kind, out value, out _)) + { + return ConvertOutcome.Warn(value!, ImportIssueKind.NumberFromText, grouping ? "grouping" : "symbols"); + } + return ConvertOutcome.Fail(ImportIssueKind.BadNumber, text); + } + + private static bool TryNumber(string text, BsonKind kind, out BsonValue? value, out bool fraction) + { + value = null; + fraction = false; + switch (kind) + { + case BsonKind.Int32 or BsonKind.Int64: + if (long.TryParse(text, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out long whole)) + { + if (kind == BsonKind.Int32 && whole is < int.MinValue or > int.MaxValue) + { + return false; + } + value = kind == BsonKind.Int32 ? new BsonInt32((int)whole) : new BsonInt64(whole); + return true; + } + // "12.00" 当整数可以接受;"12.5" 不行(静默截断会丢钱)。 + if (decimal.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out decimal d)) + { + if (decimal.Truncate(d) != d) + { + fraction = true; + return false; + } + if (kind == BsonKind.Int32 && d is < int.MinValue or > int.MaxValue) + { + return false; + } + value = kind == BsonKind.Int32 ? new BsonInt32((int)d) : new BsonInt64((long)d); + return true; + } + return false; + case BsonKind.Double: + if (double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out double dbl)) + { + value = new BsonDouble(dbl); + return true; + } + return false; + default: + if (text.Length > 0 && (char.IsDigit(text[0]) || text[0] is '-' or '+' or '.') + && Decimal128.TryParse(text, out Decimal128 dec)) + { + value = new BsonDecimal128(dec); + return true; + } + return false; + } + } + + /// 去掉引号、千分位、货币符号与空白("1,580.00" → 1580.00)。 + internal static string CleanNumber(string text, out bool grouping) + { + grouping = false; + string t = text.Trim().Trim('"', '\'', '“', '”').Trim(); + var builder = new System.Text.StringBuilder(t.Length); + foreach (char c in t) + { + switch (c) + { + case ',' or '_' or ' ' or ' ' or ' ': + grouping = true; + break; + case '¥' or '¥' or '$' or '€' or '£': + break; + default: + builder.Append(c); + break; + } + } + return builder.ToString(); + } + + /// + /// 解日期:用户指定的格式优先;其余只认无歧义的写法。没有时区的按本地时间。 + /// + public static bool TryDate(string text, string? dateFormat, out DateTime utc) + { + string t = Unwrap(Unwrap(text, "ISODate"), "new Date"); + if (dateFormat is { Length: > 0 } + && DateTime.TryParseExact(t, dateFormat, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal | DateTimeStyles.AllowWhiteSpaces, out DateTime custom)) + { + utc = custom.ToUniversalTime(); + return true; + } + if (DateTimeOffset.TryParseExact(t, IsoFormats, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out DateTimeOffset iso)) + { + utc = iso.UtcDateTime; + return true; + } + utc = default; + return false; + } + + /// + /// 猜一个日期写法的格式(09/01/26 11:31 → MM/dd/yy HH:mm), + /// 用来写"格式 MM/dd/yy"与"指定日期格式"的默认值。第一段大于 12 时猜 dd/MM。 + /// + public static string? GuessDateFormat(string text, bool includeTime) + { + Match m = SlashDate().Match(text.Trim()); + string? date = null; + string? time = null; + if (m.Success) + { + bool dayFirst = int.Parse(m.Groups[1].Value, CultureInfo.InvariantCulture) > 12; + string sep = m.Groups[2].Value; + string year = m.Groups[4].Value.Length == 2 ? "yy" : "yyyy"; + date = dayFirst ? $"dd{sep}MM{sep}{year}" : $"MM{sep}dd{sep}{year}"; + if (m.Groups[5].Success) + { + time = m.Groups[7].Success ? "HH:mm:ss" : "HH:mm"; + } + } + else if (Compact().IsMatch(text.Trim())) + { + date = "yyyyMMdd"; + } + if (date is null) + { + return null; + } + return includeTime && time is not null ? $"{date} {time}" : date; + } + + /// 剥掉 Ctor("…") 外壳与引号。 + private static string Unwrap(string text, string ctor) + { + string t = text.Trim(); + if (t.StartsWith(ctor + "(", StringComparison.Ordinal) && t.EndsWith(')')) + { + t = t[(ctor.Length + 1)..^1].Trim(); + } + return t.Trim('"', '\''); + } + + private static string Shorten(string text) => text.Length > 40 ? text[..40] + "…" : text; + + [GeneratedRegex(@"^(\d{1,2})([/.\-])(\d{1,2})\2(\d{2}|\d{4})(\s+(\d{1,2}):\d{2}(:\d{2})?)?$")] + private static partial Regex SlashDate(); + + [GeneratedRegex(@"^\d{8}$")] + private static partial Regex Compact(); +} diff --git a/plugins/VelaShell.Plugin.Mongo/Transfer/SchemaSampler.cs b/plugins/VelaShell.Plugin.Mongo/Transfer/SchemaSampler.cs new file mode 100644 index 0000000..bb7521b --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Transfer/SchemaSampler.cs @@ -0,0 +1,136 @@ +using MongoDB.Bson; +using MongoDB.Driver; +using VelaShell.Plugin.Mongo.Bson; + +namespace VelaShell.Plugin.Mongo.Transfer; + +/// +/// 抽样 schema:导出字段表(设计稿 19「来自 Schema 抽样」)与导入的默认目标类型都从这里来。 +/// +/// 嵌套文档按需**展开**成 customer.name / customer.level 这样的叶子路径(CSV 一列一个叶子); +/// 数组**不展开** —— 数组里的元素个数每份文档都不同,展开成 items.0.sku 列只会得到一张参差不齐的表, +/// 所以数组整体是一个字段,导出时写成 JSON 字符串。 +/// +/// +internal static class SchemaSampler +{ + /// 分析一批文档。 + /// 文档。 + /// 嵌套文档展开成叶子路径。 + /// 最多展开几层。 + public static IReadOnlyList Analyze(IEnumerable documents, bool flatten, int maxDepth = 4) + { + var order = new List(); + var kinds = new Dictionary>(StringComparer.Ordinal); + var counts = new Dictionary(StringComparer.Ordinal); + int total = 0; + foreach (BsonDocument document in documents) + { + total++; + var seen = new HashSet(StringComparer.Ordinal); + Visit(document, null, 0); + + void Visit(BsonDocument doc, string? prefix, int depth) + { + foreach (BsonElement element in doc) + { + string path = BsonPath.Join(prefix, element.Name); + if (flatten && depth < maxDepth && TransferText.IsNestedDocument(element.Value)) + { + Visit(element.Value.AsBsonDocument, path, depth + 1); + continue; + } + if (!kinds.TryGetValue(path, out Dictionary? perKind)) + { + perKind = []; + kinds[path] = perKind; + order.Add(path); + counts[path] = 0; + } + BsonKind kind = BsonKinds.Of(element.Value); + perKind[kind] = perKind.GetValueOrDefault(kind) + 1; + if (seen.Add(path)) + { + counts[path]++; + } + } + } + } + // _id 永远在第一列(与 mongoexport、网格一致),其余按第一次出现的顺序。 + IEnumerable ordered = order.Where(static p => p == "_id").Concat(order.Where(static p => p != "_id")); + return + [ + .. ordered.Select(path => new XferField( + path, + [ + .. kinds[path].OrderBy(static kv => kv.Key == BsonKind.Null ? 1 : 0) + .ThenByDescending(static kv => kv.Value) + .Select(static kv => kv.Key) + ], + counts[path], + total)) + ]; + } + + /// + /// 从集合里抽样($match + $sample)。视图同样支持 $sample。 + /// 抽样失败(权限、超时)时退回 find().limit():字段表宁可不随机,也不能是空的。 + /// + public static async Task> SampleAsync( + IMongoCollection collection, + BsonDocument? filter, + int size, + CancellationToken cancellationToken) + { + var stages = new List(); + if (filter is { ElementCount: > 0 }) + { + stages.Add(new BsonDocument("$match", filter)); + } + stages.Add(new BsonDocument("$sample", new BsonDocument("size", size))); + try + { + using IAsyncCursor cursor = await collection.AggregateAsync( + stages.ToArray(), + new AggregateOptions { MaxTime = TimeSpan.FromSeconds(15), AllowDiskUse = true }, + cancellationToken).ConfigureAwait(false); + return await cursor.ToListAsync(cancellationToken).ConfigureAwait(false); + } + catch (MongoCommandException) + { + return await collection.Find(filter ?? new BsonDocument()).Limit(size).ToListAsync(cancellationToken).ConfigureAwait(false); + } + } + + /// 从 $jsonSchema 里取一个字段声明的类型(导入的默认目标类型优先听它的)。 + public static BsonKind? KindFromSchema(BsonDocument? schemaProperty) + { + if (schemaProperty is null) + { + return null; + } + BsonValue? type = schemaProperty.TryGetValue("bsonType", out BsonValue bsonType) + ? bsonType + : schemaProperty.TryGetValue("type", out BsonValue jsonType) ? jsonType : null; + string? name = type switch + { + BsonString s => s.Value, + BsonArray a => a.FirstOrDefault(static v => v.IsString && v.AsString != "null")?.AsString, + _ => null + }; + return name switch + { + "string" => BsonKind.String, + "int" => BsonKind.Int32, + "long" => BsonKind.Int64, + "double" or "number" => BsonKind.Double, + "decimal" => BsonKind.Decimal128, + "bool" or "boolean" => BsonKind.Boolean, + "date" => BsonKind.Date, + "objectId" => BsonKind.ObjectId, + "object" => BsonKind.Object, + "array" => BsonKind.Array, + _ => null + }; + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Transfer/TextEncodings.cs b/plugins/VelaShell.Plugin.Mongo/Transfer/TextEncodings.cs new file mode 100644 index 0000000..71a3d80 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Transfer/TextEncodings.cs @@ -0,0 +1,97 @@ +using System.Text; + +namespace VelaShell.Plugin.Mongo.Transfer; + +/// +/// 文本编码:导出时按用户选的写,导入时从文件头认。 +/// +/// GBK 走 **直接取**,不调 Encoding.RegisterProvider: +/// 后者是进程级的全局登记,插件不该替宿主改这种东西。 +/// +/// +internal static class TextEncodings +{ + /// GBK 的代码页。 + private const int GbkCodePage = 936; + + /// 取编码对象。UTF-8 带 BOM 的那个会让 先写三个字节的 BOM。 + public static Encoding Get(TextEncodingKind kind) => kind switch + { + TextEncodingKind.Utf8Bom => new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), + TextEncodingKind.Gbk => Gbk, + TextEncodingKind.Utf16 => new UnicodeEncoding(bigEndian: false, byteOrderMark: true), + _ => new UTF8Encoding(encoderShouldEmitUTF8Identifier: false) + }; + + /// GBK 编码。 + public static Encoding Gbk => CodePagesEncodingProvider.Instance.GetEncoding(GbkCodePage) ?? Encoding.UTF8; + + /// 给人看的名字(文件头那一行:UTF-8 / GBK)。 + public static string Name(TextEncodingKind kind) => kind switch + { + TextEncodingKind.Utf8Bom => "UTF-8 BOM", + TextEncodingKind.Gbk => "GBK", + TextEncodingKind.Utf16 => "UTF-16", + _ => "UTF-8" + }; + + /// + /// 从文件头认编码:先看 BOM;没有 BOM 时按严格 UTF-8 解一遍,解不通就当 GBK —— + /// 国内导出的 CSV 不是 UTF-8 的,十有八九是 GBK(Excel "另存为 CSV" 的默认)。 + /// + /// 文件开头的若干字节(64 KB 足够)。 + /// 这段是不是整个文件(否则末尾可能截断了一个多字节字符,要宽容)。 + public static TextEncodingKind Detect(ReadOnlySpan head, bool complete) + { + if (head.Length >= 3 && head[0] == 0xEF && head[1] == 0xBB && head[2] == 0xBF) + { + return TextEncodingKind.Utf8Bom; + } + if (head.Length >= 2 && head[0] == 0xFF && head[1] == 0xFE) + { + return TextEncodingKind.Utf16; + } + ReadOnlySpan body = head; + if (!complete) + { + // 截掉末尾可能被切开的那个多字节字符(UTF-8 最长 4 字节)。 + int cut = body.Length; + for (int i = 1; i <= 3 && i <= body.Length; i++) + { + byte b = body[^i]; + if ((b & 0xC0) == 0xC0) + { + cut = body.Length - i; + break; + } + if ((b & 0x80) == 0) + { + break; + } + } + body = body[..cut]; + } + try + { + _ = new UTF8Encoding(false, throwOnInvalidBytes: true).GetCharCount(body); + return TextEncodingKind.Utf8; + } + catch (DecoderFallbackException) + { + return TextEncodingKind.Gbk; + } + } + + /// 读文件开头一段并认编码。 + public static TextEncodingKind Detect(string path) + { + using FileStream stream = File.OpenRead(path); + byte[] buffer = new byte[64 * 1024]; + int read = stream.ReadAtLeast(buffer, buffer.Length, throwOnEndOfStream: false); + return Detect(buffer.AsSpan(0, read), complete: read < buffer.Length); + } + + /// 按编码开一个读文本的读者(会自己跳过 BOM)。 + public static StreamReader OpenReader(Stream stream, TextEncodingKind kind) => + new(stream, Get(kind), detectEncodingFromByteOrderMarks: kind != TextEncodingKind.Gbk, bufferSize: 64 * 1024); +} diff --git a/plugins/VelaShell.Plugin.Mongo/Transfer/TransferModels.cs b/plugins/VelaShell.Plugin.Mongo/Transfer/TransferModels.cs new file mode 100644 index 0000000..6070815 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Transfer/TransferModels.cs @@ -0,0 +1,253 @@ +using MongoDB.Bson; +using VelaShell.Plugin.Mongo.Bson; + +namespace VelaShell.Plugin.Mongo.Transfer; + +/// +/// CSV / Excel 单元格的值换算(设计稿 19 字段表的「转换」列)。 +/// +/// 换算按**字段**挑,而不是全局一个开关:同一张表里 _id 要十六进制串、total 要两位小数、 +/// createdAt 要本地时间 —— 这正是从 MongoDB 导出给财务看的那张表的样子。 +/// +/// +internal enum CellConversion +{ + /// 原样(字符串、整数;Decimal 保留原始小数位)。 + None, + + /// ObjectId → 24 位十六进制字符串。 + Hex, + + /// 数值 → 保留 2 位小数(不带千分位 —— 千分位会让 CSV 的逗号分隔串位)。 + Fixed2, + + /// 日期 → 本地时间 yyyy-MM-dd HH:mm:ss。 + LocalTime, + + /// 日期 → ISO 8601 UTC。 + IsoTime, + + /// 数组 / 嵌套文档 → 单行 JSON 字符串(Relaxed EJSON)。 + Json +} + +/// CSV 里嵌套文档的写法。 +internal enum NestedMode +{ + /// 展开为 a.b 列。 + Flatten, + + /// 整个对象写成一列 JSON 字符串。 + Json +} + +/// 文本文件的编码。 +internal enum TextEncodingKind +{ + /// UTF-8(无 BOM)。 + Utf8, + + /// UTF-8 带 BOM —— Excel 双击打开 CSV 时靠它认出 UTF-8,否则中文全是乱码。 + Utf8Bom, + + /// GBK(代码页 936):老版本 Excel 与国内不少系统的默认编码。 + Gbk, + + /// UTF-16 LE(带 BOM)。 + Utf16 +} + +/// CSV 选项。 +internal sealed record CsvOptions +{ + /// 分隔符。 + public char Delimiter { get; init; } = ','; + + /// 编码。 + public TextEncodingKind Encoding { get; init; } = TextEncodingKind.Utf8Bom; + + /// 嵌套对象的写法。 + public NestedMode Nested { get; init; } = NestedMode.Flatten; + + /// null 写成空单元格(否则写字面量 null)。 + public bool NullAsEmpty { get; init; } = true; + + /// 首行写入列名。 + public bool WriteHeader { get; init; } = true; +} + +/// JSON 选项。 +internal sealed record JsonOptions +{ + /// 行分隔(JSONL,一行一份文档 —— 与 mongoexport 默认一致);否则写成一个数组。 + public bool Lines { get; init; } = true; + + /// 扩展 JSON 写法。 + public EjsonMode Mode { get; init; } = EjsonMode.Relaxed; +} + +/// Excel 选项。 +internal sealed record ExcelOptions +{ + /// 工作表名(空 = 用集合名)。 + public string SheetName { get; init; } = ""; + + /// 冻结首行。 + public bool FreezeHeader { get; init; } = true; + + /// 首行写入列名。 + public bool WriteHeader { get; init; } = true; +} + +/// BSON 转储选项。 +internal sealed record DumpOptions +{ + /// gzip 压缩(mongodump --gzip 的布局:.bson.gz + .metadata.json.gz)。 + public bool Gzip { get; init; } +} + +/// Shell 脚本选项。 +internal sealed record ShellOptions +{ + /// 每条 insertMany 带多少份文档(mongosh 单条语句太大会解析得很慢)。 + public int BatchSize { get; init; } = 1000; +} + +/// CSV / Excel 的一列。 +/// 字段路径(customer.name)。 +/// 列名(可改,客户)。 +/// 值换算。 +internal sealed record ExportColumn(string Path, string Header, CellConversion Conversion); + +/// 抽样得到的一个字段。 +/// 路径。 +/// 出现过的类型(按出现次数降序,null 排在非空类型之后)。 +/// 出现在多少份文档里。 +/// 抽样文档数。 +internal sealed record XferField(string Path, IReadOnlyList Kinds, int Count, int Total) +{ + /// 主类型(第一个非 null 的类型;全是 null 时就是 Null)。 + public BsonKind Dominant => Kinds.FirstOrDefault(static k => k != BsonKind.Null, Kinds.Count > 0 ? Kinds[0] : BsonKind.Missing); + + /// 出现率。 + public double Presence => Total == 0 ? 0 : (double)Count / Total; + + /// 出现过 null(或在部分文档里缺失)。 + public bool Nullable => Kinds.Contains(BsonKind.Null) || Count < Total; + + /// 类型文字(String | Null)。 + public string KindsText => string.Join(" | ", Kinds.Take(3).Select(BsonKinds.Name)); + + /// 这个字段默认的换算。 + public CellConversion DefaultConversion => DefaultConversionFor(Dominant); + + /// 某种类型默认的换算。 + public static CellConversion DefaultConversionFor(BsonKind kind) => kind switch + { + BsonKind.ObjectId => CellConversion.Hex, + BsonKind.Decimal128 => CellConversion.Fixed2, + BsonKind.Date => CellConversion.LocalTime, + BsonKind.Array or BsonKind.Object => CellConversion.Json, + _ => CellConversion.None + }; + + /// 某种类型可选的换算(字段表的下拉)。 + public static IReadOnlyList ChoicesFor(BsonKind kind) => kind switch + { + BsonKind.ObjectId => [CellConversion.Hex], + BsonKind.Decimal128 or BsonKind.Double => [CellConversion.None, CellConversion.Fixed2], + BsonKind.Date => [CellConversion.LocalTime, CellConversion.IsoTime], + BsonKind.Array or BsonKind.Object => [CellConversion.Json], + _ => [CellConversion.None] + }; +} + +/// 导出进度。 +/// 已写出的文档数。 +/// 预计总数(未知为 0)。 +/// 已写出的字节。 +/// 已用时间。 +/// 当前集合。 +internal sealed record ExportProgress(long Documents, long Total, long Bytes, TimeSpan Elapsed, string Collection); + +/// 导出结果。 +/// 写出的文档数。 +/// 写出的字节。 +/// 耗时。 +/// 生成的文件(转储与多集合导出是多个)。 +internal sealed record ExportResult(long Documents, long Bytes, TimeSpan Elapsed, IReadOnlyList Files); + +/// 一行日志的语气(数据传输右栏、导出 / 导入执行页)。 +internal enum XferTone +{ + /// 普通。 + Normal, + + /// 成功(绿)。 + Ok, + + /// 警告(橙)。 + Warn, + + /// 错误(红)。 + Error, + + /// 弱化(跳过之类)。 + Muted, + + /// 进行中(强调色)。 + Accent +} + +/// 一些共用的小工具。 +internal static class TransferText +{ + /// 剩余 / 已用时间:< 1 s、50 s、3 min 20 s、1 h 05 min。 + public static string Duration(TimeSpan span) + { + if (span < TimeSpan.FromSeconds(1)) + { + return "< 1 s"; + } + if (span < TimeSpan.FromMinutes(1)) + { + return $"{(int)span.TotalSeconds} s"; + } + if (span < TimeSpan.FromHours(1)) + { + return span.Seconds == 0 ? $"{(int)span.TotalMinutes} min" : $"{(int)span.TotalMinutes} min {span.Seconds} s"; + } + return $"{(int)span.TotalHours} h {span.Minutes:00} min"; + } + + /// 速率:18.2k、940。 + public static string Rate(double perSecond) => perSecond switch + { + >= 1_000_000 => (perSecond / 1_000_000).ToString("0.0", System.Globalization.CultureInfo.InvariantCulture) + "M", + >= 1_000 => (perSecond / 1_000).ToString("0.0", System.Globalization.CultureInfo.InvariantCulture) + "k", + _ => perSecond.ToString("0", System.Globalization.CultureInfo.InvariantCulture) + }; + + /// 本机时区的 UTC+8 写法(设计稿「本地时间 UTC+8」)。 + public static string LocalOffset() + { + TimeSpan offset = TimeZoneInfo.Local.GetUtcOffset(DateTime.UtcNow); + string sign = offset < TimeSpan.Zero ? "-" : "+"; + offset = offset.Duration(); + return offset.Minutes == 0 + ? $"UTC{sign}{offset.Hours}" + : $"UTC{sign}{offset.Hours}:{offset.Minutes:00}"; + } + + /// 文件名里不能出现的字符换成下划线(集合名可以带 : / 之类)。 + public static string SafeFileName(string name) + { + char[] invalid = Path.GetInvalidFileNameChars(); + var chars = name.Select(c => invalid.Contains(c) ? '_' : c).ToArray(); + string safe = new string(chars).Trim(); + return safe.Length == 0 ? "_" : safe; + } + + /// 一个值是不是值得在字段表里展开的嵌套文档。 + public static bool IsNestedDocument(BsonValue value) => value is BsonDocument { ElementCount: > 0 }; +} diff --git a/plugins/VelaShell.Plugin.Mongo/Transfer/XlsxWriter.cs b/plugins/VelaShell.Plugin.Mongo/Transfer/XlsxWriter.cs new file mode 100644 index 0000000..a8bb4a6 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Transfer/XlsxWriter.cs @@ -0,0 +1,498 @@ +using System.Globalization; +using System.IO.Compression; +using System.Text; +using System.Xml; + +namespace VelaShell.Plugin.Mongo.Transfer; + +/// 一个 Excel 单元格。 +internal readonly record struct XlsxCell +{ + /// 文本(内联字符串);数值单元格为 。 + public string? Text { get; init; } + + /// 数值。 + public double? Number { get; init; } + + /// 布尔。 + public bool? Flag { get; init; } + + /// 数值用两位小数格式显示。 + public bool Fixed2 { get; init; } + + /// 文本单元格。 + public static XlsxCell OfText(string? text) => new() { Text = text ?? "" }; + + /// 数值单元格。 + public static XlsxCell OfNumber(double number, bool fixed2 = false) => new() { Number = number, Fixed2 = fixed2 }; + + /// 布尔单元格。 + public static XlsxCell OfFlag(bool flag) => new() { Flag = flag }; +} + +/// +/// 最小可用的 xlsx 写出器:用 手写 OpenXML 的五个部件 +/// ([Content_Types].xml、_rels/.rels、xl/workbook.xml + 关系、xl/styles.xml、各工作表)。 +/// +/// 为什么不引 ClosedXML / EPPlus:前者把整张表建在内存里(百万行导出直接吃光内存), +/// 后者有商业许可;而导出要的只是"数字是数字、表头加粗、首行冻结"。 +/// 这里**逐行流式**写工作表、字符串一律内联(inlineStr)—— 不需要共享字符串表, +/// 也就不必把所有字符串攒到最后。 +/// +/// +/// 一张表超过 Excel 的 1,048,576 行上限时,由调用方换一张新表()。 +/// +/// +internal sealed class XlsxWriter : IDisposable +{ + /// Excel 单表最大行数。 + public const int MaxRows = 1_048_576; + + /// 单元格文本上限(Excel 规格)。 + private const int MaxCellText = 32_767; + + private const string MainNs = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; + private const string RelNs = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + + private readonly ZipArchive _zip; + private readonly List _sheets = []; + private readonly bool _freezeHeader; + private Stream? _sheetStream; + private XmlWriter? _sheet; + private int _row; + private bool _completed; + + /// 构造。 + /// 输出流(会被接管并在 时关闭)。 + /// 冻结首行。 + public XlsxWriter(Stream output, bool freezeHeader) + { + _zip = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: false, Encoding.UTF8); + _freezeHeader = freezeHeader; + } + + /// 当前表已写的行数。 + public int RowsInSheet => _row; + + /// 已建的表数。 + public int SheetCount => _sheets.Count; + + /// 开一张新表(上一张自动收尾)。 + /// 表名(自动清洗非法字符并去重)。 + /// 各列宽度(字符数);可空。 + public void BeginSheet(string name, IReadOnlyList? columnWidths = null) + { + EndSheet(); + string sheetName = UniqueSheetName(SanitizeSheetName(name)); + _sheets.Add(sheetName); + ZipArchiveEntry entry = _zip.CreateEntry($"xl/worksheets/sheet{_sheets.Count}.xml", CompressionLevel.Fastest); + _sheetStream = entry.Open(); + _sheet = XmlWriter.Create(_sheetStream, XmlSettings()); + _sheet.WriteStartDocument(standalone: true); + _sheet.WriteStartElement("worksheet", MainNs); + _sheet.WriteAttributeString("xmlns", "r", null, RelNs); + if (_freezeHeader) + { + _sheet.WriteStartElement("sheetViews", MainNs); + _sheet.WriteStartElement("sheetView", MainNs); + _sheet.WriteAttributeString("workbookViewId", "0"); + _sheet.WriteStartElement("pane", MainNs); + _sheet.WriteAttributeString("ySplit", "1"); + _sheet.WriteAttributeString("topLeftCell", "A2"); + _sheet.WriteAttributeString("activePane", "bottomLeft"); + _sheet.WriteAttributeString("state", "frozen"); + _sheet.WriteEndElement(); + _sheet.WriteStartElement("selection", MainNs); + _sheet.WriteAttributeString("pane", "bottomLeft"); + _sheet.WriteEndElement(); + _sheet.WriteEndElement(); + _sheet.WriteEndElement(); + } + _sheet.WriteStartElement("sheetFormatPr", MainNs); + _sheet.WriteAttributeString("defaultRowHeight", "15"); + _sheet.WriteEndElement(); + if (columnWidths is { Count: > 0 }) + { + _sheet.WriteStartElement("cols", MainNs); + for (int i = 0; i < columnWidths.Count; i++) + { + string index = (i + 1).ToString(CultureInfo.InvariantCulture); + _sheet.WriteStartElement("col", MainNs); + _sheet.WriteAttributeString("min", index); + _sheet.WriteAttributeString("max", index); + _sheet.WriteAttributeString("width", Math.Clamp(columnWidths[i], 6, 80).ToString("0.##", CultureInfo.InvariantCulture)); + _sheet.WriteAttributeString("customWidth", "1"); + _sheet.WriteEndElement(); + } + _sheet.WriteEndElement(); + } + _sheet.WriteStartElement("sheetData", MainNs); + _row = 0; + } + + /// 写一行。 + /// 单元格。 + /// 表头行(加粗)。 + public void WriteRow(IReadOnlyList cells, bool header = false) + { + if (_sheet is null) + { + throw new InvalidOperationException("BeginSheet first."); + } + if (_row >= MaxRows) + { + throw new InvalidOperationException("The sheet is full."); + } + _row++; + string rowRef = _row.ToString(CultureInfo.InvariantCulture); + _sheet.WriteStartElement("row", MainNs); + _sheet.WriteAttributeString("r", rowRef); + for (int i = 0; i < cells.Count; i++) + { + XlsxCell cell = cells[i]; + _sheet.WriteStartElement("c", MainNs); + _sheet.WriteAttributeString("r", ColumnName(i) + rowRef); + if (cell.Number is { } number) + { + if (cell.Fixed2) + { + _sheet.WriteAttributeString("s", "2"); + } + _sheet.WriteElementString("v", MainNs, number.ToString("R", CultureInfo.InvariantCulture)); + } + else if (cell.Flag is { } flag) + { + _sheet.WriteAttributeString("t", "b"); + _sheet.WriteElementString("v", MainNs, flag ? "1" : "0"); + } + else + { + _sheet.WriteAttributeString("t", "inlineStr"); + if (header) + { + _sheet.WriteAttributeString("s", "1"); + } + string text = Clean(cell.Text ?? ""); + _sheet.WriteStartElement("is", MainNs); + _sheet.WriteStartElement("t", MainNs); + if (text.Length > 0 && (char.IsWhiteSpace(text[0]) || char.IsWhiteSpace(text[^1]) || text.Contains('\n'))) + { + _sheet.WriteAttributeString("xml", "space", null, "preserve"); + } + _sheet.WriteString(text); + _sheet.WriteEndElement(); + _sheet.WriteEndElement(); + } + _sheet.WriteEndElement(); + } + _sheet.WriteEndElement(); + } + + /// 收尾当前表。 + public void EndSheet() + { + if (_sheet is null) + { + return; + } + _sheet.WriteEndElement(); // sheetData + _sheet.WriteEndElement(); // worksheet + _sheet.WriteEndDocument(); + _sheet.Dispose(); + _sheetStream!.Dispose(); + _sheet = null; + _sheetStream = null; + } + + /// 写出工作簿部件并关闭压缩包。 + public void Complete() + { + if (_completed) + { + return; + } + if (_sheets.Count == 0) + { + BeginSheet("Sheet1"); + } + EndSheet(); + WritePart("[Content_Types].xml", w => + { + const string ns = "http://schemas.openxmlformats.org/package/2006/content-types"; + w.WriteStartElement("Types", ns); + Default(w, ns, "rels", "application/vnd.openxmlformats-package.relationships+xml"); + Default(w, ns, "xml", "application/xml"); + Override(w, ns, "/xl/workbook.xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"); + for (int i = 1; i <= _sheets.Count; i++) + { + Override(w, ns, $"/xl/worksheets/sheet{i}.xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"); + } + Override(w, ns, "/xl/styles.xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"); + w.WriteEndElement(); + }); + WritePart("_rels/.rels", w => + { + const string ns = "http://schemas.openxmlformats.org/package/2006/relationships"; + w.WriteStartElement("Relationships", ns); + Relationship(w, ns, "rId1", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument", "xl/workbook.xml"); + w.WriteEndElement(); + }); + WritePart("xl/workbook.xml", w => + { + w.WriteStartElement("workbook", MainNs); + w.WriteAttributeString("xmlns", "r", null, RelNs); + w.WriteStartElement("sheets", MainNs); + for (int i = 0; i < _sheets.Count; i++) + { + w.WriteStartElement("sheet", MainNs); + w.WriteAttributeString("name", _sheets[i]); + w.WriteAttributeString("sheetId", (i + 1).ToString(CultureInfo.InvariantCulture)); + w.WriteAttributeString("id", RelNs, $"rId{i + 1}"); + w.WriteEndElement(); + } + w.WriteEndElement(); + w.WriteEndElement(); + }); + WritePart("xl/_rels/workbook.xml.rels", w => + { + const string ns = "http://schemas.openxmlformats.org/package/2006/relationships"; + w.WriteStartElement("Relationships", ns); + for (int i = 1; i <= _sheets.Count; i++) + { + Relationship(w, ns, $"rId{i}", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet", $"worksheets/sheet{i}.xml"); + } + Relationship(w, ns, $"rId{_sheets.Count + 1}", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles", "styles.xml"); + w.WriteEndElement(); + }); + WritePart("xl/styles.xml", WriteStyles); + _completed = true; + } + + /// + public void Dispose() + { + _sheet?.Dispose(); + _sheetStream?.Dispose(); + _zip.Dispose(); + } + + /// 列号 → 列名(0 → A,26 → AA)。 + public static string ColumnName(int index) + { + var name = new StringBuilder(); + int n = index + 1; + while (n > 0) + { + int rem = (n - 1) % 26; + name.Insert(0, (char)('A' + rem)); + n = (n - 1) / 26; + } + return name.ToString(); + } + + /// 工作表名:去掉 []:*?/\,最长 31 个字符,不能为空。 + public static string SanitizeSheetName(string name) + { + var chars = name.Where(static c => c is not ('[' or ']' or ':' or '*' or '?' or '/' or '\\') && !char.IsControl(c)).ToArray(); + string clean = new string(chars).Trim('\'').Trim(); + if (clean.Length == 0) + { + clean = "Sheet1"; + } + return clean.Length > 31 ? clean[..31] : clean; + } + + private string UniqueSheetName(string name) + { + if (!_sheets.Contains(name, StringComparer.OrdinalIgnoreCase)) + { + return name; + } + for (int i = 2; ; i++) + { + string suffix = $" ({i})"; + string candidate = (name.Length + suffix.Length > 31 ? name[..(31 - suffix.Length)] : name) + suffix; + if (!_sheets.Contains(candidate, StringComparer.OrdinalIgnoreCase)) + { + return candidate; + } + } + } + + /// XML 1.0 不允许的控制字符去掉(BSON 字符串里什么都可能有),超长截断。 + private static string Clean(string text) + { + if (text.Length > MaxCellText) + { + text = text[..MaxCellText]; + } + bool dirty = false; + foreach (char c in text) + { + if (!IsXmlChar(c)) + { + dirty = true; + break; + } + } + if (!dirty) + { + return text; + } + var builder = new StringBuilder(text.Length); + for (int i = 0; i < text.Length; i++) + { + char c = text[i]; + if (char.IsHighSurrogate(c) && i + 1 < text.Length && char.IsLowSurrogate(text[i + 1])) + { + builder.Append(c).Append(text[i + 1]); + i++; + } + else if (IsXmlChar(c) && !char.IsSurrogate(c)) + { + builder.Append(c); + } + } + return builder.ToString(); + } + + private static bool IsXmlChar(char c) => + c == '\t' || c == '\n' || c == '\r' || (c >= 0x20 && c <= 0xFFFD && c is not '￾' and not '￿'); + + private static XmlWriterSettings XmlSettings() => new() + { + Encoding = new UTF8Encoding(false), + Indent = false, + CloseOutput = false + }; + + private void WritePart(string path, Action body) + { + ZipArchiveEntry entry = _zip.CreateEntry(path, CompressionLevel.Fastest); + using Stream stream = entry.Open(); + using var writer = XmlWriter.Create(stream, XmlSettings()); + writer.WriteStartDocument(standalone: true); + body(writer); + writer.WriteEndDocument(); + } + + private static void Default(XmlWriter w, string ns, string extension, string type) + { + w.WriteStartElement("Default", ns); + w.WriteAttributeString("Extension", extension); + w.WriteAttributeString("ContentType", type); + w.WriteEndElement(); + } + + private static void Override(XmlWriter w, string ns, string part, string type) + { + w.WriteStartElement("Override", ns); + w.WriteAttributeString("PartName", part); + w.WriteAttributeString("ContentType", type); + w.WriteEndElement(); + } + + private static void Relationship(XmlWriter w, string ns, string id, string type, string target) + { + w.WriteStartElement("Relationship", ns); + w.WriteAttributeString("Id", id); + w.WriteAttributeString("Type", type); + w.WriteAttributeString("Target", target); + w.WriteEndElement(); + } + + /// 样式:0 = 默认,1 = 加粗(表头),2 = 两位小数(内置格式 2 = 0.00)。 + private static void WriteStyles(XmlWriter w) + { + w.WriteStartElement("styleSheet", MainNs); + + w.WriteStartElement("fonts", MainNs); + w.WriteAttributeString("count", "2"); + Font(w, bold: false); + Font(w, bold: true); + w.WriteEndElement(); + + w.WriteStartElement("fills", MainNs); + w.WriteAttributeString("count", "2"); + foreach (string pattern in new[] { "none", "gray125" }) + { + w.WriteStartElement("fill", MainNs); + w.WriteStartElement("patternFill", MainNs); + w.WriteAttributeString("patternType", pattern); + w.WriteEndElement(); + w.WriteEndElement(); + } + w.WriteEndElement(); + + w.WriteStartElement("borders", MainNs); + w.WriteAttributeString("count", "1"); + w.WriteStartElement("border", MainNs); + foreach (string side in new[] { "left", "right", "top", "bottom", "diagonal" }) + { + w.WriteElementString(side, MainNs, ""); + } + w.WriteEndElement(); + w.WriteEndElement(); + + w.WriteStartElement("cellStyleXfs", MainNs); + w.WriteAttributeString("count", "1"); + Xf(w, numFmt: 0, font: 0, withXfId: false); + w.WriteEndElement(); + + w.WriteStartElement("cellXfs", MainNs); + w.WriteAttributeString("count", "3"); + Xf(w, numFmt: 0, font: 0, withXfId: true); + Xf(w, numFmt: 0, font: 1, withXfId: true); + Xf(w, numFmt: 2, font: 0, withXfId: true); + w.WriteEndElement(); + + w.WriteStartElement("cellStyles", MainNs); + w.WriteAttributeString("count", "1"); + w.WriteStartElement("cellStyle", MainNs); + w.WriteAttributeString("name", "Normal"); + w.WriteAttributeString("xfId", "0"); + w.WriteAttributeString("builtinId", "0"); + w.WriteEndElement(); + w.WriteEndElement(); + + w.WriteEndElement(); + } + + private static void Font(XmlWriter w, bool bold) + { + w.WriteStartElement("font", MainNs); + if (bold) + { + w.WriteElementString("b", MainNs, ""); + } + w.WriteStartElement("sz", MainNs); + w.WriteAttributeString("val", "11"); + w.WriteEndElement(); + w.WriteStartElement("name", MainNs); + w.WriteAttributeString("val", "Calibri"); + w.WriteEndElement(); + w.WriteEndElement(); + } + + private static void Xf(XmlWriter w, int numFmt, int font, bool withXfId) + { + w.WriteStartElement("xf", MainNs); + w.WriteAttributeString("numFmtId", numFmt.ToString(CultureInfo.InvariantCulture)); + w.WriteAttributeString("fontId", font.ToString(CultureInfo.InvariantCulture)); + w.WriteAttributeString("fillId", "0"); + w.WriteAttributeString("borderId", "0"); + if (withXfId) + { + w.WriteAttributeString("xfId", "0"); + if (font != 0) + { + w.WriteAttributeString("applyFont", "1"); + } + if (numFmt != 0) + { + w.WriteAttributeString("applyNumberFormat", "1"); + } + } + w.WriteEndElement(); + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Charts/BarChart.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Charts/BarChart.cs new file mode 100644 index 0000000..6373c83 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Charts/BarChart.cs @@ -0,0 +1,328 @@ +using System.Globalization; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 图表里的一组数据。 +/// 名字(图例、悬停提示里的那一行)。 +/// 颜色令牌(MongoChart1、VelaShellGreen…)。 +/// 每根柱子的值。 +public sealed record ChartSeries(string Name, string BrushToken, IReadOnlyList Values); + +/// +/// 柱状图:多组数据堆叠成一根柱(设计稿 11 的操作 / 秒、连接数;08 的 Schema 直方图)。 +/// +/// 自绘而不是引一个图表库:用到的只有"等宽柱 + 堆叠 + 几条网格线 + 悬停提示"这一种形态, +/// 而任何图表库都会随插件目录分发一大串程序集,配色也得另外接一遍宿主令牌。 +/// +/// +public sealed class BarChart : Control +{ + /// 数据。 + public static readonly StyledProperty?> SeriesProperty = + AvaloniaProperty.Register?>(nameof(Series)); + + /// 横轴标签(与柱一一对应;只画首、中、尾三个,其余进悬停提示)。 + public static readonly StyledProperty?> LabelsProperty = + AvaloniaProperty.Register?>(nameof(Labels)); + + /// 纵轴上限;0 = 按数据自动取整。 + public static readonly StyledProperty MaxValueProperty = + AvaloniaProperty.Register(nameof(MaxValue)); + + /// 是否画坐标轴(网格线、刻度、横轴标签)。直方图那种小图关掉。 + public static readonly StyledProperty ShowAxesProperty = + AvaloniaProperty.Register(nameof(ShowAxes), true); + + /// 柱间距占柱宽的比例。 + public static readonly StyledProperty GapRatioProperty = + AvaloniaProperty.Register(nameof(GapRatio), 0.25); + + /// 悬停提示(堆叠图的那张小卡片)。 + public static readonly StyledProperty ShowTooltipProperty = + AvaloniaProperty.Register(nameof(ShowTooltip), true); + + /// 横轴画几个标签(均匀取,含首尾;设计稿 11 是 5 个)。 + public static readonly StyledProperty XLabelCountProperty = + AvaloniaProperty.Register(nameof(XLabelCount), 5); + + private int _hover = -1; + + static BarChart() + { + AffectsRender(SeriesProperty, LabelsProperty, MaxValueProperty, ShowAxesProperty, GapRatioProperty); + } + + /// 数据。 + public IReadOnlyList? Series + { + get => GetValue(SeriesProperty); + set => SetValue(SeriesProperty, value); + } + + /// 横轴标签。 + public IReadOnlyList? Labels + { + get => GetValue(LabelsProperty); + set => SetValue(LabelsProperty, value); + } + + /// 纵轴上限。 + public double MaxValue + { + get => GetValue(MaxValueProperty); + set => SetValue(MaxValueProperty, value); + } + + /// 是否画坐标轴。 + public bool ShowAxes + { + get => GetValue(ShowAxesProperty); + set => SetValue(ShowAxesProperty, value); + } + + /// 柱间距比例。 + public double GapRatio + { + get => GetValue(GapRatioProperty); + set => SetValue(GapRatioProperty, value); + } + + /// 横轴标签个数。 + public int XLabelCount + { + get => GetValue(XLabelCountProperty); + set => SetValue(XLabelCountProperty, value); + } + + /// 悬停提示。 + public bool ShowTooltip + { + get => GetValue(ShowTooltipProperty); + set => SetValue(ShowTooltipProperty, value); + } + + private const double AxisLeft = 30; + private const double AxisBottom = 16; + + private Rect Plot => ShowAxes + ? new Rect(AxisLeft, 6, Math.Max(0, Bounds.Width - AxisLeft - 4), Math.Max(0, Bounds.Height - AxisBottom - 6)) + : new Rect(0, 0, Bounds.Width, Bounds.Height); + + private int Count => Series is { Count: > 0 } s ? s.Max(static x => x.Values.Count) : 0; + + /// + protected override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(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)); + index = index < 0 || index >= count ? -1 : index; + if (index != _hover) + { + _hover = index; + InvalidateVisual(); + } + } + + /// + protected override void OnPointerExited(PointerEventArgs e) + { + base.OnPointerExited(e); + _hover = -1; + InvalidateVisual(); + } + + /// + public override void Render(DrawingContext context) + { + // 透明底:让整块区域都接得住指针(悬停提示要)。 + context.FillRectangle(Brushes.Transparent, new Rect(Bounds.Size)); + IReadOnlyList series = Series ?? []; + int count = Count; + if (count == 0) + { + return; + } + double[] totals = new double[count]; + for (int i = 0; i < count; i++) + { + totals[i] = series.Sum(s => i < s.Values.Count ? Math.Max(0, s.Values[i]) : 0); + } + (double max, double step) = MaxValue > 0 ? (MaxValue, NiceStep(MaxValue)) : NiceScale(totals.Max()); + Rect plot = Plot; + IBrush muted = ThemeBrushes.Get("VelaTextMuted", Brushes.Gray); + var grid = new Pen(ThemeBrushes.Get("VelaBorderPrimary", Brushes.DimGray), 1); + var typeface = new Typeface(this.TryFindResource("VelaUiMonoFont", out object? f) && f is FontFamily ff ? ff : FontFamily.Default); + + if (ShowAxes) + { + int steps = Math.Clamp((int)Math.Round(max / step), 1, 8); + for (int g = 0; g <= steps; g++) + { + double value = Math.Min(max, step * g); + double y = plot.Bottom - (plot.Height * value / max); + context.DrawLine(grid, new Point(plot.Left, y), new Point(plot.Right, y)); + var label = new FormattedText(Short(value), CultureInfo.InvariantCulture, FlowDirection.LeftToRight, typeface, 9, muted); + context.DrawText(label, new Point(plot.Left - label.Width - 6, y - (label.Height / 2))); + } + } + + double slot = plot.Width / count; + 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 bottom = plot.Bottom; + int top = -1; + for (int k = series.Count - 1; k >= 0; k--) + { + if (i < series[k].Values.Count && series[k].Values[i] > 0) + { + top = k; + break; + } + } + for (int k = 0; k < series.Count; k++) + { + ChartSeries s = series[k]; + double v = i < s.Values.Count ? Math.Max(0, s.Values[i]) : 0; + double h = max <= 0 ? 0 : plot.Height * Math.Min(v, max) / max; + if (h <= 0) + { + continue; + } + IBrush fill = ThemeBrushes.Get(s.BrushToken, Brushes.SteelBlue); + // 只有最上面那一段圆顶(1px):整根柱子是一个形状,中间的接缝不该有圆角。 + if (k == top && h > 2) + { + context.FillRectangle(fill, new Rect(x, bottom - h, barWidth, h), 1); + context.FillRectangle(fill, new Rect(x, bottom - Math.Min(h, 2), barWidth, Math.Min(h, 2))); + } + else + { + context.FillRectangle(fill, new Rect(x, bottom - h, barWidth, h)); + } + bottom -= h; + } + if (i == _hover) + { + context.FillRectangle(ThemeBrushes.GetDim("VelaTextPrimary", 0.08), new Rect(plot.Left + (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 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)); + } + } + + if (ShowTooltip && _hover >= 0) + { + DrawTooltip(context, typeface, series, plot, slot); + } + } + + private void DrawTooltip(DrawingContext context, Typeface typeface, IReadOnlyList series, Rect plot, double slot) + { + IBrush primary = ThemeBrushes.Get("VelaTextPrimary", Brushes.White); + IBrush secondary = ThemeBrushes.Get("VelaTextSecondary", Brushes.LightGray); + var lines = new List<(string Name, string Value, IBrush Brush)>(); + foreach (ChartSeries s in series) + { + double v = _hover < s.Values.Count ? s.Values[_hover] : 0; + lines.Add((s.Name, v.ToString("#,0.##", CultureInfo.InvariantCulture), ThemeBrushes.Get(s.BrushToken, Brushes.SteelBlue))); + } + string title = Labels is { } labels && _hover < labels.Count ? labels[_hover] : ""; + 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; + if (x + width > Bounds.Width) + { + x = plot.Left + (slot * _hover) - width - 6; + } + double y = plot.Top + 4; + var box = new Rect(Math.Max(0, x), y, width, height); + context.DrawRectangle(ThemeBrushes.Get("VelaBgSurface", Brushes.Black), + new Pen(ThemeBrushes.Get("VelaBorderSecondary", Brushes.Gray), 1), box, 4, 4); + double ty = box.Y + 5; + if (title.Length > 0) + { + context.DrawText(new FormattedText(title, CultureInfo.InvariantCulture, FlowDirection.LeftToRight, typeface, 10, primary), new Point(box.X + 8, ty)); + ty += row; + } + foreach ((string name, string value, IBrush brush) in lines) + { + context.FillRectangle(brush, new Rect(box.X + 8, ty + 4, 7, 7), 1); + context.DrawText(new FormattedText(name, CultureInfo.InvariantCulture, FlowDirection.LeftToRight, typeface, 10, secondary), new Point(box.X + 20, ty)); + var v = new FormattedText(value, CultureInfo.InvariantCulture, FlowDirection.LeftToRight, typeface, 10, primary); + context.DrawText(v, new Point(box.Right - v.Width - 8, ty)); + ty += row; + } + } + + /// 纵轴刻度:取 1 / 2 / 2.5 / 5 × 10ⁿ 的步长,使刻度数在 3–5 之间,上限抬到步长的整数倍。 + internal static (double Max, double Step) NiceScale(double value) + { + if (value <= 0) + { + return (1, 1d / 3); + } + double step = NiceStep(value); + return (Math.Ceiling(value / step) * step, step); + } + + /// 给定上限,挑一个让刻度落在整数上的步长。 + internal static double NiceStep(double max) + { + double raw = max / 3; + double magnitude = Math.Pow(10, Math.Floor(Math.Log10(raw))); + foreach (double m in new[] { 1, 2, 2.5, 5, 10 }) + { + if (m * magnitude >= raw) + { + return m * magnitude; + } + } + return 10 * magnitude; + } + + /// 把最大值抬到一个好读的整数(1.5k、200、20)。 + internal static double NiceCeiling(double value) + { + if (value <= 0) + { + return 1; + } + double magnitude = Math.Pow(10, Math.Floor(Math.Log10(value))); + foreach (double step in new[] { 1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10 }) + { + if (step * magnitude >= value) + { + return step * magnitude; + } + } + return 10 * magnitude; + } + + /// 刻度文字:1.5k、500。 + internal static string Short(double value) => value switch + { + >= 1_000_000 => (value / 1_000_000).ToString("0.#", CultureInfo.InvariantCulture) + "M", + >= 1_000 => (value / 1_000).ToString("0.#", CultureInfo.InvariantCulture) + "k", + _ => value.ToString("0.#", CultureInfo.InvariantCulture) + }; +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Collection/AddStagedFieldDialogView.axaml b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/AddStagedFieldDialogView.axaml new file mode 100644 index 0000000..0d7669d --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/AddStagedFieldDialogView.axaml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Collection/AddStagedFieldDialogView.axaml.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/AddStagedFieldDialogView.axaml.cs new file mode 100644 index 0000000..c98f42d --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/AddStagedFieldDialogView.axaml.cs @@ -0,0 +1,15 @@ +using Avalonia.Controls; +using Avalonia.Threading; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 「添加字段」对话框的视图(打开即聚焦名字框)。 +public sealed partial class AddStagedFieldDialogView : UserControl +{ + /// 构造。 + public AddStagedFieldDialogView() + { + InitializeComponent(); + AttachedToVisualTree += (_, _) => Dispatcher.UIThread.Post(() => NameBox.Focus(), DispatcherPriority.Input); + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Collection/AddStagedFieldDialogViewModel.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/AddStagedFieldDialogViewModel.cs new file mode 100644 index 0000000..c43f459 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/AddStagedFieldDialogViewModel.cs @@ -0,0 +1,154 @@ +using Avalonia.Controls; +using MongoDB.Bson; +using VelaShell.Plugin.Mongo.Bson; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 类型下拉里的一项(色块 + 名字)。 +/// 类型。 +internal sealed record ValueKindChoice(BsonKind Kind) +{ + /// 显示名(Binary · UUID)。 + public string Name => BsonKinds.MenuName(Kind); + + /// 色块令牌。 + public string Token => BsonKinds.ColorToken(Kind); + + /// 可选的全部类型。 + public static IReadOnlyList All { get; } = [.. BsonKinds.Editable.Select(static k => new ValueKindChoice(k))]; +} + +/// +/// 「添加字段」小对话框(检查器的「添加字段 Ctrl+Enter」、树的「添加字段… Ctrl+D」):名字 + 类型 + 值。 +/// 结果交给调用方写进暂存区 —— 与别的修改一样等 Ctrl+S 才提交。 +/// +internal sealed class AddStagedFieldDialogViewModel : DialogViewModel, IViewFactory +{ + private readonly BsonDocument _document; + private readonly string _parent; + private readonly Func _stage; + private string _name = ""; + private ValueKindChoice _kind = ValueKindChoice.All[0]; + private string _valueText = ""; + private string? _error; + + /// 构造。 + /// 外壳服务。 + /// 要加字段的文档(查重名)。 + /// 加在哪个对象下;顶层为空串。 + /// 写入暂存区;返回是否写成。 + public AddStagedFieldDialogViewModel(IMongoWorkspace workspace, BsonDocument document, string parentPath, Func stage) + : base(workspace) + { + _document = document; + _parent = parentPath; + _stage = stage; + Title = Loc["Cw_AddFieldTitle"]; + Subtitle = parentPath.Length == 0 ? Loc["Cw_AddFieldRoot"] : parentPath; + ConfirmCommand = new RelayCommand(Confirm); + } + + /// + public override string IconKey => "Mongo.plus"; + + /// + public override double Width => 420; + + /// 字段名。 + public string Name + { + get => _name; + set + { + if (SetProperty(ref _name, value ?? "")) + { + Error = null; + } + } + } + + /// 可选类型。 + public IReadOnlyList Kinds => ValueKindChoice.All; + + /// 选中的类型。 + public ValueKindChoice Kind + { + get => _kind; + set + { + if (SetProperty(ref _kind, value ?? ValueKindChoice.All[0])) + { + Error = null; + RaisePropertyChanged(nameof(ValueHint)); + } + } + } + + /// 值(按类型解析;空 = 该类型的空值)。 + public string ValueText + { + get => _valueText; + set + { + if (SetProperty(ref _valueText, value ?? "")) + { + Error = null; + } + } + } + + /// 值框的占位提示。 + public string ValueHint => Loc.Format("Cw_AddFieldValueHint", BsonText.Literal(BsonEdit.Empty(_kind.Kind))); + + /// 错误。 + public string? Error + { + get => _error; + private set + { + if (SetProperty(ref _error, value)) + { + RaisePropertyChanged(nameof(HasError)); + } + } + } + + /// 有错。 + public bool HasError => _error is not null; + + /// 添加。 + public RelayCommand ConfirmCommand { get; } + + /// + public Control CreateView() => new AddStagedFieldDialogView { DataContext = this }; + + private void Confirm() + { + string name = _name.Trim(); + if (name.Length == 0 || name.StartsWith('$') || name.Contains('.')) + { + Error = Loc["Cw_AddFieldBadName"]; + return; + } + string path = _parent.Length == 0 ? name : $"{_parent}.{name}"; + if (BsonPath.Get(_document, path) is not null) + { + Error = Loc["Cw_AddFieldExists"]; + return; + } + BsonValue value; + if (_valueText.Length == 0) + { + value = BsonEdit.Empty(_kind.Kind); + } + else if (!BsonEdit.TryParse(_valueText, _kind.Kind, out value, out string? error)) + { + Error = Loc[error ?? "Edit_BadLiteral"]; + return; + } + if (_stage(path, value)) + { + Close(); + } + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Collection/CollectionMenus.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/CollectionMenus.cs new file mode 100644 index 0000000..c1906fc --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/CollectionMenus.cs @@ -0,0 +1,159 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Shapes; +using Avalonia.Layout; +using Avalonia.Media; +using MongoDB.Bson; +using VelaShell.Plugin.Mongo.Bson; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 网格 / 树 / 检查器右键菜单的零件(设计稿 13:图标 + 文字 + 右侧快捷键小字,底部「筛选将生成 …」)。 +/// 菜单在 code-behind 里现建:菜单项要直达视图模型的方法,写在 AXAML 里就得从弹出层的独立可视树绕回数据上下文。 +/// +internal static class CollectionMenus +{ + /// 一项菜单。 + /// 文字。 + /// 图标键。 + /// 右侧小字(快捷键、路径);没有为 。 + /// 点了做什么。 + /// 危险操作(红字红图标)。 + /// 首项强调(设计稿「编辑值」的强调色图标)。 + /// 可用。 + public static MenuItem Item(string label, string icon, string? hint, Action action, bool danger = false, bool accent = false, bool enabled = true) + { + string token = danger ? "VelaError" : accent ? "VelaAccent" : "VelaTextTertiary"; + var item = new MenuItem + { + Header = Header(label, hint, danger), + Icon = new Glyph { Key = icon, Size = 12, Brush = ThemeBrushes.Get(token, Brushes.Gray) }, + IsEnabled = enabled + }; + if (danger) + { + item.Classes.Add("danger"); + } + item.Click += (_, _) => action(); + return item; + } + + /// 带子菜单的一项(「修改类型 ›」)。 + public static MenuItem Submenu(string label, string icon, IEnumerable children, bool enabled = true) => new() + { + Header = Header(label, null, false), + Icon = new Glyph { Key = icon, Size = 12, Brush = ThemeBrushes.Get("VelaTextTertiary", Brushes.Gray) }, + ItemsSource = children.ToList(), + IsEnabled = enabled + }; + + /// + /// 类型菜单(设计稿 01 检查器右侧那个):色块 + 类型名,当前类型打勾。 + /// + public static IEnumerable KindItems(BsonKind current, Action pick) + { + foreach (BsonKind kind in BsonKinds.Editable) + { + var row = new DockPanel { MinWidth = 120 }; + if (kind == current) + { + var check = new Glyph { Key = "Mongo.check", Size = 11, Brush = ThemeBrushes.Get("VelaAccent", Brushes.Gray) }; + DockPanel.SetDock(check, Dock.Right); + row.Children.Add(check); + } + row.Children.Add(new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 8, + Children = + { + new Rectangle + { + Width = 8, + Height = 8, + RadiusX = 2, + RadiusY = 2, + Fill = ThemeBrushes.Get(BsonKinds.ColorToken(kind), Brushes.Gray), + VerticalAlignment = VerticalAlignment.Center + }, + new TextBlock + { + Text = BsonKinds.MenuName(kind), + Classes = { "mono" }, + Foreground = ThemeBrushes.Get(kind == current ? "VelaTextPrimary" : "VelaTextSecondary", Brushes.Gray) + } + } + }); + var item = new MenuItem { Header = row }; + BsonKind captured = kind; + item.Click += (_, _) => pick(captured); + yield return item; + } + } + + /// 分隔线。 + public static Separator Separator() => new(); + + /// + /// 菜单底部的「筛选将生成 { "items.sku": "SKU-7710" }」:点它等于「按此值筛选」。 + /// + public static MenuItem FilterHint(Loc loc, string path, BsonValue? value, Action apply) + { + var item = new MenuItem + { + Header = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 4, + Children = + { + new TextBlock { Text = loc["Cw_MenuFilterPreview"], Classes = { "ui", "small", "muted" } }, + new TextBlock + { + Text = CollectionTabViewModel.FilterFor(path, value), + Classes = { "mono", "small", "trim" }, + MaxWidth = 300, + Foreground = ThemeBrushes.Get("VelaShellCyan", Brushes.Gray) + } + } + } + }; + item.Classes.Add("hint"); + item.Click += (_, _) => apply(); + return item; + } + + /// 装成一个菜单并在 上打开。 + public static void Open(Control target, IEnumerable items) + { + var menu = new ContextMenu { ItemsSource = items.ToList() }; + menu.Classes.Add("cw"); + menu.Open(target); + } + + private static Control Header(string label, string? hint, bool danger) + { + var grid = new Grid { ColumnDefinitions = new ColumnDefinitions("*,Auto"), MinWidth = 190 }; + var text = new TextBlock { Text = label, VerticalAlignment = VerticalAlignment.Center }; + if (danger) + { + text.Foreground = ThemeBrushes.Get("VelaError", Brushes.Red); + } + grid.Children.Add(text); + if (!string.IsNullOrEmpty(hint)) + { + var right = new TextBlock + { + Text = hint, + Classes = { "mono", "small", "muted" }, + Margin = new Thickness(16, 0, 0, 0), + MaxWidth = 140, + TextTrimming = TextTrimming.CharacterEllipsis + }; + Grid.SetColumn(right, 1); + grid.Children.Add(right); + } + return grid; + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Collection/CollectionSample.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/CollectionSample.cs new file mode 100644 index 0000000..d995852 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/CollectionSample.cs @@ -0,0 +1,255 @@ +using System.Globalization; +using MongoDB.Bson; +using MongoDB.Driver; +using VelaShell.Plugin.Mongo.Bson; +using VelaShell.Plugin.Mongo.Core; +using VelaShell.Plugin.Mongo.Shell; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 抽样里看到的一个字段。 +/// 路径(数组不展开下标:items.sku)。 +/// 主导类型。 +/// 出现在多少份抽样文档里。 +internal sealed record CollectionSampleField(string Path, BsonKind Kind, int Count); + +/// +/// 补全用的抽样:$sample 一批文档,在内存里算字段表与"某字段的取值分布"。 +/// +/// 值分布在客户端从同一批样本里数,而不是每问一个字段就跑一次 $group: +/// 补全是边打字边问的,一次往返的延迟就足以让弹层慢半拍;样本已经在手里,数一遍是微秒级。 +/// +/// +internal sealed class CollectionSample +{ + private readonly IReadOnlyList _documents; + private readonly Dictionary> _valueCache = new(StringComparer.Ordinal); + + private CollectionSample(IReadOnlyList documents) + { + _documents = documents; + var counts = new Dictionary>(StringComparer.Ordinal); + var presence = new Dictionary(StringComparer.Ordinal); + var order = new List(); + foreach (BsonDocument doc in documents) + { + var seen = new HashSet(StringComparer.Ordinal); + foreach ((string path, BsonValue value) in BsonPath.Walk(doc, maxDepth: 4)) + { + if (!counts.TryGetValue(path, out Dictionary? kinds)) + { + kinds = []; + counts[path] = kinds; + order.Add(path); + } + BsonKind kind = BsonKinds.Of(value); + kinds[kind] = kinds.GetValueOrDefault(kind) + 1; + if (seen.Add(path)) + { + presence[path] = presence.GetValueOrDefault(path) + 1; + } + } + } + Fields = [.. order.Select(p => new CollectionSampleField(p, counts[p].MaxBy(static k => k.Value).Key, presence.GetValueOrDefault(p)))]; + } + + /// 空样本(还没抽到 / 抽样失败)。 + public static CollectionSample Empty { get; } = new([]); + + /// 样本大小。 + public int Size => _documents.Count; + + /// 字段表(按首次出现的顺序)。 + public IReadOnlyList Fields { get; } + + /// 抽一批。视图与时序集合同样支持 $sample。 + public static async Task LoadAsync(MongoConnection connection, string database, string collection, int size, CancellationToken cancellationToken) + { + var pipeline = new[] { new BsonDocument("$sample", new BsonDocument("size", Math.Clamp(size, 50, 5000))) }; + using IAsyncCursor cursor = await connection.Collection(database, collection) + .AggregateAsync(pipeline, new AggregateOptions { MaxTime = TimeSpan.FromSeconds(10) }, cancellationToken) + .ConfigureAwait(false); + List documents = await cursor.ToListAsync(cancellationToken).ConfigureAwait(false); + return new(documents); + } + + /// 某个字段的主导类型;样本里没有为 。 + public BsonKind KindOf(string path) => Fields.FirstOrDefault(f => f.Path == NormalizePath(path))?.Kind ?? BsonKind.Missing; + + /// 某个对象下的直接子字段名(JSON 编辑器里键位置的补全)。 + public IEnumerable ChildrenOf(string parent) + { + string normalized = NormalizePath(parent); + string prefix = normalized.Length == 0 ? "" : normalized + "."; + return Fields.Where(f => f.Path.StartsWith(prefix, StringComparison.Ordinal) && f.Path.IndexOf('.', prefix.Length) < 0); + } + + /// + /// 某个字段的取值分布(前 8 个):字面量、插入文本、类型、占比(出现该值的文档 / 样本大小)。 + /// 数组里的值摊开来数(items.sku 数的是每一项的 sku)。 + /// + public IReadOnlyList<(string Label, string Insert, BsonKind Kind, double Ratio)> ValuesOf(string path) + { + string normalized = NormalizePath(path); + if (_valueCache.TryGetValue(normalized, out List<(string, string, BsonKind, double)>? cached)) + { + return cached; + } + var counts = new Dictionary(StringComparer.Ordinal); + string[] segments = normalized.Split('.'); + foreach (BsonDocument doc in _documents) + { + var seen = new HashSet(StringComparer.Ordinal); + foreach (BsonValue value in Collect(doc, segments, 0)) + { + if (value is BsonDocument or BsonArray) + { + continue; + } + string literal = BsonText.Literal(value); + if (literal.Length > 60 || !seen.Add(literal)) + { + continue; + } + counts[literal] = counts.TryGetValue(literal, out (BsonValue Value, int Count) entry) ? (entry.Value, entry.Count + 1) : (value, 1); + } + } + List<(string, string, BsonKind, double)> list = + [ + .. counts.OrderByDescending(static p => p.Value.Count).Take(8) + .Select(p => (p.Key, p.Key, BsonKinds.Of(p.Value.Value), _documents.Count == 0 ? 0 : (double)p.Value.Count / _documents.Count)) + ]; + // 每个值都只出现一次(单号、ObjectId)的字段没有"分布"可言,给出来只会是一列噪音。 + if (list.Count > 1 && counts.Values.Max(static v => v.Count) <= 1) + { + list.Clear(); + } + _valueCache[normalized] = list; + return list; + } + + /// 某列里取值不多(≤ 12 种)的字符串列的全部取值:网格内联编辑的下拉候选(status 这种枚举列)。 + public IReadOnlyList EnumValuesOf(string path) + { + IReadOnlyList<(string Label, string Insert, BsonKind Kind, double Ratio)> values = ValuesOf(path); + if (values.Count == 0 || values.Count >= 8 || values.Any(static v => v.Kind != BsonKind.String)) + { + return []; + } + double covered = values.Sum(static v => v.Ratio); + return covered < 0.6 ? [] : [.. values.Select(static v => ShellJson.ParseValue(v.Label).AsString)]; + } + + /// 去掉数组下标(items.0.sku → items.sku),与抽样字段表的写法一致。 + internal static string NormalizePath(string path) => + string.Join('.', path.Split('.').Where(static s => s.Length > 0 && !int.TryParse(s, NumberStyles.None, CultureInfo.InvariantCulture, out _))); + + private static IEnumerable Collect(BsonValue current, string[] segments, int index) + { + if (current is BsonArray array) + { + foreach (BsonValue item in array) + { + foreach (BsonValue nested in Collect(item, segments, index)) + { + yield return nested; + } + } + yield break; + } + if (index == segments.Length) + { + yield return current; + yield break; + } + if (current is BsonDocument doc && doc.TryGetValue(segments[index], out BsonValue next)) + { + foreach (BsonValue nested in Collect(next, segments, index + 1)) + { + yield return nested; + } + } + } + + /// + /// 补全:键位置给字段名(筛选栏里给完整点路径,$ 开头给查询运算符),值位置给取值分布。 + /// + /// 文案表。 + /// 全文。 + /// 光标。 + /// 筛选栏(键给完整点路径与查询运算符)还是文档编辑(键只给当前对象的子字段)。 + public CompletionSet? Complete(Loc loc, string text, int caret, bool filterMode) + { + CaretPathScanner.Result at = CaretPathScanner.Scan(text, caret); + string prefix = text[at.TokenStart..Math.Max(at.TokenStart, caret)]; + if (at.InValue) + { + string fieldPath = at.Path; + if (filterMode) + { + // 筛选里 { total: { $gte: | } }:值属于外层那个字段,而不是 $gte。 + string[] parts = fieldPath.Split('.'); + fieldPath = string.Join('.', parts.Where(static p => !p.StartsWith('$'))); + } + IReadOnlyList<(string Label, string Insert, BsonKind Kind, double Ratio)> values = ValuesOf(fieldPath); + if (values.Count == 0) + { + return null; + } + return new CompletionSet + { + Items = [.. values.Select(v => new CompletionItem + { + Label = v.Label, + InsertText = v.Insert, + IconKey = "Mongo.type", + IconToken = BsonKinds.ColorToken(v.Kind), + Ratio = v.Ratio, + Category = loc.Format("Cw_SuggestRatio", (v.Ratio * 100).ToString("0", CultureInfo.InvariantCulture)) + })], + ReplaceOffset = at.TokenStart, + ReplaceLength = Math.Max(0, caret - at.TokenStart), + Header = loc.Format("Cw_SuggestHeader", NormalizePath(fieldPath), BsonText.Grouped(Size)), + Footer = loc["Cw_SuggestFooter"] + }; + } + + var items = new List(); + if (filterMode && prefix.StartsWith('$')) + { + items.AddRange(MongoVocabulary.QueryOperators.Select(e => MongoVocabulary.ToCompletion(e, loc.IsChinese))); + } + else + { + IEnumerable fields = filterMode && at.ParentPath.Length == 0 ? Fields : ChildrenOf(at.ParentPath); + foreach (CollectionSampleField field in fields.Take(80)) + { + string name = filterMode ? field.Path : field.Path[(field.Path.LastIndexOf('.') + 1)..]; + bool needsQuotes = !System.Text.RegularExpressions.Regex.IsMatch(name, "^[A-Za-z_$][A-Za-z0-9_$]*$"); + items.Add(new CompletionItem + { + Label = name, + InsertText = (needsQuotes ? BsonText.Quote(name) : name) + ": |", + IconKey = "Mongo.variable", + IconToken = "VelaWarning", + Category = loc.Format("Cw_SuggestField", BsonKinds.Name(field.Kind)), + Description = loc.Format("Cw_SuggestFieldDetail", BsonText.Grouped(field.Count), BsonText.Grouped(Size)) + }); + } + if (filterMode && prefix.Length == 0) + { + items.AddRange(MongoVocabulary.QueryOperators.Take(6).Select(e => MongoVocabulary.ToCompletion(e, loc.IsChinese))); + } + } + if (items.Count == 0) + { + return null; + } + return new CompletionSet + { + Items = items, + ReplaceOffset = at.TokenStart, + ReplaceLength = Math.Max(0, caret - at.TokenStart) + }; + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Collection/CollectionSpinner.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/CollectionSpinner.cs new file mode 100644 index 0000000..3ae141e --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/CollectionSpinner.cs @@ -0,0 +1,91 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Threading; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 长查询卡片里的转圈(设计稿 22:VelaBorderSecondary 圆环 + 一段强调色弧)。 +/// 只在可见时转 —— 隐藏的标签页里一直跑计时器是白耗电。 +/// +public sealed class CollectionSpinner : Control +{ + private readonly DispatcherTimer _timer; + private double _angle; + + /// 构造。 + public CollectionSpinner() + { + _timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(33) }; + _timer.Tick += (_, _) => + { + if (!IsEffectivelyVisible) + { + return; + } + _angle = (_angle + 12) % 360; + InvalidateVisual(); + }; + } + + /// + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + Sync(); + } + + /// + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + _timer.Stop(); + base.OnDetachedFromVisualTree(e); + } + + /// + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == IsVisibleProperty) + { + Sync(); + } + } + + private void Sync() + { + if (IsVisible && VisualRoot is not null) + { + _timer.Start(); + } + else + { + _timer.Stop(); + } + } + + /// + public override void Render(DrawingContext context) + { + double size = Math.Min(Bounds.Width, Bounds.Height); + if (size <= 4) + { + return; + } + double radius = size / 2 - 1.5; + var center = new Point(Bounds.Width / 2, Bounds.Height / 2); + var track = new Pen(ThemeBrushes.Get("VelaBorderSecondary", Brushes.Gray), 3); + context.DrawEllipse(null, track, center, radius, radius); + var arc = new StreamGeometry(); + using (StreamGeometryContext g = arc.Open()) + { + double start = _angle * Math.PI / 180; + double end = start + Math.PI / 2; + g.BeginFigure(new Point(center.X + radius * Math.Cos(start), center.Y + radius * Math.Sin(start)), false); + g.ArcTo(new Point(center.X + radius * Math.Cos(end), center.Y + radius * Math.Sin(end)), new Size(radius, radius), 0, false, SweepDirection.Clockwise); + g.EndFigure(false); + } + context.DrawGeometry(null, new Pen(ThemeBrushes.Get("VelaAccent", Brushes.MediumPurple), 3, lineCap: PenLineCap.Round), arc); + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Collection/CollectionStyles.axaml b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/CollectionStyles.axaml new file mode 100644 index 0000000..ab38a8b --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/CollectionStyles.axaml @@ -0,0 +1,281 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Collection/CollectionTabView.axaml b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/CollectionTabView.axaml new file mode 100644 index 0000000..ac3e3b0 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/CollectionTabView.axaml @@ -0,0 +1,346 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Collection/CollectionTabView.axaml.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/CollectionTabView.axaml.cs new file mode 100644 index 0000000..dc8a95f --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/CollectionTabView.axaml.cs @@ -0,0 +1,163 @@ +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.VisualTree; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 集合工作台的视图:装配三种数据视图、查询栏的键盘手势(Enter / Ctrl+Enter 查找)、 +/// 历史下拉,以及标签级快捷键 Ctrl+S 应用 · Esc 放弃。 +/// +public sealed partial class CollectionTabView : UserControl +{ + private readonly CollectionTabViewModel? _viewModel; + + /// 用给定的视图模型初始化。 + internal CollectionTabView(CollectionTabViewModel viewModel) + { + _viewModel = viewModel; + InitializeComponent(); + DataContext = viewModel; + GridPane.Attach(viewModel); + TreePane.Attach(viewModel); + JsonPane.Attach(viewModel); + FilterEditor.CompletionProvider = request => + Task.FromResult(viewModel.Sample.Complete(viewModel.Loc, request.Text, request.CaretOffset, filterMode: true)); + // 单行筛选框:Enter 就是查找 —— 补全弹层开着时除外,那时的 Enter 是「接受选中的补全项」。 + // 同一元素上的隧道处理器按注册的逆序调用,这里比编辑器自己的那个先拿到事件,所以要自己让路。 + FilterEditor.Editor.TextArea.AddHandler(KeyDownEvent, OnFilterKeyDown, RoutingStrategies.Tunnel); + FilterEditor.Editor.HorizontalScrollBarVisibility = ScrollBarVisibility.Hidden; + FilterEditor.Editor.VerticalScrollBarVisibility = ScrollBarVisibility.Hidden; + FilterEditor.Editor.Padding = new Avalonia.Thickness(8, 5, 8, 0); + AddHandler(KeyDownEvent, OnKeyDown, RoutingStrategies.Bubble); + } + + /// 设计器 / headless 用的无参构造。 + public CollectionTabView() + { + InitializeComponent(); + } + + /// 网格面板(测试用)。 + internal GridPaneView Grid => GridPane; + + /// 树面板(测试用)。 + internal DocTreeView Tree => TreePane; + + /// JSON 面板(测试用)。 + internal JsonCardsView Json => JsonPane; + + private void OnFilterKeyDown(object? sender, KeyEventArgs e) + { + if (_viewModel is null || e.Key != Key.Enter || FilterEditor.IsCompletionOpen) + { + return; + } + e.Handled = true; + _viewModel.FindCommand.Execute(null); + } + + private void OnOptionKeyDown(object? sender, KeyEventArgs e) + { + if (e.Key == Key.Enter && _viewModel is not null) + { + e.Handled = true; + _viewModel.FindCommand.Execute(null); + } + } + + private void OnPageKeyDown(object? sender, KeyEventArgs e) + { + if (e.Key == Key.Enter && _viewModel is not null) + { + e.Handled = true; + _viewModel.GoToPageCommand.Execute(null); + } + } + + /// + /// 标签级快捷键:Ctrl+S 应用、Esc 放弃、Ctrl+Enter 查找。挂在冒泡阶段 —— + /// 单元格编辑器、JSON 卡片编辑器自己的 Esc / Ctrl+S 先处理掉,这里只接剩下的。 + /// + private void OnKeyDown(object? sender, KeyEventArgs e) + { + if (_viewModel is null || e.Handled) + { + return; + } + bool control = e.KeyModifiers.HasFlag(KeyModifiers.Control); + switch (e.Key) + { + case Key.S when control: + _viewModel.ApplyCommand.Execute(null); + e.Handled = true; + break; + case Key.Enter when control: + _viewModel.FindCommand.Execute(null); + e.Handled = true; + break; + case Key.Escape when _viewModel.HasPending && !IsTextInput(e.Source): + _viewModel.DiscardCommand.Execute(null); + e.Handled = true; + break; + } + } + + /// 历史 / 收藏下拉:收藏在前(星标),历史在后;点一条即套用并查找。 + private void OnHistoryClick(object? sender, RoutedEventArgs e) + { + if (_viewModel is null) + { + return; + } + var items = new List(); + foreach (CollectionFilterItem item in _viewModel.HistoryItems.Take(30)) + { + items.Add(new MenuItem + { + Header = new TextBlock + { + Text = item.Text, + Classes = { "mono", "trim" }, + MaxWidth = 520 + }, + Icon = new Glyph { Key = item.IconKey, Size = 12, Brush = ThemeBrushes.Get(item.IconToken, Avalonia.Media.Brushes.Gray) }, + Command = _viewModel.ApplyHistoryCommand, + CommandParameter = item + }); + } + if (items.Count == 0) + { + items.Add(new MenuItem { Header = _viewModel.Loc["Cw_HistoryEmpty"], IsEnabled = false }); + } + var menu = new ContextMenu + { + ItemsSource = items, + Classes = { "cw" }, + Placement = PlacementMode.BottomEdgeAlignedRight, + HorizontalAlignment = HorizontalAlignment.Right + }; + menu.Open(HistoryButton); + } + + /// 事件来自文本输入(文本框、代码编辑器)—— 那里的 Esc 是"不打了",不是"放弃全部修改"。 + private static bool IsTextInput(object? source) + { + for (var visual = source as Avalonia.Visual; visual is not null; visual = visual.GetVisualParent()) + { + if (visual is TextBox or CodeEditor) + { + return true; + } + } + return false; + } + + /// 焦点在不在某个控件里面(子面板判断键盘归属用)。 + internal static bool IsWithin(Control container) => + TopLevel.GetTopLevel(container)?.FocusManager?.GetFocusedElement() is Control focused + && (ReferenceEquals(focused, container) || container.IsVisualAncestorOf(focused)); +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Collection/CollectionTabViewModel.Drill.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/CollectionTabViewModel.Drill.cs new file mode 100644 index 0000000..2f31cba --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/CollectionTabViewModel.Drill.cs @@ -0,0 +1,422 @@ +using System.Collections.ObjectModel; +using System.Globalization; +using MongoDB.Bson; +using VelaShell.Plugin.Mongo.Bson; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 网格钻入(Navicat 式):双击对象 / 数组单元格,网格换成那一层的子表;面包屑与 Backspace 往回走。 +/// +/// 网格绑的是 / / : +/// 不钻入时它们就是本页文档那一套,钻入时换成子表。树视图、JSON 卡片与检查器始终看文档那一层 —— +/// 检查器讲的仍是子表所属的那份文档。 +/// +/// +internal sealed partial class CollectionTabViewModel +{ + private GridDrill? _drill; + private ObservableCollection _drillRows = []; + private CollectionRow? _drillSelected; + private IReadOnlyList _drillSelectedRows = []; + private readonly List _drillHistory = []; + private IReadOnlyList _drillCrumbs = []; + private bool _swappingGrid; + + /// 正在看某个对象 / 数组的子表。 + public bool IsDrilled => _drill is not null; + + /// 当前这一层;没钻入为 。 + public GridDrill? Drill => _drill; + + /// 网格的列。 + public IReadOnlyList GridColumns => _drill?.Columns ?? _columns; + + /// 网格的行。 + public ObservableCollection GridRows => _drill is null ? _rows : _drillRows; + + /// 网格选中的行(钻入时是子表里的那一行;检查器仍看它所属的文档)。 + public CollectionRow? GridSelectedRow + { + get => _drill is null ? _selectedRow : _drillSelected; + set + { + // 换数据源的那一刻 ListBox 会把选中清成 null 推回来 —— 那不是用户的选择。 + if (_swappingGrid) + { + return; + } + if (_drill is null) + { + SelectedRow = value; + return; + } + CollectionRow? previous = _drillSelected; + if (ReferenceEquals(previous, value)) + { + return; + } + _drillSelected = value; + UpdateCurrentCell(previous, value); + RaisePropertyChanged(); + } + } + + /// 网格里多选的行(由视图在选中变化时写入)。 + public IReadOnlyList GridSelectedRows + { + get => _drill is null ? SelectedRows : _drillSelectedRows.Count > 0 ? _drillSelectedRows : _drillSelected is { } one ? [one] : []; + set + { + if (_swappingGrid) + { + return; + } + if (_drill is null) + { + SelectedRows = value; + } + else + { + _drillSelectedRows = value; + } + } + } + + /// 面包屑:文档 #3 › items › [2] › attrs。 + public IReadOnlyList DrillCrumbs + { + get => _drillCrumbs; + private set => SetProperty(ref _drillCrumbs, value); + } + + /// 面包屑右侧的小字(数组 · 4 项)。 + public string DrillSummary => _drill?.Summary(Loc) ?? ""; + + /// 面包屑左侧的「‹」。 + public RelayCommand DrillBackCommand { get; private set; } = null!; + + /// 点面包屑的某一段。 + public RelayCommand DrillToCommand { get; private set; } = null!; + + private void InitializeDrillCommands() + { + DrillBackCommand = new(DrillBack, () => _drill is not null); + DrillToCommand = new(crumb => + { + if (_drill is not { } drill || crumb.IsLast) + { + return; + } + // 往回跳:历史里比目标更深的都不要了。 + _drillHistory.RemoveAll(p => p.Length >= crumb.Path.Length); + if (crumb.Path.Length == 0) + { + ExitDrill(drill.Path); + } + else + { + Show(drill.Root, crumb.Path, focusFrom: drill.Path); + } + }); + } + + /// + /// 钻进一格(双击 / Enter / 右键「展开」):对象与数组才进得去。 + /// 返回 false 表示这格不是容器,调用方照常走内联编辑。 + /// + internal bool DrillInto(CollectionCell cell) + { + if (!cell.IsContainer) + { + return false; + } + CancelGridEdits(); + if (_drill is { } current) + { + _drillHistory.Add(current.Path); + } + return Show(cell.Row.Root, cell.Path, focusFrom: null); + } + + /// 回上一层(Backspace / Alt+← / 「‹」);已在最外层时什么也不做。 + internal void DrillBack() + { + if (_drill is not { } drill) + { + return; + } + CancelGridEdits(); + if (_drillHistory.Count > 0) + { + string previous = _drillHistory[^1]; + _drillHistory.RemoveAt(_drillHistory.Count - 1); + if (Show(drill.Root, previous, focusFrom: drill.Path)) + { + return; + } + } + ExitDrill(drill.Path); + } + + /// 退出钻入,回到本页文档;选中回到那份文档、当前列回到钻进去时的那一列。 + internal void ExitDrill(string? focusFrom = null) + { + if (_drill is not { } drill) + { + return; + } + CancelGridEdits(); + _drillHistory.Clear(); + _swappingGrid = true; + try + { + _drill = null; + _drillRows = []; + _drillSelected = null; + _drillSelectedRows = []; + RaiseGridChanged(); + } + finally + { + _swappingGrid = false; + } + string? first = focusFrom?.Split('.')[0]; + if (first is not null && _columns.FirstOrDefault(c => c.Name == first) is { } column) + { + _currentColumn = column; + RaisePropertyChanged(nameof(CurrentColumn)); + } + CollectionRow? root = _rows.Contains(drill.Root) ? drill.Root : _selectedRow; + _selectedRow = null; + SelectedRow = root; + RaisePropertyChanged(nameof(GridSelectedRow)); + } + + /// + /// 换到 里 那一层。 是刚离开的那一层的路径: + /// 往回走时用它把选中落回"刚才是从哪一行、哪一列钻进去的"。 + /// + private bool Show(CollectionRow root, string path, string? focusFrom) + { + IReadOnlyList? previous = _drill is { } old && old.Path == path ? old.Columns : null; + GridDrill? drill = GridDrill.Create(this, root, path, previous, out List rows); + if (drill is null) + { + return false; + } + int keepIndex = _drill is { } same && same.Path == path ? (_drillSelected?.Number ?? 0) : 0; + string? keepColumn = _currentColumn?.Name; + _swappingGrid = true; + try + { + _drill = drill; + _drillRows = new ObservableCollection(rows); + _drillSelected = null; + _drillSelectedRows = []; + RaiseGridChanged(); + } + finally + { + _swappingGrid = false; + } + if (!ReferenceEquals(_selectedRow, root)) + { + SelectedRow = root; + } + + // 往回走:从 focusFrom 里认出刚才那一行(数组下标)与那一列(下一段字段名)。 + CollectionRow? select = null; + CollectionColumn? column = null; + if (focusFrom is not null && focusFrom.Length > path.Length && focusFrom.StartsWith(path + ".", StringComparison.Ordinal)) + { + string[] rest = focusFrom[(path.Length + 1)..].Split('.'); + int at = 0; + if (drill.IsArray && int.TryParse(rest[0], NumberStyles.None, CultureInfo.InvariantCulture, out int index)) + { + select = rows.FirstOrDefault(r => r.Number == index); + at = 1; + } + if (at < rest.Length) + { + column = drill.Columns.FirstOrDefault(c => !c.IsElementValue && c.Name == rest[at]); + } + else if (drill.IsArray) + { + column = drill.Columns.FirstOrDefault(static c => c.IsElementValue); + } + } + select ??= rows.FirstOrDefault(r => r.Number == keepIndex) ?? rows.FirstOrDefault(); + column ??= drill.Columns.FirstOrDefault(c => c.Name == keepColumn) + ?? drill.Columns.FirstOrDefault(static c => c.Name != "_id") + ?? drill.Columns.FirstOrDefault(); + _currentColumn = column; + RaisePropertyChanged(nameof(CurrentColumn)); + GridSelectedRow = select; + if (select is not null) + { + RowAdded?.Invoke(select); + } + return true; + } + + /// 暂存区变了:子表按所属文档的新版本重建(元素可能多了、少了),停在原来的下标上。 + private void RefreshDrill() + { + if (_drill is not { } drill) + { + return; + } + if (!_rows.Contains(drill.Root)) + { + ExitDrill(); + return; + } + // 形状没变(元素数、列都一样)就原地重算各行 —— 改一格不该把子表整个换掉,选中、焦点、滚动位置都留着。 + GridDrill? fresh = GridDrill.Create(this, drill.Root, drill.Path, drill.Columns, out _); + if (fresh is not null && fresh.IsArray == drill.IsArray && fresh.Count == drill.Count + && fresh.Columns.Select(static c => (c.Name, c.IsElementValue)).SequenceEqual(drill.Columns.Select(static c => (c.Name, c.IsElementValue)))) + { + foreach (CollectionRow row in _drillRows) + { + row.Recompute(); + } + return; + } + if (!Show(drill.Root, drill.Path, focusFrom: null)) + { + // 容器本身没了(撤销了新加的字段、被改成了标量):退到最近一层还在的祖先,都没了就回文档。 + string path = drill.Path; + while (path.LastIndexOf('.') is var dot and > 0) + { + path = path[..dot]; + if (Show(drill.Root, path, focusFrom: null)) + { + return; + } + } + ExitDrill(); + } + } + + /// 重查 / 翻页之后:按 _id(新增行按暂存键)找回那份文档,接着看同一层;找不到就退出。 + private void ReattachDrill() + { + if (_drill is not { } drill) + { + return; + } + CollectionRow? root = drill.Root.Insert is { } insert + ? _rows.FirstOrDefault(r => ReferenceEquals(r.Insert, insert)) + : _rows.FirstOrDefault(r => drill.Root.Id is { } id && id.Equals(r.Id)); + if (root is null || !Show(root, drill.Path, focusFrom: null)) + { + ExitDrill(); + } + } + + /// 底栏「+」在子表里:数组末尾加一个元素;对象里加一个字段。 + private void AddDrillRow(GridDrill drill) + { + if (!EnsureCanWrite()) + { + return; + } + if (drill.IsArray) + { + InsertArrayElement(drill.Root, drill.Path); + if (_drill is { } now && _drillRows.LastOrDefault() is { } last) + { + GridSelectedRow = last; + _currentColumn = now.Columns.FirstOrDefault(static c => c.IsElementValue) ?? _currentColumn; + RaisePropertyChanged(nameof(CurrentColumn)); + RowAdded?.Invoke(last); + } + return; + } + PromptAddField(drill.Root, drill.Path); + } + + /// 工具栏「删除」、底栏「−」、Del:网格钻入时删子表里的元素,否则暂存删除选中的文档。 + private void DeleteGridSelection() + { + if (_drill is { } drill && _viewMode == CollectionViewMode.Grid) + { + DeleteDrillRows(drill); + } + else + { + StageDelete(SelectedRows); + } + } + + /// + /// 删除子表里选中的元素(数组);对象那一层没有"行"可删,删的是当前格那个字段。 + /// 数组按下标从大到小删 —— 先删小的,后面的下标就全错位了。 + /// + private void DeleteDrillRows(GridDrill drill) + { + if (!EnsureCanWrite()) + { + return; + } + if (!drill.IsArray) + { + if (_drillSelected is { } row && _currentColumn is { } column && row.CellOf(column) is { IsMissing: false } cell) + { + DeleteField(row, cell.Path); + } + return; + } + List indexes = [.. GridSelectedRows.Select(static r => r.Number).Distinct().OrderDescending()]; + if (indexes.Count == 0 || BsonPath.Get(drill.Root.Document, drill.Path) is not BsonArray array) + { + return; + } + var copy = (BsonArray)array.DeepClone(); + foreach (int index in indexes.Where(i => i >= 0 && i < copy.Count)) + { + copy.RemoveAt(index); + } + Stage(drill.Root, drill.Path, copy); + } + + private void RaiseGridChanged() + { + RaisePropertyChanged(nameof(GridColumns)); + RaisePropertyChanged(nameof(GridRows)); + RaisePropertiesChanged(nameof(IsDrilled), nameof(Drill), nameof(DrillSummary)); + DrillCrumbs = BuildCrumbs(); + DrillBackCommand.RaiseCanExecuteChanged(); + } + + private List BuildCrumbs() + { + if (_drill is not { } drill) + { + return []; + } + string document = drill.Root.Insert is null + ? Loc.Format("Cw_DrillDoc", drill.Root.NumberText) + : Loc["Cw_DrillNewDoc"]; + var crumbs = new List { new(document, "", false) }; + string[] segments = drill.Path.Split('.'); + for (int i = 0; i < segments.Length; i++) + { + crumbs.Add(new(GridDrill.SegmentLabel(segments[i]), string.Join('.', segments[..(i + 1)]), i == segments.Length - 1)); + } + return crumbs; + } + + /// 换层之前收起正在编辑的格子(编辑器挂在旧的行上,换了数据源就再也提交不了)。 + private void CancelGridEdits() + { + if (GridSelectedRow?.Cells is { } cells) + { + foreach (CollectionCell cell in cells.Where(static c => c.IsEditing)) + { + cell.Editor = null; + } + } + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Collection/CollectionTabViewModel.Edit.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/CollectionTabViewModel.Edit.cs new file mode 100644 index 0000000..4588525 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/CollectionTabViewModel.Edit.cs @@ -0,0 +1,808 @@ +using System.Globalization; +using Avalonia.Threading; +using MongoDB.Bson; +using MongoDB.Driver; +using VelaShell.Plugin.Mongo.Bson; +using VelaShell.Plugin.Mongo.Staging; +using VelaShell.Plugin.Mongo.Core; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 暂存、提交、撤销、冲突与工具栏的写操作。 +internal sealed partial class CollectionTabViewModel +{ + private HashSet _trackedIds = []; + private bool _committing; + private DispatcherTimer? _relockTimer; + + /// 添加文档(打开文档编辑器)。 + public RelayCommand AddDocumentCommand { get; private set; } = null!; + + /// 克隆选中文档(去掉 _id 交给文档编辑器)。 + public RelayCommand CloneCommand { get; private set; } = null!; + + /// 删除选中(暂存;已暂存删除的再点一次是撤销删除)。 + public RelayCommand DeleteCommand { get; private set; } = null!; + + /// 导入。 + public RelayCommand ImportCommand { get; private set; } = null!; + + /// 导出(带上当前查询)。 + public RelayCommand ExportCommand { get; private set; } = null!; + + /// 选中文档按当前 EJSON 写法复制。 + public AsyncCommand CopyJsonCommand { get; private set; } = null!; + + /// 底栏「+」:暂存一行新文档。 + public RelayCommand AddRowCommand { get; private set; } = null!; + + /// 应用(提交暂存区,Ctrl+S)。 + public AsyncCommand ApplyCommand { get; private set; } = null!; + + /// 放弃(Esc)。 + public RelayCommand DiscardCommand { get; private set; } = null!; + + /// 只读横幅「解锁写入 15 分钟」。 + public AsyncCommand UnlockCommand { get; private set; } = null!; + + private void InitializeEditCommands() + { + AddDocumentCommand = new(OpenNewDocument); + CloneCommand = new(() => + { + if (SelectedRow is { } row) + { + Clone(row); + } + }); + DeleteCommand = new(DeleteGridSelection); + ImportCommand = new(() => + { + if (EnsureCanWrite()) + { + Workspace.ShowDialog(new ImportWizardViewModel(Workspace, Database, CollectionName)); + } + }); + ExportCommand = new(() => Workspace.ShowDialog(new ExportWizardViewModel(Workspace, Database, CollectionName, _lastRequest, null))); + CopyJsonCommand = new(() => CopyDocumentsAsync(SelectedRows)); + AddRowCommand = new(() => + { + if (_drill is { } drill && _viewMode == CollectionViewMode.Grid) + { + AddDrillRow(drill); + } + else + { + AddRow(); + } + }); + ApplyCommand = new(() => CommitAsync(CommitScope.All)); + DiscardCommand = new(DiscardAll); + UnlockCommand = new(UnlockAsync); + InitializeDrillCommands(); + } + + // ── 待提交 ─────────────────────────────────────────────────────────────── + + /// 有没有待提交的修改。 + public bool HasPending => !Staging.IsEmpty; + + /// + /// 底栏的「待提交:1 修改 · 1 新增 · 1 删除」;JSON 视图里只有字段修改时换成 + /// 「待提交:文档 #3 · #4 共 3 处修改」(那里看的是一份份文档,不是行)。 + /// + public string PendingText + { + get + { + if (Staging.IsEmpty) + { + return Loc["Cw_PendingNone"]; + } + if (_viewMode == CollectionViewMode.Json && Staging.InsertCount == 0 && Staging.DeleteCount == 0) + { + IEnumerable numbers = Staging.Edits + .Select(e => _rows.FirstOrDefault(r => r.Insert is null && e.Id.Equals(r.Id))?.Number) + .Where(static n => n is not null) + .Take(4) + .Select(static n => "#" + n!.Value.ToString(CultureInfo.InvariantCulture)); + return Loc.Format("Cw_PendingDocs", string.Join(" · ", numbers), Staging.FieldChangeCount); + } + var parts = new List(3); + if (Staging.EditCount > 0) + { + parts.Add(Loc.Format("Cw_PendingEdits", Staging.EditCount)); + } + if (Staging.InsertCount > 0) + { + parts.Add(Loc.Format("Cw_PendingInserts", Staging.InsertCount)); + } + if (Staging.DeleteCount > 0) + { + parts.Add(Loc.Format("Cw_PendingDeletes", Staging.DeleteCount)); + } + return Loc["Cw_PendingPrefix"] + string.Join(" · ", parts); + } + } + + /// + /// 暂存区变了:只重算受影响的行(暂存过的 _id 与上一次暂存过的 _id), + /// 新增行增删,树 / 卡片 / 检查器跟着刷新。5 万行的页里改一格不该把 5 万行都过一遍。 + /// + private void OnStagingChanged() + { + IsModified = !Staging.IsEmpty; + HashSet now = [.. Staging.Edits.Select(static e => e.Id), .. Staging.Deletes.Select(static d => d.Id)]; + HashSet affected = [.. now, .. _trackedIds]; + _trackedIds = now; + if (affected.Count > 0) + { + foreach (CollectionRow row in _rows) + { + if (row.Insert is null && row.Id is { } id && affected.Contains(id)) + { + row.Recompute(); + } + } + } + SyncInsertRows(); + RefreshDrill(); + RebuildTree(); + foreach (JsonCardViewModel card in Cards) + { + if (card.Row.Insert is not null || card.Row.Id is { } id && affected.Contains(id)) + { + card.Refresh(); + } + } + Inspector.Reload(); + RaisePropertiesChanged(nameof(HasPending), nameof(PendingText), nameof(IsEmptyCollection), nameof(IsNoResult), nameof(ShowData)); + UpdateStatus(); + } + + /// 暂存的新文档与网格末尾的新增行对齐。 + private void SyncInsertRows() + { + var staged = Staging.Inserts.ToHashSet(); + for (int i = _rows.Count - 1; i >= 0; i--) + { + if (_rows[i].Insert is { } insert && !staged.Contains(insert)) + { + if (ReferenceEquals(_rows[i], _selectedRow)) + { + SelectedRow = null; + } + _rows.RemoveAt(i); + } + } + var present = _rows.Where(static r => r.Insert is not null).Select(static r => r.Insert!).ToHashSet(); + foreach (StagedInsert insert in Staging.Inserts) + { + if (!present.Contains(insert)) + { + _rows.Add(new CollectionRow(this, 0, insert)); + } + } + foreach (CollectionRow row in _rows.Where(static r => r.Insert is not null)) + { + row.Recompute(); + } + } + + // ── 写入口 ─────────────────────────────────────────────────────────────── + + /// + /// 写之前的检查:视图一律不能写;其余交给 + /// (只读模式 / 没权限时它会弹出"怎么解锁"的提示)。暂存这一步就拦, + /// 而不是等提交时才说不行 —— 在只读连接上攒了一堆改动最后提交不了,比一开始就告诉他更糟。 + /// + internal bool EnsureCanWrite() + { + if (!Info.IsEditable) + { + Workspace.Toast(new() { Title = Loc["Cw_ViewReadOnly"], Kind = ToastKind.Warning }); + return false; + } + return Workspace.EnsureWritable(Database); + } + + /// 暂存一个字段的新值( 为 null 即删除字段)。 + internal bool Stage(CollectionRow row, string path, BsonValue? value) + { + if (path == "_id" && row.Insert is null) + { + Workspace.Toast(new() { Title = Loc["Cw_IdImmutable"], Kind = ToastKind.Warning }); + return false; + } + if (!EnsureCanWrite() || row.IsDeleted) + { + return false; + } + if (row.Insert is { } insert) + { + Staging.SetInsertField(insert, path, value); + } + else if (row.Original is { } original) + { + Staging.SetField(original, path, value); + } + return true; + } + + /// 暂存删除(或撤销暂存的删除;新增行直接拿掉)。 + internal void StageDelete(IReadOnlyList rows) + { + if (rows.Count == 0 || !EnsureCanWrite()) + { + return; + } + List existing = [.. rows.Where(static r => r.Insert is null && r.Original is not null)]; + bool undo = existing.Count > 0 && existing.All(r => Staging.IsDeleted(r.Id)); + foreach (CollectionRow row in rows.ToList()) + { + if (row.Insert is { } insert) + { + Staging.RemoveInsert(insert); + } + else if (undo) + { + Staging.UnmarkDeleted(row.Id!); + } + else + { + Staging.MarkDeleted(row.Original!); + } + } + } + + /// 撤掉一份文档上的全部暂存(检查器「放弃」)。 + internal void DiscardDocument(CollectionRow row) + { + if (row.Insert is { } insert) + { + Staging.RemoveInsert(insert); + } + else if (row.Id is { } id) + { + Staging.Discard(id); + } + } + + /// 底栏「+」:按当前列给一份空模板(数值 0、对象 {}、数组 [],日期取现在),排在网格末尾。 + private void AddRow() + { + if (!EnsureCanWrite()) + { + return; + } + var doc = new BsonDocument(); + foreach (CollectionColumn column in _columns.Where(static c => c.Name != "_id")) + { + doc[column.Name] = column.Kind is BsonKind.Null or BsonKind.Missing or BsonKind.Binary or BsonKind.Regex + or BsonKind.Timestamp or BsonKind.Other or BsonKind.ObjectId + ? BsonNull.Value + : BsonEdit.Empty(column.Kind); + } + StagedInsert insert = Staging.AddInsert(doc); + if (_rows.FirstOrDefault(r => ReferenceEquals(r.Insert, insert)) is { } row) + { + SelectedRow = row; + RowAdded?.Invoke(row); + } + } + + /// 新增了一行(视图滚到它)。 + public event Action? RowAdded; + + private void OpenNewDocument() + { + if (EnsureCanWrite()) + { + Workspace.ShowDialog(new DocumentEditorDialogViewModel(Workspace, Info, null, OnEditorSavedAsync)); + } + } + + /// 克隆:去掉 _id 交给文档编辑器(保存即插入一份新的)。 + internal void Clone(CollectionRow row) + { + if (!EnsureCanWrite()) + { + return; + } + var copy = (BsonDocument)row.Document.DeepClone(); + copy.Remove("_id"); + Workspace.ShowDialog(new DocumentEditorDialogViewModel(Workspace, Info, copy, OnEditorSavedAsync)); + } + + /// 在文档编辑器中编辑(编辑器直接写服务器;这份文档在这里的暂存随之作废)。 + internal void OpenInEditor(CollectionRow row) + { + if (!Info.IsEditable) + { + Workspace.Toast(new() { Title = Loc["Cw_ViewReadOnly"], Kind = ToastKind.Warning }); + return; + } + BsonDocument document = (BsonDocument)row.Document.DeepClone(); + Workspace.ShowDialog(new DocumentEditorDialogViewModel(Workspace, Info, document, async saved => + { + DiscardDocument(row); + await OnEditorSavedAsync(saved).ConfigureAwait(true); + })); + } + + private async Task OnEditorSavedAsync(BsonDocument? saved) + { + await RunQueryAsync(resetPage: false).ConfigureAwait(true); + if (saved?.GetValue("_id", BsonNull.Value) is { IsBsonNull: false } id + && _rows.FirstOrDefault(r => id.Equals(r.Id)) is { } row) + { + SelectedRow = row; + } + } + + // ── 复制 / 筛选 ────────────────────────────────────────────────────────── + + /// 选中文档按当前写法复制(多份写成数组)。 + internal async Task CopyDocumentsAsync(IReadOnlyList rows) + { + if (rows.Count == 0) + { + return; + } + string text = rows.Count == 1 + ? BsonText.Pretty(rows[0].Document, _ejson) + : "[\n" + string.Join(",\n", rows.Select(r => Indent(BsonText.Pretty(r.Document, _ejson)))) + "\n]"; + await Workspace.CopyAsync(text).ConfigureAwait(true); + Workspace.Toast(new() { Title = Loc.Format("Cw_CopiedDocs", rows.Count), Kind = ToastKind.Success }); + + static string Indent(string block) => " " + block.Replace("\n", "\n ", StringComparison.Ordinal); + } + + /// 复制一个值(字符串复制原文,其余复制 mongosh 字面量)。 + internal async Task CopyValueAsync(BsonValue? value) + { + string text = value switch + { + null => "", + BsonString s => s.Value, + _ => BsonText.Literal(value) + }; + await Workspace.CopyAsync(text).ConfigureAwait(true); + Workspace.Toast(new() { Title = Loc["Common_Copied"], Kind = ToastKind.Success, Duration = TimeSpan.FromSeconds(2) }); + } + + /// 复制路径。 + internal async Task CopyTextAsync(string text) + { + await Workspace.CopyAsync(text).ConfigureAwait(true); + Workspace.Toast(new() { Title = Loc["Common_Copied"], Kind = ToastKind.Success, Duration = TimeSpan.FromSeconds(2) }); + } + + /// 复制为 JSON(当前写法,多行)。 + internal Task CopyValueAsJsonAsync(BsonValue? value) => + CopyTextAsync(value is null ? "" : BsonText.Pretty(value, _ejson)); + + /// 「按此值筛选」生成的筛选文本(右键菜单底部的预览也用它)。 + internal static string FilterFor(string path, BsonValue? value) + { + string key = BsonText.FieldName(path); + string literal = value is null ? "{ $exists: false }" : BsonText.Literal(value); + return $"{{ {key}: {literal} }}"; + } + + /// 按此值筛选。 + internal Task FilterByAsync(string path, BsonValue? value) + { + FilterText = FilterFor(path, value); + return RunQueryAsync(resetPage: true); + } + + /// 在新查询中打开(按这个值查)。 + internal void OpenInQuery(string path, BsonValue? value) => + Workspace.OpenQuery(Database, $"db.{MongoWorkspaceViewModel.ShellCollectionRef(CollectionName)}.find({FilterFor(path, value)})"); + + // ── 字段级编辑(网格 / 树 / 检查器共用) ─────────────────────────────────── + + /// + /// 编辑器的初始状态:类型(null / 缺失时按列的主导类型)、文本与候选值。 + /// 日期不给候选值 —— 它的按钮弹的是日历(),「现在」在日历里。 + /// + internal InlineValueEditor CreateEditor(BsonValue? value, string path, BsonKind fallback) + { + BsonKind kind = BsonKinds.Of(value); + if (kind is BsonKind.Missing or BsonKind.Null) + { + kind = fallback is BsonKind.Missing or BsonKind.Null ? BsonKind.String : fallback; + } + IReadOnlyList choices = kind switch + { + BsonKind.Boolean => ["true", "false"], + BsonKind.String => EnumChoices(path), + _ => [] + }; + return new InlineValueEditor(BsonEdit.EditText(value), kind, choices); + } + + /// + /// 字符串列的候选值:抽样里取值不多的(status),或本页里不超过 8 种取值的。 + /// 不给候选就是普通文本框 —— 单号、名字这类列给一列"候选"只会碍事。 + /// + private IReadOnlyList EnumChoices(string path) + { + IReadOnlyList sampled = _sample.EnumValuesOf(path); + if (sampled.Count > 0) + { + return sampled; + } + var distinct = new HashSet(StringComparer.Ordinal); + foreach (BsonDocument doc in _documents) + { + if (BsonPath.Get(doc, path) is BsonString s) + { + distinct.Add(s.Value); + if (distinct.Count > 8) + { + return []; + } + } + } + return distinct.Count >= 2 && _documents.Count >= distinct.Count * 3 ? [.. distinct.Order(StringComparer.Ordinal)] : []; + } + + /// + /// 按编辑器解析文本并暂存。空文本对非字符串类型视为 null(清空一格的直觉意思)。 + /// 解析失败把原因挂在编辑器上(红框 + 提示),返回 ,编辑器不关。 + /// + internal bool CommitEditor(InlineValueEditor editor, CollectionRow row, string path) + { + BsonValue value; + if (editor.Text.Length == 0 && editor.Kind != BsonKind.String) + { + value = BsonNull.Value; + } + else if (!BsonEdit.TryParse(editor.Text, editor.Kind, out value, out string? error)) + { + editor.Error = Loc[error ?? "Edit_BadLiteral"]; + return false; + } + BsonValue? current = BsonPath.Get(row.Document, path); + if (current is not null && StagedEdit.SameValue(current, value)) + { + return true; + } + return Stage(row, path, value); + } + + /// 网格:开始编辑一格(双击 / Enter / F2)。 + internal void BeginCellEdit(CollectionCell cell) + { + if (cell.Row.IsDeleted) + { + return; + } + if (cell.Path == "_id" && cell.Row.Insert is null) + { + Workspace.Toast(new() { Title = Loc["Cw_IdImmutable"], Kind = ToastKind.Warning }); + return; + } + if (!EnsureCanWrite()) + { + return; + } + if (!ReferenceEquals(GridSelectedRow, cell.Row)) + { + GridSelectedRow = cell.Row; + } + CurrentColumn = cell.Column; + foreach (CollectionCell other in cell.Row.Cells.Where(static c => c.IsEditing)) + { + other.Editor = null; + } + cell.Editor = CreateEditor(cell.Value, cell.Path, cell.Column.Kind); + } + + /// 网格:提交一格的编辑(写入暂存区);解析失败返回 false,编辑器留着。 + internal bool CommitCellEdit(CollectionCell cell) + { + if (cell.Editor is not { } editor || !CommitEditor(editor, cell.Row, cell.Path)) + { + return false; + } + cell.Editor = null; + return true; + } + + /// 网格:取消一格的编辑。 + internal static void CancelCellEdit(CollectionCell cell) => cell.Editor = null; + + /// 换类型(检查器 / 树的类型菜单):值按 换算,换不了就给新类型的空值并提示。 + internal void ChangeType(CollectionRow row, string path, BsonKind kind) + { + BsonValue? current = BsonPath.Get(row.Document, path); + BsonValue converted = BsonEdit.Convert(current, kind, out bool reset); + if (Stage(row, path, converted) && reset) + { + Workspace.Toast(new() { Title = Loc["Edit_ValueReset"], Kind = ToastKind.Warning }); + } + } + + /// 删除字段;数组元素按下标删掉并前移($unset 数组元素只会留下一个 null)。 + internal void DeleteField(CollectionRow row, string path) + { + int dot = path.LastIndexOf('.'); + if (dot > 0 && BsonPath.Get(row.Document, path[..dot]) is BsonArray array + && int.TryParse(path[(dot + 1)..], NumberStyles.None, CultureInfo.InvariantCulture, out int index) && index < array.Count) + { + var copy = (BsonArray)array.DeepClone(); + copy.RemoveAt(index); + Stage(row, path[..dot], copy); + return; + } + Stage(row, path, null); + } + + /// 在数组末尾插入一个元素(类型照第一项;文档元素给同样的键、空值)。 + internal void InsertArrayElement(CollectionRow row, string arrayPath) + { + if (BsonPath.Get(row.Document, arrayPath) is not BsonArray array) + { + return; + } + BsonValue element = array.Count == 0 + ? new BsonString("") + : array[0] is BsonDocument template + ? new BsonDocument(template.Elements.Select(static e => new BsonElement(e.Name, BsonEdit.Empty(BsonKinds.Of(e.Value))))) + : BsonEdit.Empty(BsonKinds.Of(array[0])); + Stage(row, $"{arrayPath}.{array.Count.ToString(CultureInfo.InvariantCulture)}", element); + } + + /// 添加字段(弹一个小对话框问名字、类型、值)。 + internal void PromptAddField(CollectionRow row, string parentPath) + { + if (!EnsureCanWrite()) + { + return; + } + Workspace.ShowDialog(new AddStagedFieldDialogViewModel(Workspace, row.Document, parentPath, (path, value) => Stage(row, path, value))); + } + + // ── 提交 / 撤销 / 冲突 ──────────────────────────────────────────────────── + + /// + /// 提交。删除在「写前确认」打开时要确认(生产连接还要手打集合名);成功后右下角 + /// 「已提交 N 处修改 · 撤销 (9)」;冲突逐份弹冲突对话框;写失败的留在暂存区并报原因。 + /// + internal async Task CommitAsync(CommitScope scope) + { + if (Staging.IsEmpty || _committing || !EnsureCanWrite()) + { + return; + } + IReadOnlyList ops = StagingCommitter.Plan(Staging, scope); + if (ops.Count == 0) + { + return; + } + int deletes = ops.Count(static o => o is DeleteOperation); + if (deletes > 0 && Workspace.Guard.ConfirmWrites) + { + bool confirmed = await Workspace.ConfirmAsync(new() + { + Title = Loc["State_DeleteDocsTitle"], + Message = Loc.Format("State_DeleteDocsBody", deletes, Info.Namespace), + ConfirmLabel = Loc.Format("State_DeleteDocsConfirm", deletes), + IconKey = "Mongo.trash-2", + Danger = true, + TypeToConfirm = Workspace.Guard.IsProduction ? CollectionName : null, + Facts = + [ + new(Loc["State_FactUpdates"], BsonText.Grouped(ops.Count(static o => o is UpdateOperation))), + new(Loc["State_FactInserts"], BsonText.Grouped(ops.Count(static o => o is InsertOperation))), + new(Loc["State_FactDeletes"], BsonText.Grouped(deletes)) + ] + }).ConfigureAwait(true); + if (!confirmed) + { + return; + } + } + _committing = true; + ApplyCommand.RaiseCanExecuteChanged(); + try + { + CommitOutcome outcome = await StagingCommitter.CommitAsync(Collection, Staging, scope, _lifetime.Token).ConfigureAwait(true); + if (outcome.Applied > 0) + { + UndoPlan undo = outcome.Undo; + Workspace.Toast(new() + { + Title = Loc.Format("State_Committed", outcome.Applied), + Detail = CommitDetail(outcome), + Kind = ToastKind.Success, + ActionLabel = undo.Count > 0 ? Loc["State_Undo"] : null, + Action = undo.Count > 0 ? () => UndoAsync(undo) : null, + Duration = TimeSpan.FromSeconds(10) + }); + } + if (outcome.Failures.Count > 0) + { + Workspace.Toast(new() + { + Title = Loc.Format("State_CommitFailed", outcome.Failures.Count), + Detail = outcome.Failures[0].Message, + Kind = ToastKind.Error, + Duration = TimeSpan.FromSeconds(8) + }); + } + await RunQueryAsync(resetPage: false).ConfigureAwait(true); + if (outcome.Conflicts.Count > 0) + { + ShowConflicts(outcome.Conflicts); + } + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Workspace.Toast(new() { Title = Loc.Format("Common_Failed", MongoConnector.Describe(ex)), Kind = ToastKind.Error }); + } + finally + { + _committing = false; + ApplyCommand.RaiseCanExecuteChanged(); + } + } + + /// 1 更新 · 1 插入 · 1 删除 · 12 ms。 + private string CommitDetail(CommitOutcome outcome) + { + var parts = new List(4); + if (outcome.Updated > 0) + { + parts.Add(Loc.Format("State_DetailUpdated", outcome.Updated)); + } + if (outcome.Inserted > 0) + { + parts.Add(Loc.Format("State_DetailInserted", outcome.Inserted)); + } + if (outcome.Deleted > 0) + { + parts.Add(Loc.Format("State_DetailDeleted", outcome.Deleted)); + } + parts.Add(Loc.Format("Common_Ms", Ms(outcome.Elapsed))); + return string.Join(" · ", parts); + } + + /// 撤销刚才那一批(10 秒内):用提交前的原像字段级回滚。 + private async Task UndoAsync(UndoPlan plan) + { + if (!EnsureCanWrite()) + { + return; + } + try + { + (int restored, int skipped) = await plan.ExecuteAsync(Collection, _lifetime.Token).ConfigureAwait(true); + Workspace.Toast(new() + { + Title = skipped == 0 ? Loc.Format("State_UndoDone", restored) : Loc.Format("State_UndoPartial", restored, skipped), + Kind = skipped == 0 ? ToastKind.Success : ToastKind.Warning + }); + await RunQueryAsync(resetPage: false).ConfigureAwait(true); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Workspace.Toast(new() { Title = Loc.Format("Common_Failed", MongoConnector.Describe(ex)), Kind = ToastKind.Error }); + } + } + + /// 逐份弹冲突对话框(处理完一份再弹下一份)。 + private void ShowConflicts(IReadOnlyList conflicts) + { + var queue = new Queue(conflicts); + Next(); + + void Next() + { + if (queue.TryDequeue(out EditConflict? conflict)) + { + Workspace.ShowDialog(new ConflictDialogViewModel(Workspace, conflict, async resolution => + { + await ResolveConflictAsync(conflict, resolution).ConfigureAwait(true); + Next(); + })); + } + } + } + + /// + /// 冲突的三种出路:放弃我的(撤掉暂存、看服务器版本);覆盖(以服务器版本为新原像、我的修改全保留,再提交); + /// 合并(以服务器版本为新原像、只保留勾了「我的」的那几个字段,再提交)。 + /// 覆盖与合并都是**重新走一遍乐观并发**:在我看冲突的这几秒里服务器又变了,照样会再被拦下来。 + /// + private async Task ResolveConflictAsync(EditConflict conflict, ConflictResolution resolution) + { + BsonValue id = conflict.Edit.Id; + switch (resolution.Choice) + { + case ConflictChoice.DiscardMine: + Staging.Discard(id); + await RunQueryAsync(resetPage: false).ConfigureAwait(true); + return; + case ConflictChoice.Overwrite when conflict.Server is not null: + Staging.Rebase(id, conflict.Server, conflict.Edit.Changes.Select(static c => c.Path).ToHashSet(StringComparer.Ordinal)); + break; + case ConflictChoice.Merge when conflict.Server is not null: + Staging.Rebase(id, conflict.Server, resolution.Keep); + break; + default: + return; + } + if (Staging.EditOf(id) is null) + { + await RunQueryAsync(resetPage: false).ConfigureAwait(true); + return; + } + await CommitAsync(new CommitScope(id)).ConfigureAwait(true); + } + + /// 放弃全部(Esc)。不碰服务器,所以给一个「撤销」把暂存区原样恢复。 + private void DiscardAll() + { + if (Staging.IsEmpty) + { + return; + } + StagingArea.Snapshot snapshot = Staging.TakeSnapshot(); + Staging.Clear(); + Workspace.Toast(new() + { + Title = Loc.Format("State_Discarded", snapshot.OperationCount), + Kind = ToastKind.Info, + ActionLabel = Loc["State_Undo"], + Action = () => + { + Staging.Restore(snapshot); + return Task.CompletedTask; + }, + Duration = TimeSpan.FromSeconds(8) + }); + } + + // ── 只读解锁 ───────────────────────────────────────────────────────────── + + /// + /// 「解锁写入 15 分钟」:确认一次后关掉只读,15 分钟后自动恢复。限时而不是一直开着, + /// 是因为"解锁了忘了锁回去"正是生产事故最常见的前奏。 + /// + private async Task UnlockAsync() + { + if (!await Workspace.ConfirmAsync(new() + { + Title = Loc["State_UnlockTitle"], + Message = Loc.Format("State_UnlockBody", Workspace.ConnectionName), + ConfirmLabel = Loc["State_UnlockConfirm"], + IconKey = "Mongo.lock-open", + Danger = Workspace.Guard.IsProduction + }).ConfigureAwait(true)) + { + return; + } + Workspace.Guard.IsReadOnly = false; + _relockTimer?.Stop(); + var timer = new DispatcherTimer { Interval = TimeSpan.FromMinutes(15) }; + timer.Tick += (_, _) => Relock(); + _relockTimer = timer; + timer.Start(); + Workspace.Toast(new() { Title = Loc["State_Unlocked"], Kind = ToastKind.Warning }); + } + + private void Relock() + { + _relockTimer?.Stop(); + _relockTimer = null; + if (!Workspace.Guard.IsReadOnly) + { + Workspace.Guard.IsReadOnly = true; + Workspace.Toast(new() { Title = Loc["State_Relocked"], Kind = ToastKind.Info }); + } + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Collection/CollectionTabViewModel.Query.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/CollectionTabViewModel.Query.cs new file mode 100644 index 0000000..bd11299 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/CollectionTabViewModel.Query.cs @@ -0,0 +1,800 @@ +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Globalization; +using Avalonia.Threading; +using MongoDB.Bson; +using MongoDB.Driver; +using VelaShell.Plugin.Mongo.Bson; +using VelaShell.Plugin.Mongo.Core; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 历史 / 收藏下拉里的一项。 +/// 筛选原文。 +/// 是不是收藏。 +internal sealed record CollectionFilterItem(string Text, bool IsFavorite) +{ + /// 图标。 + public string IconKey => IsFavorite ? "Mongo.star" : "Mongo.history"; + + /// 图标颜色。 + public string IconToken => IsFavorite ? "VelaShellYellow" : "VelaTextTertiary"; +} + +/// 查询栏、分页、长查询与执行计划。 +internal sealed partial class CollectionTabViewModel +{ + /// 长查询卡片出现前的等待:短查询闪一下卡片比不显示更烦人。 + private static readonly TimeSpan LongQueryThreshold = TimeSpan.FromMilliseconds(700); + + private string _filterText; + private string? _filterError; + private IReadOnlyList _filterDiagnostics = []; + private string _projectionText = ""; + private bool _projectionInvalid; + private string _sortText = ""; + private bool _sortInvalid; + private string _skipText = "0"; + private string _limitText; + private bool _skipInvalid; + private bool _limitInvalid; + private IReadOnlyList _hintChoices = []; + private string _selectedHint = ""; + private bool _isFavorite; + private IReadOnlyList _favorites = []; + private IReadOnlyList _history = []; + private int _pageIndex; + private string _pageText = "1"; + private CancellationTokenSource? _queryCts; + private string? _queryComment; + private Stopwatch? _queryWatch; + private DispatcherTimer? _longQueryTimer; + private bool _isLongQuery; + private string _longQueryText = ""; + private long? _opid; + private string _lastRunFilter = ""; + private FindRequest? _lastRequest; + private int _countVersion; + + /// 查找(Ctrl+Enter)。 + public AsyncCommand FindCommand { get; private set; } = null!; + + /// 重置查询栏。 + public AsyncCommand ResetCommand { get; private set; } = null!; + + /// 清除筛选(无结果卡片)。 + public AsyncCommand ClearFilterCommand { get; private set; } = null!; + + /// 执行计划(在新查询标签里跑 explain("executionStats"))。 + public RelayCommand ExplainCommand { get; private set; } = null!; + + /// 收藏 / 取消收藏当前筛选。 + public AsyncCommand ToggleFavoriteCommand { get; private set; } = null!; + + /// 从历史 / 收藏里套用一条。 + public AsyncCommand ApplyHistoryCommand { get; private set; } = null!; + + /// 刷新本页。 + public AsyncCommand RefreshCommand { get; private set; } = null!; + + /// 停止长查询(killOp)。 + public AsyncCommand StopCommand { get; private set; } = null!; + + /// 首页。 + public AsyncCommand FirstPageCommand { get; private set; } = null!; + + /// 上一页。 + public AsyncCommand PreviousPageCommand { get; private set; } = null!; + + /// 下一页。 + public AsyncCommand NextPageCommand { get; private set; } = null!; + + /// 末页。 + public AsyncCommand LastPageCommand { get; private set; } = null!; + + /// 跳到页码框里的那一页。 + public AsyncCommand GoToPageCommand { get; private set; } = null!; + + /// 按列排序(点列头):降序 → 升序 → 不排。 + public AsyncCommand SortByColumnCommand { get; private set; } = null!; + + private void InitializeQueryCommands() + { + FindCommand = new(() => RunQueryAsync(resetPage: true)); + ResetCommand = new(ResetAsync); + ClearFilterCommand = new(() => + { + FilterText = ""; + return RunQueryAsync(resetPage: true); + }); + ExplainCommand = new(OpenExplain); + ToggleFavoriteCommand = new(ToggleFavoriteAsync); + ApplyHistoryCommand = new(item => + { + FilterText = item.Text; + return RunQueryAsync(resetPage: true); + }); + RefreshCommand = new(() => RunQueryAsync(resetPage: false)); + StopCommand = new(StopAsync, () => _isLoading); + FirstPageCommand = new(() => GoToPageAsync(0), () => _pageIndex > 0); + PreviousPageCommand = new(() => GoToPageAsync(_pageIndex - 1), () => _pageIndex > 0); + NextPageCommand = new(() => GoToPageAsync(_pageIndex + 1), () => _pageIndex < PageCount - 1); + LastPageCommand = new(() => GoToPageAsync(PageCount - 1), () => _totalCount is not null && _pageIndex < PageCount - 1); + GoToPageCommand = new(() => + { + if (int.TryParse(_pageText.Replace(",", "", StringComparison.Ordinal), NumberStyles.Integer, CultureInfo.InvariantCulture, out int page)) + { + return GoToPageAsync(Math.Clamp(page - 1, 0, Math.Max(0, PageCount - 1))); + } + PageText = (_pageIndex + 1).ToString(CultureInfo.InvariantCulture); + return Task.CompletedTask; + }); + SortByColumnCommand = new(column => + { + int next = column.SortDirection switch { 0 => -1, -1 => 1, _ => 0 }; + SortText = next == 0 ? "" : $"{{ {BsonText.FieldName(column.Name)}: {next.ToString(CultureInfo.InvariantCulture)} }}"; + return RunQueryAsync(resetPage: true); + }); + } + + // ── 查询栏 ─────────────────────────────────────────────────────────────── + + /// 筛选原文(mongosh 写法)。外壳读它判断要不要 。 + public string FilterText + { + get => _filterText; + set + { + if (SetProperty(ref _filterText, value ?? "")) + { + ValidateFilter(); + IsFavorite = _favorites.Contains(_filterText.Trim()); + RaisePropertyChanged(nameof(EchoText)); + } + } + } + + /// 外壳从别处带着筛选打开这个标签(对象列表、树的「按此值筛选」):换上并重查。 + public void ApplyFilter(string filter) + { + FilterText = filter; + _ = RunQueryAsync(resetPage: true); + } + + /// 筛选的语法错误(标红 + 提示);没有为 。 + public string? FilterError + { + get => _filterError; + private set + { + if (SetProperty(ref _filterError, value)) + { + RaisePropertyChanged(nameof(HasFilterError)); + } + } + } + + /// 有语法错误。 + public bool HasFilterError => _filterError is not null; + + /// 编辑器上的诊断(红色波浪线)。 + public IReadOnlyList FilterDiagnostics + { + get => _filterDiagnostics; + private set => SetProperty(ref _filterDiagnostics, value); + } + + /// 投影。 + public string ProjectionText + { + get => _projectionText; + set + { + if (SetProperty(ref _projectionText, value ?? "")) + { + ProjectionInvalid = !ShellJson.TryParseDocument(_projectionText, out _, out _); + RaisePropertyChanged(nameof(EchoText)); + } + } + } + + /// 投影写错了。 + public bool ProjectionInvalid + { + get => _projectionInvalid; + private set => SetProperty(ref _projectionInvalid, value); + } + + /// 排序。 + public string SortText + { + get => _sortText; + set + { + if (SetProperty(ref _sortText, value ?? "")) + { + SortInvalid = !ShellJson.TryParseDocument(_sortText, out _, out _); + RaisePropertyChanged(nameof(EchoText)); + } + } + } + + /// 排序写错了。 + public bool SortInvalid + { + get => _sortInvalid; + private set => SetProperty(ref _sortInvalid, value); + } + + /// 跳过。 + public string SkipText + { + get => _skipText; + set + { + if (SetProperty(ref _skipText, value ?? "")) + { + SkipInvalid = ParseCount(_skipText, 0) is null; + } + } + } + + /// 跳过写错了。 + public bool SkipInvalid + { + get => _skipInvalid; + private set => SetProperty(ref _skipInvalid, value); + } + + /// 限制(每页行数)。 + public string LimitText + { + get => _limitText; + set + { + if (SetProperty(ref _limitText, value ?? "")) + { + LimitInvalid = ParseCount(_limitText, 1) is null; + } + } + } + + /// 限制写错了。 + public bool LimitInvalid + { + get => _limitInvalid; + private set => SetProperty(ref _limitInvalid, value); + } + + /// 索引提示的选项(第一项「自动」)。 + public IReadOnlyList HintChoices + { + get => _hintChoices.Count == 0 ? [Loc["Cw_HintAuto"]] : _hintChoices; + private set => SetProperty(ref _hintChoices, value); + } + + /// 选中的索引提示。 + public string SelectedHint + { + get => _selectedHint.Length == 0 ? Loc["Cw_HintAuto"] : _selectedHint; + set => SetProperty(ref _selectedHint, value == Loc["Cw_HintAuto"] ? "" : value ?? ""); + } + + /// 当前筛选是不是收藏(星标填色)。 + public bool IsFavorite + { + get => _isFavorite; + private set => SetProperty(ref _isFavorite, value); + } + + /// 历史下拉的内容:收藏在前,历史在后。 + public IReadOnlyList HistoryItems => + [ + .. _favorites.Select(static f => new CollectionFilterItem(f, true)), + .. _history.Where(h => !_favorites.Contains(h)).Select(static h => new CollectionFilterItem(h, false)) + ]; + + /// 有没有历史。 + public bool HasHistory => _favorites.Count > 0 || _history.Count > 0; + + /// 筛选非空(决定空态是「空集合」还是「无结果」)。 + public bool HasActiveFilter => _lastRunFilter.Length > 0 && _lastRunFilter != "{}"; + + /// 最近一次执行的筛选(无结果卡片里回显)。 + public string LastRunFilter => _lastRunFilter; + + /// 当前查询(导出向导「导出当前查询结果」用)。 + public FindRequest? CurrentRequest => _lastRequest; + + /// 解析好的排序(列头的箭头用);写错为 。 + internal BsonDocument? ParsedSort => ShellJson.TryParseDocument(_sortText, out BsonDocument doc, out _) ? doc : null; + + // ── 分页 ───────────────────────────────────────────────────────────────── + + /// 总数(有筛选用 countDocuments,无筛选用 estimatedDocumentCount);没数完为 。 + public long? TotalCount => _totalCount; + + /// 每页行数。 + public int PageSize => ParseCount(_limitText, 1) ?? Workspace.Connection.Settings.PageSize; + + /// 基础跳过数。 + public int BaseSkip => ParseCount(_skipText, 0) ?? 0; + + /// 总页数(总数未知时至少当前页 + 1)。 + public int PageCount => _totalCount is { } total + ? (int)Math.Max(1, Math.Ceiling(Math.Max(0, total - BaseSkip) / (double)PageSize)) + : _pageIndex + (_documents.Count >= PageSize ? 2 : 1); + + /// 页码框。 + public string PageText + { + get => _pageText; + set => SetProperty(ref _pageText, value ?? ""); + } + + /// / 25,699 页。 + public string PageCountText => _totalCount is null ? Loc["Cw_PageCountUnknown"] : Loc.Format("Cw_PageCount", BsonText.Grouped(PageCount)); + + /// 1–50 / 1,284,902。 + public string RangeText + { + get + { + long first = BaseSkip + (long)_pageIndex * PageSize; + string total = _totalCount is { } t ? BsonText.Grouped(t) : "…"; + return _documents.Count == 0 + ? $"0 / {total}" + : $"{BsonText.Grouped(first + 1)}–{BsonText.Grouped(first + _documents.Count)} / {total}"; + } + } + + /// 底栏回显的 mongosh 语句(筛选缩成 {…})。 + public string EchoText => BuildStatement(abbreviate: true); + + // ── 长查询 ─────────────────────────────────────────────────────────────── + + /// 长查询卡片(设计稿 22「正在查询 shop.orders…」)。 + public bool IsLongQuery + { + get => _isLongQuery; + private set => SetProperty(ref _isLongQuery, value); + } + + /// 长查询卡片标题。 + public string LongQueryTitle => Loc.Format("State_LongQueryTitle", Info.Namespace); + + /// 已运行 6.2 s · 服务端 opid 8812045。 + public string LongQueryText + { + get => _longQueryText; + private set => SetProperty(ref _longQueryText, value); + } + + // ── 执行 ───────────────────────────────────────────────────────────────── + + /// + /// 执行查询。 为真回到第一页(改了筛选 / 排序时); + /// 刷新、翻页留在当前页。 + /// + public async Task RunQueryAsync(bool resetPage) + { + if (_disposed) + { + return; + } + if (!TryBuildRequest(resetPage ? 0 : _pageIndex, out FindRequest? request)) + { + return; + } + if (resetPage) + { + SetPage(0); + } + _queryCts?.Cancel(); + var cts = CancellationTokenSource.CreateLinkedTokenSource(_lifetime.Token); + _queryCts = cts; + _queryComment = request.Comment; + _opid = null; + IsLoading = true; + StartLongQueryTimer(); + Stopwatch watch = StartWatch(); + try + { + var options = new FindOptions + { + Projection = request.Projection, + Sort = request.Sort, + Skip = request.Skip, + Limit = request.Limit, + Comment = request.Comment, + MaxTime = request.MaxTimeMs > 0 ? TimeSpan.FromMilliseconds(request.MaxTimeMs) : null + }; + if (request.Hint is not null) + { + options.Hint = request.Hint; + } + using IAsyncCursor cursor = await Collection.FindAsync(request.Filter, options, cts.Token).ConfigureAwait(true); + List documents = await cursor.ToListAsync(cts.Token).ConfigureAwait(true); + watch.Stop(); + if (!ReferenceEquals(_queryCts, cts)) + { + return; + } + _elapsed = watch.Elapsed; + _loadedOnce = true; + _lastRequest = request; + _lastRunFilter = _filterText.Trim(); + ApplyResults(documents, request.Skip + 1); + RaisePropertiesChanged(nameof(Elapsed), nameof(LastRunFilter), nameof(HasActiveFilter), nameof(CurrentRequest), + nameof(EchoText), nameof(RangeText), nameof(PageCountText)); + UpdateStatus(); + _ = CountAsync(request); + _ = ExplainPlanAsync(request); + if (HasActiveFilter) + { + _ = RememberFilterAsync(_lastRunFilter); + } + } + catch (OperationCanceledException) + { + // 被新查询顶掉或用户点了停止:停止那条路自己报。 + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + if (ex is MongoCommandException { Code: 2 or 9 } command) + { + // BadValue / FailedToParse:多半是筛选写得服务器不认($foo 之类),标到筛选框上。 + FilterError = command.ErrorMessage; + } + Workspace.Toast(new() { Title = Loc.Format("Common_Failed", MongoConnector.Describe(ex)), Kind = ToastKind.Error }); + } + finally + { + if (ReferenceEquals(_queryCts, cts)) + { + IsLoading = false; + StopLongQueryTimer(); + _queryCts = null; + } + cts.Dispose(); + } + } + + /// 按查询栏拼一次查询;有语法错误就标红并返回 。 + private bool TryBuildRequest(int pageIndex, out FindRequest request) + { + request = null!; + ValidateFilter(); + if (_filterError is not null || _projectionInvalid || _sortInvalid || _skipInvalid || _limitInvalid) + { + return false; + } + BsonDocument filter = ShellJson.ParseDocument(_filterText); + BsonDocument projection = ShellJson.ParseDocument(_projectionText); + BsonDocument sort = ShellJson.ParseDocument(_sortText); + request = new FindRequest + { + Database = Database, + Collection = CollectionName, + Filter = filter, + Projection = projection.ElementCount > 0 ? projection : null, + Sort = sort.ElementCount > 0 ? sort : null, + Skip = BaseSkip + pageIndex * PageSize, + Limit = PageSize, + Hint = _selectedHint.Length > 0 ? new BsonString(_selectedHint) : null, + MaxTimeMs = Workspace.Connection.Settings.MaxTimeMs, + Comment = "velashell:" + Guid.NewGuid().ToString("N")[..12] + }; + return true; + } + + /// 边打字边做语法检查(含"带点号的裸键要加引号"诊断)。 + private void ValidateFilter() + { + string text = _filterText; + var diagnostics = new List(); + foreach (ShellDiagnostic diagnostic in ShellJson.Diagnose(text)) + { + diagnostics.Add(new EditorDiagnostic(diagnostic.Offset, diagnostic.Length, + Loc.Format(diagnostic.MessageKey, diagnostic.Argument), DiagnosticSeverity.Error, diagnostic.Fix, Loc["Cw_FixHint"])); + } + string? error = diagnostics.FirstOrDefault()?.Message; + if (error is null && !string.IsNullOrWhiteSpace(text)) + { + try + { + _ = ShellJson.ParseDocument(text); + } + catch (ShellJsonException ex) + { + error = Loc["Cw_FilterSyntax"]; + int at = ex.Offset >= 0 ? Math.Min(ex.Offset, Math.Max(0, text.Length - 1)) : 0; + diagnostics.Add(new EditorDiagnostic(at, ex.Offset >= 0 ? 1 : text.Length, error)); + } + } + FilterError = error; + FilterDiagnostics = diagnostics; + } + + private static int? ParseCount(string text, int min) => + int.TryParse(text.Trim().Replace(",", "", StringComparison.Ordinal), NumberStyles.Integer, CultureInfo.InvariantCulture, out int value) + && value >= min && value <= 1_000_000 + ? value + : string.IsNullOrWhiteSpace(text) && min == 0 ? 0 : null; + + private async Task ResetAsync() + { + FilterText = ""; + ProjectionText = ""; + SortText = ""; + SkipText = "0"; + LimitText = Workspace.Connection.Settings.PageSize.ToString(CultureInfo.InvariantCulture); + SelectedHint = ""; + await RunQueryAsync(resetPage: true).ConfigureAwait(true); + } + + private async Task GoToPageAsync(int page) + { + if (page < 0 || page == _pageIndex && !_isLoading && _documents.Count > 0) + { + return; + } + SetPage(page); + await RunQueryAsync(resetPage: false).ConfigureAwait(true); + } + + private void SetPage(int page) + { + _pageIndex = Math.Max(0, page); + PageText = (_pageIndex + 1).ToString(CultureInfo.InvariantCulture); + RaisePropertiesChanged(nameof(RangeText), nameof(PageCountText)); + RaisePagingCanExecute(); + } + + private void RaisePagingCanExecute() + { + FirstPageCommand.RaiseCanExecuteChanged(); + PreviousPageCommand.RaiseCanExecuteChanged(); + NextPageCommand.RaiseCanExecuteChanged(); + LastPageCommand.RaiseCanExecuteChanged(); + } + + /// + /// 数总数:有筛选用 countDocuments(要扫描,给 5 秒上限),没有筛选用 estimatedDocumentCount + /// (元数据,O(1))—— 百万级集合上对空筛选做 countDocuments 会白白扫一遍。 + /// + private async Task CountAsync(FindRequest request) + { + int version = ++_countVersion; + _totalCount = null; + RaisePropertiesChanged(nameof(TotalCount), nameof(PageCountText), nameof(RangeText)); + try + { + long? count = null; + if (request.Filter.ElementCount == 0) + { + count = await Workspace.Connection.EstimatedCountAsync(Database, CollectionName, _lifetime.Token).ConfigureAwait(true); + } + count ??= await Collection.CountDocumentsAsync(request.Filter, + new CountOptions { MaxTime = TimeSpan.FromSeconds(5), Hint = request.Hint }, _lifetime.Token).ConfigureAwait(true); + if (version != _countVersion) + { + return; + } + _totalCount = count; + } + catch (Exception ex) when (ex is MongoException or TimeoutException or OperationCanceledException) + { + Workspace.Log.Info($"Counting {Info.Namespace} failed: {ex.Message}"); + } + RaisePropertiesChanged(nameof(TotalCount), nameof(PageCountText), nameof(RangeText), nameof(PageCount)); + RaisePagingCanExecute(); + } + + /// 取一次 explain("queryPlanner") 的获胜阶段给状态栏(不跑查询本身,开销很小)。 + private async Task ExplainPlanAsync(FindRequest request) + { + try + { + var find = new BsonDocument { { "find", CollectionName }, { "filter", request.Filter } }; + if (request.Sort is not null) + { + find["sort"] = request.Sort; + } + if (request.Projection is not null) + { + find["projection"] = request.Projection; + } + if (request.Hint is not null) + { + find["hint"] = request.Hint; + } + find["skip"] = request.Skip; + find["limit"] = request.Limit; + BsonDocument reply = await Workspace.Connection.RunCommandAsync(Database, + new BsonDocument { { "explain", find }, { "verbosity", "queryPlanner" } }, _lifetime.Token).ConfigureAwait(true); + PlanSummary = SummarizePlan(reply); + } + catch (Exception ex) when (ex is MongoException or TimeoutException or OperationCanceledException) + { + PlanSummary = ""; + } + } + + // ── 长查询:计时、opid、killOp ───────────────────────────────────────────── + + private void StartLongQueryTimer() + { + _queryWatch = Stopwatch.StartNew(); + _longQueryTimer ??= new DispatcherTimer(TimeSpan.FromMilliseconds(100), DispatcherPriority.Background, (_, _) => OnLongQueryTick()); + _longQueryTimer.Start(); + } + + private void StopLongQueryTimer() + { + _longQueryTimer?.Stop(); + _queryWatch?.Stop(); + IsLongQuery = false; + } + + private void OnLongQueryTick() + { + if (_queryWatch is null || !_isLoading) + { + return; + } + TimeSpan elapsed = _queryWatch.Elapsed; + if (elapsed < LongQueryThreshold) + { + return; + } + if (!_isLongQuery) + { + IsLongQuery = true; + _ = FindOpidAsync(_queryComment); + } + string seconds = elapsed.TotalSeconds.ToString("0.0", CultureInfo.InvariantCulture); + LongQueryText = _opid is { } opid + ? Loc.Format("State_LongQueryOpid", seconds, opid.ToString(CultureInfo.InvariantCulture)) + : Loc.Format("State_LongQueryElapsed", seconds); + } + + /// 按 comment 在 currentOp 里认出这次查询的 opid(卡片上给 DBA 看的那串数)。 + private async Task FindOpidAsync(string? comment) + { + if (comment is null) + { + return; + } + try + { + BsonDocument reply = await Workspace.Connection.RunCommandAsync("admin", new BsonDocument + { + { "currentOp", true }, + { "command.comment", comment } + }).ConfigureAwait(true); + if (reply.GetValue("inprog", new BsonArray()).AsBsonArray.FirstOrDefault() is BsonDocument op + && op.TryGetValue("opid", out BsonValue opid) && opid.IsNumeric) + { + _opid = opid.ToInt64(); + } + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Workspace.Log.Info($"currentOp failed: {ex.Message}"); + } + } + + /// + /// 停止:先 killOp(服务器那边真的停下),再放弃本地等待。只取消令牌的话, + /// 客户端不等了,服务器上的扫描照跑不误 —— 在生产库上那正是要拦的那种查询。 + /// + private async Task StopAsync() + { + string? comment = _queryComment; + CancellationTokenSource? cts = _queryCts; + try + { + if (comment is not null) + { + int killed = await Workspace.Connection.KillByCommentAsync(comment).ConfigureAwait(true); + Workspace.Toast(new() + { + Title = killed > 0 ? Loc["State_Killed"] : Loc["State_Cancelled"], + Kind = ToastKind.Info + }); + } + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Workspace.Toast(new() { Title = Loc.Format("Common_Failed", MongoConnector.Describe(ex)), Kind = ToastKind.Error }); + } + finally + { + cts?.Cancel(); + } + } + + // ── 历史 / 收藏 / 索引 ───────────────────────────────────────────────────── + + private async Task LoadHistoryAsync() + { + _favorites = await Workspace.Store.LoadFilterFavoritesAsync(Workspace.ConnectionKey, Info.Namespace).ConfigureAwait(true); + _history = await Workspace.Store.LoadFilterHistoryAsync(Workspace.ConnectionKey, Info.Namespace).ConfigureAwait(true); + IsFavorite = _favorites.Contains(_filterText.Trim()); + RaisePropertiesChanged(nameof(HistoryItems), nameof(HasHistory)); + } + + private async Task RememberFilterAsync(string filter) + { + await Workspace.Store.AddFilterHistoryAsync(Workspace.ConnectionKey, Info.Namespace, filter).ConfigureAwait(true); + _history = [filter, .. _history.Where(h => h != filter).Take(29)]; + RaisePropertiesChanged(nameof(HistoryItems), nameof(HasHistory)); + } + + private async Task ToggleFavoriteAsync() + { + string filter = _filterText.Trim(); + if (filter.Length == 0 || filter == "{}") + { + return; + } + _favorites = _favorites.Contains(filter) ? [.. _favorites.Where(f => f != filter)] : [filter, .. _favorites]; + IsFavorite = _favorites.Contains(filter); + RaisePropertiesChanged(nameof(HistoryItems), nameof(HasHistory)); + await Workspace.Store.SaveFilterFavoritesAsync(Workspace.ConnectionKey, Info.Namespace, _favorites).ConfigureAwait(true); + } + + private async Task LoadIndexesAsync() + { + try + { + IReadOnlyList indexes = await Workspace.Connection.ListIndexesAsync(Database, CollectionName, _lifetime.Token).ConfigureAwait(true); + HintChoices = [Loc["Cw_HintAuto"], .. indexes.Select(static i => i.GetValue("name", "").AsString).Where(static n => n.Length > 0)]; + RaisePropertyChanged(nameof(SelectedHint)); + } + catch (Exception ex) when (ex is MongoException or TimeoutException or OperationCanceledException) + { + Workspace.Log.Info($"Listing indexes of {Info.Namespace} failed: {ex.Message}"); + } + } + + // ── 语句 ───────────────────────────────────────────────────────────────── + + /// + /// 拼 mongosh 语句:db.orders.find({…}).sort({ createdAt: -1 }).limit(50)。 + /// 底栏回显把筛选缩成 {…}(那里放不下,完整的在筛选框里);执行计划用完整的。 + /// + internal string BuildStatement(bool abbreviate, int? skipOverride = null) + { + string filter = string.IsNullOrWhiteSpace(_filterText) ? "{}" : abbreviate ? "{…}" : _filterText.Trim(); + string projection = string.IsNullOrWhiteSpace(_projectionText) ? "" : ", " + _projectionText.Trim(); + var text = new System.Text.StringBuilder() + .Append("db.").Append(MongoWorkspaceViewModel.ShellCollectionRef(CollectionName)) + .Append(".find(").Append(filter).Append(projection).Append(')'); + if (!string.IsNullOrWhiteSpace(_sortText)) + { + text.Append(".sort(").Append(_sortText.Trim()).Append(')'); + } + if (_selectedHint.Length > 0) + { + text.Append(".hint(").Append(BsonText.Quote(_selectedHint)).Append(')'); + } + int skip = skipOverride ?? (BaseSkip + _pageIndex * PageSize); + if (skip > 0) + { + text.Append(".skip(").Append(skip.ToString(CultureInfo.InvariantCulture)).Append(')'); + } + text.Append(".limit(").Append(PageSize.ToString(CultureInfo.InvariantCulture)).Append(')'); + return text.ToString(); + } + + private void OpenExplain() + { + if (_filterError is not null) + { + return; + } + Workspace.OpenQuery(Database, BuildStatement(abbreviate: false) + ".explain(\"executionStats\")", run: true); + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Collection/CollectionTabViewModel.Views.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/CollectionTabViewModel.Views.cs new file mode 100644 index 0000000..dc50639 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/CollectionTabViewModel.Views.cs @@ -0,0 +1,180 @@ +using System.Collections.ObjectModel; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 树视图与 JSON 卡片:同一页文档的另外两种画法。 +internal sealed partial class CollectionTabViewModel +{ + private readonly HashSet _treeExpanded = new(StringComparer.Ordinal); + private ObservableCollection _treeRows = []; + private DocTreeRow? _selectedTreeRow; + private ObservableCollection _cards = []; + private JsonCardViewModel? _focusedCard; + private bool _inlineTypeHints = true; + private bool _formatOnSave = true; + + // ── 树 ─────────────────────────────────────────────────────────────────── + + /// 树视图的可见行。 + public ObservableCollection TreeRows + { + get => _treeRows; + private set => SetProperty(ref _treeRows, value); + } + + /// 树视图选中的行。 + public DocTreeRow? SelectedTreeRow + { + get => _selectedTreeRow; + set + { + if (SetProperty(ref _selectedTreeRow, value) && value is not null && !ReferenceEquals(value.Row, _selectedRow)) + { + SelectedRow = value.Row; + } + } + } + + /// 展开 / 收起一行。 + internal void ToggleTree(DocTreeRow row) + { + if (!row.IsExpandable) + { + return; + } + string key = DocTreeBuilder.ExpansionKey(row.Row, row.Path); + if (!_treeExpanded.Remove(key)) + { + _treeExpanded.Add(key); + } + RebuildTree(); + } + + /// 展开一份文档的某个路径(及其祖先)—— 截图与"定位到字段"用。 + internal void ExpandTreePath(CollectionRow row, string path) + { + _treeExpanded.Add(DocTreeBuilder.ExpansionKey(row, "")); + string[] segments = path.Split('.', StringSplitOptions.RemoveEmptyEntries); + for (int i = 1; i <= segments.Length; i++) + { + _treeExpanded.Add(DocTreeBuilder.ExpansionKey(row, string.Join('.', segments[..i]))); + } + RebuildTree(); + } + + /// 重建树的可见行(保持选中)。只在树视图可见时做实事 —— 网格模式下不必为它付钱。 + internal void RebuildTree() + { + if (_viewMode != CollectionViewMode.Tree) + { + return; + } + (CollectionRow? keepRow, string? keepPath) = (_selectedTreeRow?.Row, _selectedTreeRow?.Path); + List rows = DocTreeBuilder.Build(_rows, _treeExpanded, Staging, Loc); + TreeRows = new ObservableCollection(rows); + _selectedTreeRow = rows.FirstOrDefault(r => ReferenceEquals(r.Row, keepRow) && r.Path == keepPath) + ?? rows.FirstOrDefault(r => ReferenceEquals(r.Row, _selectedRow) && r.IsDocument); + RaisePropertyChanged(nameof(SelectedTreeRow)); + } + + // ── JSON 卡片 ───────────────────────────────────────────────────────────── + + /// JSON 视图的卡片(一份文档一张)。 + public ObservableCollection Cards + { + get => _cards; + private set => SetProperty(ref _cards, value); + } + + /// 大纲面板看的那张卡片(编辑中的优先,否则跟着选中行)。 + public JsonCardViewModel? FocusedCard + { + get => _focusedCard; + set + { + if (SetProperty(ref _focusedCard, value)) + { + value?.RefreshOutline(); + RaisePropertyChanged(nameof(OutlineTitle)); + if (value is not null && !ReferenceEquals(value.Row, _selectedRow)) + { + SelectedRow = value.Row; + } + } + } + } + + /// 大纲头部右侧的「文档 #4」。 + public string OutlineTitle => _focusedCard is { } card ? Loc.Format("Cw_OutlineDoc", card.Row.NumberText) : ""; + + /// 行内类型提示(编辑底栏显示光标所在字段的类型)。 + public bool InlineTypeHints + { + get => _inlineTypeHints; + set + { + if (SetProperty(ref _inlineTypeHints, value)) + { + _focusedCard?.UpdateCaret(); + } + } + } + + /// 保存前自动格式化(「更新文档」先把文本重排成标准缩进)。 + public bool FormatOnSave + { + get => _formatOnSave; + set => SetProperty(ref _formatOnSave, value); + } + + /// 重建卡片(只在 JSON 视图可见时)。正在编辑的卡片保留,免得刷新把用户敲了一半的文本冲掉。 + internal void RebuildCards() + { + if (_viewMode != CollectionViewMode.Json) + { + return; + } + Dictionary editing = _cards.Where(static c => c.IsEditing).ToDictionary(static c => c.Row); + var cards = new List(_rows.Count); + foreach (CollectionRow row in _rows) + { + JsonCardViewModel? kept = editing.GetValueOrDefault(row) + ?? editing.Values.FirstOrDefault(c => c.Row.Id is { } id && id.Equals(row.Id)); + cards.Add(kept is not null && ReferenceEquals(kept.Row, row) ? kept : new JsonCardViewModel(this, row)); + } + Cards = new ObservableCollection(cards); + SyncCardSelection(); + } + + /// 选中行变了:大纲跟着换到那张卡片(除非有卡片正在编辑)。 + private void SyncCardSelection() + { + if (_viewMode != CollectionViewMode.Json) + { + return; + } + JsonCardViewModel? editing = _cards.FirstOrDefault(static c => c.IsEditing); + JsonCardViewModel? target = editing ?? _cards.FirstOrDefault(c => ReferenceEquals(c.Row, _selectedRow)) ?? _cards.FirstOrDefault(); + if (!ReferenceEquals(target, _focusedCard)) + { + _focusedCard = target; + target?.RefreshOutline(); + RaisePropertiesChanged(nameof(FocusedCard), nameof(OutlineTitle)); + } + } + + /// 一张卡片进入 / 退出编辑(大纲转去看它)。 + internal void OnCardEditingChanged(JsonCardViewModel card) + { + if (card.IsEditing) + { + foreach (JsonCardViewModel other in _cards.Where(c => c.IsEditing && !ReferenceEquals(c, card)).ToList()) + { + other.CancelEdit(); + } + FocusedCard = card; + } + SyncCardSelection(); + RaisePropertyChanged(nameof(PendingText)); + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Collection/CollectionTabViewModel.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/CollectionTabViewModel.cs new file mode 100644 index 0000000..275af8f --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/CollectionTabViewModel.cs @@ -0,0 +1,723 @@ +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Globalization; +using Avalonia.Threading; +using MongoDB.Bson; +using MongoDB.Driver; +using VelaShell.Plugin.Mongo.Bson; +using VelaShell.Plugin.Mongo.Core; +using VelaShell.Plugin.Mongo.Staging; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 集合工作台(设计稿 01 网格 / 02 JSON / 13 树)。 +/// +/// 三种视图看的是**同一页文档**与**同一个暂存区**:在网格里改一格、在树里改一个嵌套字段、 +/// 在 JSON 卡片里改一段,都落到 ,底栏的「待提交」与标签上的橙点只认它。 +/// 切视图不重查,只是换一种画法。 +/// +/// 分部文件:.Query 查询栏与分页,.Edit 暂存与提交,.Views 树 / JSON / 检查器的联动。 +/// +internal sealed partial class CollectionTabViewModel : WorkspaceTab +{ + private CollectionInfo _info; + private CollectionViewMode _viewMode = CollectionViewMode.Grid; + private IReadOnlyList _columns = []; + private ObservableCollection _rows = []; + private List _documents = []; + private CollectionRow? _selectedRow; + private IReadOnlyList _selectedRows = []; + private CollectionColumn? _currentColumn; + private EjsonMode _ejson; + private bool _isLoading; + private bool _loadedOnce; + private long? _totalCount; + private TimeSpan _elapsed; + private string _planSummary = ""; + private CollectionSample _sample = CollectionSample.Empty; + private Task? _sampleTask; + private CollectionStats? _stats; + private bool _disposed; + + /// 构造(外壳在用的签名,保持不变)。 + /// 外壳服务。 + /// 集合信息(对象树没加载到它时是一份占位, 里重探)。 + /// 打开时带的筛选(对象列表「按此值筛选」之类)。 + public CollectionTabViewModel(IMongoWorkspace workspace, CollectionInfo info, string? filter) + : base(workspace) + { + _info = info; + Title = info.Name; + Scope = "@" + info.Database; + _ejson = workspace.Connection.Settings.Ejson; + _filterText = filter ?? ""; + _limitText = workspace.Connection.Settings.PageSize.ToString(CultureInfo.InvariantCulture); + Staging.Changed += OnStagingChanged; + workspace.Guard.Changed += OnGuardChanged; + Inspector = new DocInspectorViewModel(this); + InitializeQueryCommands(); + InitializeEditCommands(); + ValidateFilter(); + } + + /// 集合信息。 + public CollectionInfo Info => _info; + + /// 库名。 + public string Database => _info.Database; + + /// 集合名。 + public string CollectionName => _info.Name; + + /// + public override TabKind Kind => TabKind.Collection; + + /// + public override string Key => $"collection:{Info.Namespace}"; + + /// + public override string IconKey => Info.Kind == CollectionKind.View ? "Mongo.eye" : "Mongo.table-2"; + + /// + public override string IconToken => "VelaInfo"; + + /// 暂存区。 + public StagingArea Staging { get; } = new(); + + /// 右侧检查器。 + public DocInspectorViewModel Inspector { get; } + + /// 驱动集合。 + internal IMongoCollection Collection => Workspace.Connection.Collection(Database, CollectionName); + + // ── 视图 ─────────────────────────────────────────────────────────────── + + /// 当前视图。 + public CollectionViewMode ViewMode + { + get => _viewMode; + set + { + if (SetProperty(ref _viewMode, value)) + { + RaisePropertiesChanged(nameof(IsGridMode), nameof(IsTreeMode), nameof(IsJsonMode), nameof(PendingText)); + if (value == CollectionViewMode.Tree) + { + RebuildTree(); + } + else if (value == CollectionViewMode.Json) + { + RebuildCards(); + } + } + } + } + + /// 上一次右侧面板是开着还是收着:新开的集合标签照这个来(Navicat 也记着)。 + private static bool s_sidePanelVisible = true; + + private bool _isSidePanelVisible = s_sidePanelVisible; + + /// + /// 数据区右侧的面板(网格的文档检查器、JSON 视图的大纲)开着吗。底栏右下角那颗按钮切换它; + /// 收起之后数据区占满整宽,展开时回到收起前拖到的宽度。 + /// + public bool IsSidePanelVisible + { + get => _isSidePanelVisible; + set + { + if (SetProperty(ref _isSidePanelVisible, value)) + { + s_sidePanelVisible = value; + RaisePropertyChanged(nameof(SidePanelTip)); + } + } + } + + /// 按钮的提示:「隐藏右侧面板」/「显示右侧面板」。 + public string SidePanelTip => Loc[_isSidePanelVisible ? "Cw_HideSidePanel" : "Cw_ShowSidePanel"]; + + /// 收起 / 展开右侧面板。 + public RelayCommand ToggleSidePanelCommand => _toggleSidePanel ??= new RelayCommand(() => IsSidePanelVisible = !IsSidePanelVisible); + + private RelayCommand? _toggleSidePanel; + + /// 网格视图(分段按钮双向绑定)。 + public bool IsGridMode + { + get => _viewMode == CollectionViewMode.Grid; + set + { + if (value) + { + ViewMode = CollectionViewMode.Grid; + } + } + } + + /// 树视图。 + public bool IsTreeMode + { + get => _viewMode == CollectionViewMode.Tree; + set + { + if (value) + { + ViewMode = CollectionViewMode.Tree; + } + } + } + + /// JSON 视图。 + public bool IsJsonMode + { + get => _viewMode == CollectionViewMode.Json; + set + { + if (value) + { + ViewMode = CollectionViewMode.Json; + } + } + } + + /// 扩展 JSON 写法(JSON 视图大纲里的「显示」分段、复制为 JSON)。 + public EjsonMode Ejson + { + get => _ejson; + set + { + if (SetProperty(ref _ejson, value)) + { + RaisePropertiesChanged(nameof(IsShellMode), nameof(IsRelaxedMode), nameof(IsCanonicalMode)); + foreach (JsonCardViewModel card in Cards) + { + card.Refresh(); + } + Inspector.RefreshJson(); + } + } + } + + /// Shell 写法。 + public bool IsShellMode + { + get => _ejson == EjsonMode.Shell; + set + { + if (value) + { + Ejson = EjsonMode.Shell; + } + } + } + + /// Relaxed 写法。 + public bool IsRelaxedMode + { + get => _ejson == EjsonMode.Relaxed; + set + { + if (value) + { + Ejson = EjsonMode.Relaxed; + } + } + } + + /// Canonical 写法。 + public bool IsCanonicalMode + { + get => _ejson == EjsonMode.Canonical; + set + { + if (value) + { + Ejson = EjsonMode.Canonical; + } + } + } + + // ── 网格数据 ───────────────────────────────────────────────────────────── + + /// 列。 + public IReadOnlyList Columns + { + get => _columns; + private set + { + if (SetProperty(ref _columns, value) && _drill is null) + { + RaisePropertyChanged(nameof(GridColumns)); + } + } + } + + /// 行(本页文档 + 暂存的新文档)。 + public ObservableCollection Rows + { + get => _rows; + private set + { + if (SetProperty(ref _rows, value) && _drill is null) + { + RaisePropertyChanged(nameof(GridRows)); + } + } + } + + /// 本页从服务器读到的文档。 + public IReadOnlyList Documents => _documents; + + /// 选中的行(焦点行;检查器看它)。 + public CollectionRow? SelectedRow + { + get => _selectedRow; + set + { + CollectionRow? previous = _selectedRow; + if (!SetProperty(ref _selectedRow, value)) + { + return; + } + if (_drill is null) + { + UpdateCurrentCell(previous, value); + RaisePropertyChanged(nameof(GridSelectedRow)); + } + Inspector.Load(value); + SyncCardSelection(); + RaisePropertiesChanged(nameof(HasSelection)); + } + } + + /// 多选的全部行(工具栏删除 / 复制为 JSON 作用于它们)。 + public IReadOnlyList SelectedRows + { + get => _selectedRows.Count > 0 ? _selectedRows : _selectedRow is { } one ? [one] : []; + set + { + _selectedRows = value; + RaisePropertyChanged(nameof(HasSelection)); + } + } + + /// 有没有选中。 + public bool HasSelection => _selectedRow is not null; + + /// 当前列(键盘编辑落在哪一格)。 + public CollectionColumn? CurrentColumn + { + get => _currentColumn; + set + { + if (ReferenceEquals(_currentColumn, value)) + { + return; + } + _currentColumn = value; + RaisePropertyChanged(); + if (GridSelectedRow?.Cells is { } cells) + { + foreach (CollectionCell cell in cells) + { + cell.IsCurrent = ReferenceEquals(cell.Column, value); + } + } + } + } + + /// 是不是网格的焦点行(钻入时是子表里选中的那一行)。 + public bool IsCurrentRow(CollectionRow row) => ReferenceEquals(row, GridSelectedRow); + + /// 补全用的抽样(没抽到时为空样本)。 + public CollectionSample Sample => _sample; + + /// 集合统计(检查器「集合信息」页、对象树底部之外的另一个入口)。 + public CollectionStats? Stats + { + get => _stats; + private set => SetProperty(ref _stats, value); + } + + // ── 加载状态 ───────────────────────────────────────────────────────────── + + /// 正在查询。 + public bool IsLoading + { + get => _isLoading; + private set + { + if (SetProperty(ref _isLoading, value)) + { + RaisePropertiesChanged(nameof(IsEmptyCollection), nameof(IsNoResult), nameof(ShowData)); + StopCommand.RaiseCanExecuteChanged(); + } + } + } + + /// 本页往返耗时。 + public TimeSpan Elapsed => _elapsed; + + /// 执行计划摘要(IXSCAN status_1_createdAt_-1)。 + public string PlanSummary + { + get => _planSummary; + private set + { + if (SetProperty(ref _planSummary, value)) + { + UpdateStatus(); + } + } + } + + /// 空集合(没有筛选、一份文档都没有):设计稿 22 的「空集合」卡。 + public bool IsEmptyCollection => _loadedOnce && !_isLoading && _documents.Count == 0 && Staging.InsertCount == 0 + && !HasActiveFilter && _pageIndex == 0; + + /// 筛选无结果。 + public bool IsNoResult => _loadedOnce && !_isLoading && _documents.Count == 0 && Staging.InsertCount == 0 + && (HasActiveFilter || _pageIndex > 0); + + /// 显示数据区(不是空态)。 + public bool ShowData => !IsEmptyCollection && !IsNoResult; + + /// 空集合卡片的标题(orders_archive 中还没有文档)。 + public string EmptyTitle => Loc.Format("State_EmptyTitle", CollectionName); + + // ── 可写性 ─────────────────────────────────────────────────────────────── + + /// + /// 能不能写:视图一律不能;只读模式下工具栏的写按钮置灰(横幅里写明了), + /// 但双击编辑之类的手势仍会走到 ,由它给出"怎么解锁"的提示。 + /// + public bool CanEdit => Info.IsEditable && !Workspace.Guard.IsReadOnly && Workspace.Guard.Privileges.CanWrite(Database); + + /// 集合本身可写(视图、系统集合不可)。 + public bool IsEditable => Info.IsEditable; + + /// 只读横幅。 + public bool ShowReadOnlyBanner => Workspace.Guard.IsReadOnly; + + /// 视图横幅(视图本来就不能改,与只读模式是两回事)。 + public bool ShowViewBanner => Info.Kind == CollectionKind.View && !Workspace.Guard.IsReadOnly; + + /// 只读横幅的主文字(生产连接带上「· 生产连接」)。 + public string ReadOnlyTitle => Workspace.Guard.IsProduction ? Loc["State_ReadOnlyProd"] : Loc["State_ReadOnly"]; + + /// 视图横幅文字。 + public string ViewBannerText => Loc.Format("Cw_ViewBanner", Info.ViewOn ?? "?"); + + // ── 加载 ───────────────────────────────────────────────────────────────── + + /// + public override async Task LoadAsync() + { + await RefreshInfoAsync().ConfigureAwait(true); + _ = LoadIndexesAsync(); + _ = LoadHistoryAsync(); + await RunQueryAsync(resetPage: true).ConfigureAwait(true); + EnsureSample(); + } + + /// + public override Task RefreshAsync() => RunQueryAsync(resetPage: false); + + /// + /// 重探集合信息:外壳在对象树还没展开这个库时给的是一份"普通集合"占位 —— + /// 是不是视图、有没有验证规则,都得问一次服务器才知道。 + /// + private async Task RefreshInfoAsync() + { + try + { + using IAsyncCursor cursor = await Workspace.Connection.Database(Database) + .ListCollectionsAsync(new ListCollectionsOptions { Filter = new BsonDocument("name", CollectionName) }) + .ConfigureAwait(true); + BsonDocument? raw = await cursor.FirstOrDefaultAsync().ConfigureAwait(true); + if (raw is not null) + { + _info = MongoConnection.ToCollectionInfo(Database, raw); + RaisePropertiesChanged(nameof(Info), nameof(IconKey), nameof(CanEdit), nameof(IsEditable), nameof(ShowViewBanner), + nameof(ViewBannerText)); + Inspector.RefreshValidation(); + } + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Workspace.Log.Info($"Probing {Info.Namespace} failed: {ex.Message}"); + } + } + + /// 后台抽一次样(补全、网格下拉候选用);失败就用空样本,不打扰用户。 + internal void EnsureSample() + { + _sampleTask ??= LoadSampleAsync(); + + async Task LoadSampleAsync() + { + try + { + _sample = await CollectionSample.LoadAsync(Workspace.Connection, Database, CollectionName, + Workspace.Connection.Settings.SampleSize, _lifetime.Token).ConfigureAwait(true); + RaisePropertyChanged(nameof(Sample)); + } + catch (Exception ex) when (ex is MongoException or TimeoutException or OperationCanceledException) + { + Workspace.Log.Info($"Sampling {Info.Namespace} failed: {ex.Message}"); + } + } + } + + /// 统计(检查器「集合信息」页第一次打开时取)。 + internal async Task LoadStatsAsync() + { + if (_stats is not null) + { + return; + } + try + { + Stats = await Workspace.Connection.GetStatsAsync(Database, CollectionName, _lifetime.Token).ConfigureAwait(true); + } + catch (Exception ex) when (ex is MongoException or TimeoutException or OperationCanceledException) + { + Workspace.Log.Info($"Stats for {Info.Namespace} failed: {ex.Message}"); + } + } + + /// 把一页结果铺进网格(并按需重建树 / 卡片)。 + private void ApplyResults(List documents, int firstNumber) + { + BsonValue? keepId = _selectedRow?.Id; + int? keepInsert = _selectedRow?.Insert?.Key; + _documents = documents; + Columns = BuildColumns(documents, Staging.Inserts.Select(static i => i.Document)); + var rows = new List(documents.Count + Staging.InsertCount); + for (int i = 0; i < documents.Count; i++) + { + rows.Add(new CollectionRow(this, firstNumber + i, documents[i])); + } + foreach (StagedInsert insert in Staging.Inserts) + { + rows.Add(new CollectionRow(this, 0, insert)); + } + _selectedRows = []; + _selectedRow = null; + Rows = new ObservableCollection(rows); + _trackedIds = [.. Staging.Edits.Select(static e => e.Id).Concat(Staging.Deletes.Select(static d => d.Id))]; + CollectionRow? select = rows.FirstOrDefault(r => keepId is not null && keepId.Equals(r.Id)) + ?? rows.FirstOrDefault(r => keepInsert is not null && r.Insert?.Key == keepInsert) + ?? rows.FirstOrDefault(); + _currentColumn ??= Columns.FirstOrDefault(c => c.Name != "_id") ?? Columns.FirstOrDefault(); + if (_currentColumn is not null && Columns.FirstOrDefault(c => c.Name == _currentColumn.Name) is { } same) + { + _currentColumn = same; + } + SelectedRow = select; + RaisePropertyChanged(nameof(SelectedRow)); + RaisePropertyChanged(nameof(GridSelectedRow)); + RaisePropertiesChanged(nameof(Documents), nameof(IsEmptyCollection), nameof(IsNoResult), nameof(ShowData)); + ReattachDrill(); + RebuildTree(); + RebuildCards(); + } + + /// + /// 列:本页文档顶层字段的并集,按首次出现顺序(_id 在前);宽度按类型与内容估, + /// 用户拖过的宽度在翻页 / 重查之间保留(同名列沿用旧宽度)。 + /// + private IReadOnlyList BuildColumns(IReadOnlyList documents, IEnumerable extra) + { + var order = new List(); + var kinds = new Dictionary>(StringComparer.Ordinal); + var lengths = new Dictionary(StringComparer.Ordinal); + int seen = 0; + foreach (BsonDocument doc in documents.Concat(extra)) + { + // 宽度只看前 200 份就够估了;5 万行的页里逐格算显示文本是白花几百毫秒。 + bool measure = seen++ < 200; + foreach (BsonElement element in doc) + { + if (!kinds.TryGetValue(element.Name, out Dictionary? counts)) + { + counts = []; + kinds[element.Name] = counts; + order.Add(element.Name); + } + BsonKind kind = BsonKinds.Of(element.Value); + counts[kind] = counts.GetValueOrDefault(kind) + 1; + if (measure && lengths.GetValueOrDefault(element.Name) < 40) + { + lengths[element.Name] = Math.Max(lengths.GetValueOrDefault(element.Name), BsonText.Cell(element.Value).Length); + } + } + } + if (order.Remove("_id")) + { + order.Insert(0, "_id"); + } + Dictionary previous = _columns.ToDictionary(static c => c.Name, StringComparer.Ordinal); + BsonDocument sort = ParsedSort ?? []; + var columns = new List(order.Count); + foreach (string name in order) + { + BsonKind kind = Dominant(kinds[name]); + double width = previous.TryGetValue(name, out CollectionColumn? old) ? old.Width : EstimateWidth(name, kind, lengths.GetValueOrDefault(name)); + var column = new CollectionColumn(name, kind, width); + if (sort.TryGetValue(name, out BsonValue direction) && direction.IsNumeric) + { + column.SortDirection = direction.ToInt32() < 0 ? -1 : 1; + } + columns.Add(column); + } + return columns; + } + + /// 一列的主导类型:出现最多的那种,null 与缺失不计;全是 null 时就是 null。 + internal static BsonKind Dominant(Dictionary counts) => + counts.Where(static k => k.Key is not (BsonKind.Null or BsonKind.Missing)) + .OrderByDescending(static k => k.Value).Select(static k => k.Key).DefaultIfEmpty(BsonKind.Null).First(); + + /// 估列宽:类型给底(ObjectId 180、日期 156、布尔 60),字符串按内容长度,夹在 [60, 280]。 + internal static double EstimateWidth(string name, BsonKind kind, int contentLength) + { + double header = (name.Length + BsonKinds.Name(kind).Length) * 6.6 + 30; + double content = kind switch + { + BsonKind.ObjectId => 180, + BsonKind.Date => 156, + BsonKind.Boolean => 60, + BsonKind.Object => 148, + BsonKind.Array => 118, + BsonKind.Uuid => 270, + _ when BsonKinds.IsNumeric(kind) => Math.Max(84, contentLength * 7 + 18), + _ => contentLength * 7 + 18 + }; + return Math.Round(Math.Clamp(Math.Max(header, content), 60, 280)); + } + + private void UpdateCurrentCell(CollectionRow? previous, CollectionRow? next) + { + if (previous?.Cells is { } old) + { + foreach (CollectionCell cell in old) + { + cell.IsCurrent = false; + cell.Editor = null; + } + } + if (next is not null && _currentColumn is not null) + { + foreach (CollectionCell cell in next.Cells) + { + cell.IsCurrent = ReferenceEquals(cell.Column, _currentColumn); + } + } + } + + // ── 状态行 ─────────────────────────────────────────────────────────────── + + /// + /// 宿主状态栏那一行:shop.orders · 50 行 · 12 ms · IXSCAN status_1_createdAt_-1 · 3 项待提交。 + /// + private void UpdateStatus() + { + string text = Loc.Format("Cw_Status", Info.Namespace, BsonText.Grouped(_documents.Count), + ((int)_elapsed.TotalMilliseconds).ToString(CultureInfo.InvariantCulture)); + if (_planSummary.Length > 0) + { + text += " · " + _planSummary; + } + if (!Staging.IsEmpty) + { + text += " · " + Loc.Format("Cw_StatusPending", Staging.OperationCount); + } + StatusText = text; + } + + /// 从执行计划里挑出获胜阶段的摘要(IXSCAN 索引名 / COLLSCAN / IDHACK)。 + internal static string SummarizePlan(BsonDocument explain) + { + BsonDocument? planner = explain.GetValue("queryPlanner", BsonNull.Value) as BsonDocument; + if (planner is null && explain.GetValue("stages", BsonNull.Value) is BsonArray stages + && stages.FirstOrDefault() is BsonDocument first + && first.GetValue("$cursor", BsonNull.Value) is BsonDocument cursor) + { + planner = cursor.GetValue("queryPlanner", BsonNull.Value) as BsonDocument; + } + if (planner?.GetValue("winningPlan", BsonNull.Value) is not BsonDocument winning) + { + return ""; + } + if (winning.GetValue("queryPlan", BsonNull.Value) is BsonDocument sbe) + { + winning = sbe; + } + string[] interesting = ["IXSCAN", "EXPRESS_IXSCAN", "COLLSCAN", "IDHACK", "EXPRESS_IDHACK", "COUNT_SCAN", "DISTINCT_SCAN", "TEXT_MATCH", "GEO_NEAR_2DSPHERE", "EOF"]; + var queue = new Queue([winning]); + while (queue.Count > 0) + { + BsonDocument stage = queue.Dequeue(); + string name = stage.GetValue("stage", "").AsString; + if (interesting.Contains(name)) + { + return stage.GetValue("indexName", BsonNull.Value) is BsonString index ? $"{name} {index.Value}" : name; + } + if (stage.GetValue("inputStage", BsonNull.Value) is BsonDocument input) + { + queue.Enqueue(input); + } + if (stage.GetValue("inputStages", BsonNull.Value) is BsonArray inputs) + { + foreach (BsonDocument item in inputs.OfType()) + { + queue.Enqueue(item); + } + } + } + return winning.GetValue("stage", "").AsString; + } + + // ── 生命周期 ───────────────────────────────────────────────────────────── + + private readonly CancellationTokenSource _lifetime = new(); + + private void OnGuardChanged() => Dispatcher.UIThread.Post(() => + { + RaisePropertiesChanged(nameof(CanEdit), nameof(ShowReadOnlyBanner), nameof(ShowViewBanner), nameof(ReadOnlyTitle)); + if (!Workspace.Guard.IsReadOnly) + { + return; + } + _relockTimer?.Stop(); + _relockTimer = null; + }); + + /// + public override void Dispose() + { + if (_disposed) + { + return; + } + _disposed = true; + Workspace.Guard.Changed -= OnGuardChanged; + Staging.Changed -= OnStagingChanged; + _queryCts?.Cancel(); + _longQueryTimer?.Stop(); + _relockTimer?.Stop(); + _lifetime.Cancel(); + _lifetime.Dispose(); + base.Dispose(); + } + + /// 界面上的毫秒数。 + internal static string Ms(TimeSpan elapsed) => ((int)Math.Round(elapsed.TotalMilliseconds)).ToString(CultureInfo.InvariantCulture); + + /// 计时开始(供查询与提交共用)。 + internal static Stopwatch StartWatch() => Stopwatch.StartNew(); +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Collection/ConflictDialogView.axaml b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/ConflictDialogView.axaml new file mode 100644 index 0000000..244f5bf --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/ConflictDialogView.axaml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Collection/ConflictDialogView.axaml.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/ConflictDialogView.axaml.cs new file mode 100644 index 0000000..a51354d --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/ConflictDialogView.axaml.cs @@ -0,0 +1,20 @@ +using Avalonia.Controls; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 视图(占位)。 +public sealed partial class ConflictDialogView : UserControl +{ + /// 用给定的视图模型初始化。 + internal ConflictDialogView(ConflictDialogViewModel viewModel) + { + InitializeComponent(); + DataContext = viewModel; + } + + /// 设计器 / headless 用的无参构造。 + public ConflictDialogView() + { + InitializeComponent(); + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Collection/ConflictDialogViewModel.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/ConflictDialogViewModel.cs new file mode 100644 index 0000000..9fb9257 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/ConflictDialogViewModel.cs @@ -0,0 +1,185 @@ +using MongoDB.Bson; +using VelaShell.Plugin.Mongo.Bson; +using VelaShell.Plugin.Mongo.Staging; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 冲突的出路。 +internal enum ConflictChoice +{ + /// 放弃我的修改。 + DiscardMine, + + /// 覆盖服务器版本。 + Overwrite, + + /// 合并(逐字段选择)。 + Merge +} + +/// 冲突对话框的结论。 +/// 出路。 +/// 合并时保留"我的"的那些路径。 +internal sealed record ConflictResolution(ConflictChoice Choice, IReadOnlySet Keep); + +/// 冲突对话框的一行:字段 · 我的 · 服务器(点哪边就保留哪边)。 +internal sealed class ConflictRow : ObservableObject +{ + private bool _useMine = true; + + /// 构造。 + public ConflictRow(string path, string mine, string server, bool bothChanged) + { + Path = path; + Mine = mine; + Server = server; + BothChanged = bothChanged; + PickMineCommand = new RelayCommand(() => UseMine = true); + PickServerCommand = new RelayCommand(() => UseMine = false); + } + + /// 字段路径。 + public string Path { get; } + + /// 我的值。 + public string Mine { get; } + + /// 服务器上的值。 + public string Server { get; } + + /// 两边都改了(橙底,必须选一边)。 + public bool BothChanged { get; } + + /// 合并时保留我的。 + public bool UseMine + { + get => _useMine; + set + { + if (SetProperty(ref _useMine, value)) + { + RaisePropertyChanged(nameof(UseServer)); + } + } + } + + /// 合并时保留服务器的。 + public bool UseServer => !_useMine; + + /// 选我的。 + public RelayCommand PickMineCommand { get; } + + /// 选服务器的。 + public RelayCommand PickServerCommand { get; } +} + +/// +/// 编辑冲突(设计稿 22):提交时乐观并发检查没过 —— 在我编辑期间别的会话改了同一份文档的同一批字段。 +/// 逐字段列出「我的 vs 服务器」,三种出路:放弃我的修改 / 覆盖服务器版本 / 合并(点哪边留哪边)。 +/// +internal sealed class ConflictDialogViewModel : DialogViewModel +{ + private readonly Func _resolve; + private bool _resolved; + + /// 构造。 + /// 外壳服务。 + /// 冲突。 + /// 用户选定之后的处理(由集合工作台执行)。 + public ConflictDialogViewModel(IMongoWorkspace workspace, EditConflict conflict, Func resolve) + : base(workspace) + { + Conflict = conflict; + _resolve = resolve; + Title = Loc["State_ConflictTitle"]; + BsonDocument original = conflict.Edit.Original; + BsonDocument? server = conflict.Server; + Rows = + [ + .. conflict.Edit.Changes.Select(change => + { + BsonValue? theirs = server is null ? null : BsonPath.Get(server, change.Path); + bool both = server is not null && !StagedEdit.SameValue(theirs, change.Original); + return new ConflictRow(change.Path, Show(change.Value), Show(theirs), both); + }) + ]; + string id = BsonText.Shorten(BsonText.Cell(conflict.Edit.Id)); + if (server is null) + { + Message = Loc.Format("State_ConflictDeleted", id); + } + else if (original.GetValue("updatedAt", BsonNull.Value) is BsonDateTime before + && server.GetValue("updatedAt", BsonNull.Value) is BsonDateTime after && !before.Equals(after)) + { + Message = Loc.Format("State_ConflictStamp", id, Time(before), Time(after)); + } + else + { + Message = Loc.Format("State_ConflictBody", id); + } + List both = [.. Rows.Where(static r => r.BothChanged).Select(static r => r.Path)]; + Hint = both.Count > 0 ? Loc.Format("State_ConflictHint", string.Join("、", both)) : Loc["State_ConflictHintNone"]; + DiscardMineCommand = new AsyncCommand(() => FinishAsync(ConflictChoice.DiscardMine)); + OverwriteCommand = new AsyncCommand(() => FinishAsync(ConflictChoice.Overwrite), () => server is not null); + MergeCommand = new AsyncCommand(() => FinishAsync(ConflictChoice.Merge), () => server is not null); + } + + /// 冲突。 + public EditConflict Conflict { get; } + + /// + public override string IconKey => "Mongo.git-merge"; + + /// + public override string IconToken => "VelaWarning"; + + /// + public override double Width => 500; + + /// 说明(带 updatedAt 前后时刻)。 + public string Message { get; } + + /// 逐字段对比。 + public IReadOnlyList Rows { get; } + + /// 底下那行橙字(哪些字段两边都改了)。 + public string Hint { get; } + + /// 放弃我的修改。 + public AsyncCommand DiscardMineCommand { get; } + + /// 覆盖服务器版本。 + public AsyncCommand OverwriteCommand { get; } + + /// 合并(按每行的选择)。 + public AsyncCommand MergeCommand { get; } + + private async Task FinishAsync(ConflictChoice choice) + { + if (_resolved) + { + return; + } + _resolved = true; + HashSet keep = choice == ConflictChoice.Merge + ? Rows.Where(static r => r.UseMine).Select(static r => r.Path).ToHashSet(StringComparer.Ordinal) + : new HashSet(StringComparer.Ordinal); + Close(); + await _resolve(new ConflictResolution(choice, keep)).ConfigureAwait(true); + } + + /// 直接关掉(× / Esc)等于"先不处理":修改留在暂存区,下次提交还会再碰到。 + internal override void OnClosed() + { + _resolved = true; + base.OnClosed(); + } + + private string Show(BsonValue? value) => value is null + ? "—" + : value is BsonDateTime ? BsonText.FormatDate(value) : BsonText.Inline(value, Loc, shortenIds: true); + + private static string Time(BsonDateTime value) => + DateTimeOffset.FromUnixTimeMilliseconds(value.MillisecondsSinceEpoch).ToLocalTime() + .ToString("HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture); +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DatePickFlyout.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DatePickFlyout.cs new file mode 100644 index 0000000..c61c169 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DatePickFlyout.cs @@ -0,0 +1,133 @@ +using System.Globalization; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using VelaShell.Plugin.Mongo.Bson; +using Calendar = Avalonia.Controls.Calendar; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 日期值的选择弹层:月历 + 时刻框 +「现在」/「应用」。 +/// +/// 网格、检查器、树视图里内联编辑日期字段时,编辑框右边的日历按钮弹它。以前网格那支下拉箭头里只有一条 +/// "现在"的文字,换一天还得手敲整串 yyyy-MM-dd HH:mm:ss。 +/// 点一天 = 换成那一天(时刻取时刻框里的)并写入;只改时刻就在框里回车或点「应用」。全程本地时间, +/// 与编辑框的写法一致(存进去时由 换成 UTC)。 +/// +/// +internal static class DatePickFlyout +{ + private static readonly string[] TimeFormats = [@"h\:mm\:ss", @"hh\:mm\:ss", @"h\:mm", @"hh\:mm"]; + + /// 在 下方弹出。 + /// 锚点(编辑框右边的日历按钮)。 + /// 文案。 + /// 编辑框里当前的文本;解析不了(空、写了一半)就从"现在"起步。 + /// 选定之后:拿到要写回编辑框的文本(,本地时间)。 + /// 弹层(测试要直接摆弄里面的月历)。 + public static Flyout Show(Control anchor, Loc loc, string text, Action picked) + { + DateTime start = BsonEdit.TryParseDate(text, out DateTime utc) ? utc.ToLocalTime() : DateTime.Now; + var calendar = new Calendar + { + SelectionMode = CalendarSelectionMode.SingleDate, + SelectedDate = start.Date, + DisplayDate = start.Date, + IsTodayHighlighted = true + }; + var time = new TextBox + { + Text = start.ToString("HH:mm:ss", CultureInfo.InvariantCulture), + Width = 96, + VerticalContentAlignment = VerticalAlignment.Center + }; + if (anchor.TryFindResource("VelaUiMonoFont", out object? mono) && mono is FontFamily family) + { + time.FontFamily = family; + } + ToolTip.SetTip(time, loc["Doc_LocalTime"]); + var now = new Button { Content = loc["Cw_DateNow"], VerticalAlignment = VerticalAlignment.Center }; + var apply = new Button { Content = loc["Common_Apply"], VerticalAlignment = VerticalAlignment.Center }; + apply.Classes.Add("accent"); + + var buttons = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 6, VerticalAlignment = VerticalAlignment.Center }; + buttons.Children.Add(now); + buttons.Children.Add(apply); + DockPanel.SetDock(buttons, Dock.Right); + var label = new TextBlock + { + Text = loc["Cw_DateTime"], + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 8, 0), + Foreground = ThemeBrushes.Get("VelaTextSecondary", Brushes.Gray) + }; + DockPanel.SetDock(label, Dock.Left); + var bar = new DockPanel { LastChildFill = false }; + bar.Children.Add(buttons); + bar.Children.Add(label); + bar.Children.Add(time); + + var flyout = new Flyout + { + Content = new StackPanel { Spacing = 8, Children = { calendar, bar } }, + Placement = PlacementMode.BottomEdgeAlignedRight + }; + IBrush? normalBorder = null; + + void Commit(DateTime day) + { + if (!TryParseTime(time.Text, out TimeSpan of)) + { + normalBorder ??= time.BorderBrush; + time.BorderBrush = ThemeBrushes.Get("VelaError", Brushes.Red); + ToolTip.SetTip(time, loc["Cw_DateBadTime"]); + time.Focus(); + return; + } + flyout.Hide(); + picked((day.Date + of).ToString(BsonText.DateFormat, CultureInfo.InvariantCulture)); + } + + // 初值设完再挂事件:构造时那一次 SelectedDate 不算用户选的。 + calendar.SelectedDatesChanged += (_, _) => + { + if (calendar.SelectedDate is { } day) + { + Commit(day); + } + }; + apply.Click += (_, _) => Commit(calendar.SelectedDate ?? start.Date); + now.Click += (_, _) => + { + flyout.Hide(); + picked(DateTime.Now.ToString(BsonText.DateFormat, CultureInfo.InvariantCulture)); + }; + time.KeyDown += (_, e) => + { + if (e.Key == Key.Enter) + { + e.Handled = true; + Commit(calendar.SelectedDate ?? start.Date); + } + }; + time.TextChanged += (_, _) => + { + if (normalBorder is not null) + { + time.BorderBrush = normalBorder; + ToolTip.SetTip(time, loc["Doc_LocalTime"]); + } + }; + flyout.ShowAt(anchor); + return flyout; + } + + /// 时刻框:9:30、09:30、09:30:15 都认。 + internal static bool TryParseTime(string? text, out TimeSpan time) => + TimeSpan.TryParseExact((text ?? "").Trim(), TimeFormats, CultureInfo.InvariantCulture, out time) + && time >= TimeSpan.Zero && time < TimeSpan.FromDays(1); +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DocInspectorView.axaml b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DocInspectorView.axaml new file mode 100644 index 0000000..a9aaf01 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DocInspectorView.axaml @@ -0,0 +1,156 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DocInspectorView.axaml.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DocInspectorView.axaml.cs new file mode 100644 index 0000000..e99c8f4 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DocInspectorView.axaml.cs @@ -0,0 +1,251 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Threading; +using Avalonia.VisualTree; +using MongoDB.Bson; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 文档检查器:点对象 / 数组那一行(任何位置)展开收起、双击值编辑、点类型标签换类型(色块菜单)、 +/// 日期值的日历、右键菜单与键盘手势。编辑中焦点离开(点回网格、点别的字段)即写入 —— +/// 不然编辑框与高亮着的类型下拉会一直挂在那儿,看着像还在改。 +/// +public sealed partial class DocInspectorView : UserControl +{ + /// 构造。 + public DocInspectorView() + { + InitializeComponent(); + FieldList.AddHandler(PointerPressedEvent, OnPointerPressed, RoutingStrategies.Tunnel); + FieldList.AddHandler(KeyDownEvent, OnKeyDown, RoutingStrategies.Tunnel); + FieldList.AddHandler(LostFocusEvent, OnEditorLostFocus); + FieldList.AddHandler(Button.ClickEvent, OnDatePickClick); + FieldList.DoubleTapped += OnDoubleTapped; + FieldList.ContextRequested += OnContextRequested; + } + + private DocInspectorViewModel? ViewModel => DataContext as DocInspectorViewModel; + + /// 字段列表(测试用)。 + internal ListBox Fields => FieldList; + + private void OnPointerPressed(object? sender, PointerPressedEventArgs e) + { + if (ViewModel is not { } vm || GridPaneView.Find(e.Source) is not { } field + || !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + { + return; + } + if (IsInside(e.Source, "typetag") is { } tag) + { + e.Handled = true; + vm.SelectedField = field; + OpenTypeMenu(tag, vm, field); + return; + } + // 对象 / 数组:点这一行的任何地方(箭头、键、值)都展开 / 收起,不必瞄准 11px 的小箭头。 + // 双击的第二下(ClickCount 2)不再翻回去;编辑中的那一行点的是编辑框,不动。 + if (field is { IsExpandable: true, IsEditing: false }) + { + e.Handled = true; + if (e.ClickCount == 1) + { + vm.SelectedField = field; + vm.Toggle(field); + } + FocusSelected(); + } + } + + private void OnDoubleTapped(object? sender, TappedEventArgs e) + { + // 对象 / 数组在按下第一下时就展开 / 收起过了,双击不再改成编辑整个容器的字面量(那用 Enter / 右键「编辑值」)。 + if (ViewModel is not { } vm || GridPaneView.Find(e.Source) is not { IsEditing: false, IsExpandable: false } field) + { + return; + } + vm.BeginEdit(field); + } + + /// + /// 编辑框失焦:焦点落定后若已在编辑框之外(而且不是进了类型菜单、日历这些弹出层),就把值写进暂存区并收起编辑框。 + /// 解析不了的值留着编辑框和红框,不悄悄丢掉用户打的字。 + /// + private void OnEditorLostFocus(object? sender, RoutedEventArgs e) + { + if (e.Source is not TextBox box || ViewModel is not { Row: { } row } vm + || GridPaneView.Find(box) is not { Editor: { } editor } field) + { + return; + } + // 点网格的另一行时检查器紧接着就换了文档 —— 这次编辑属于失焦那一刻的那份文档。 + Dispatcher.UIThread.Post(() => + { + if (!ReferenceEquals(field.Editor, editor) || box.IsKeyboardFocusWithin || FocusIsInPopup()) + { + return; + } + vm.CommitEdit(field, row); + }); + } + + private bool FocusIsInPopup() => + TopLevel.GetTopLevel(this)?.FocusManager?.GetFocusedElement() is Visual focused + && focused.GetSelfAndVisualAncestors().Any(static v => v is PopupRoot or OverlayPopupHost); + + /// 日期值编辑框右边的日历按钮:选一天 / 改时刻即写入暂存区。 + private void OnDatePickClick(object? sender, RoutedEventArgs e) + { + if (e.Source is not Button { Classes: var classes } button || !classes.Contains("datepick") + || ViewModel is not { Row: { } row } vm || GridPaneView.Find(button) is not { Editor: { } editor } field) + { + return; + } + e.Handled = true; + DatePickFlyout.Show(button, vm.Loc, editor.Text, text => + { + editor.Text = text; + if (vm.CommitEdit(field, row)) + { + FocusSelected(); + } + }); + } + + /// + /// 展开 / 收起会整表重建字段行,原来拿着焦点的那一行容器没了 —— 把焦点交给新的选中行, + /// 方向键与 Enter 才接得上。 + /// + private void FocusSelected() => Dispatcher.UIThread.Post(() => + { + if (ViewModel?.SelectedField is { } selected && FieldList.ContainerFromItem(selected) is Control container) + { + container.Focus(); + } + else + { + FieldList.Focus(); + } + }, DispatcherPriority.Loaded); + + private void OnKeyDown(object? sender, KeyEventArgs e) + { + if (ViewModel is not { } vm) + { + return; + } + if (e.Source is TextBox && GridPaneView.Find(e.Source) is { IsEditing: true } editing) + { + if (e.Key == Key.Enter) + { + e.Handled = true; + if (vm.CommitEdit(editing)) + { + FieldList.Focus(); + } + } + else if (e.Key == Key.Escape) + { + e.Handled = true; + DocInspectorViewModel.CancelEdit(editing); + FieldList.Focus(); + } + return; + } + bool control = e.KeyModifiers.HasFlag(KeyModifiers.Control); + if (control && e.Key == Key.Enter) + { + e.Handled = true; + vm.AddFieldCommand.Execute(null); + return; + } + if (vm.SelectedField is not { } field || vm.Row is not { } row) + { + return; + } + switch (e.Key) + { + case Key.Enter or Key.F2: + e.Handled = true; + vm.BeginEdit(field); + break; + case Key.Right when field.IsExpandable && !field.IsExpanded: + case Key.Left when field.IsExpandable && field.IsExpanded: + e.Handled = true; + vm.Toggle(field); + break; + case Key.Delete when !field.IsMissing && field.Path != "_id": + e.Handled = true; + vm.Owner.DeleteField(row, field.Path); + break; + case Key.C when control: + e.Handled = true; + _ = vm.Owner.CopyValueAsync(field.Value); + break; + } + } + + private void OnContextRequested(object? sender, ContextRequestedEventArgs e) + { + if (ViewModel is not { Row: { } row } vm || GridPaneView.Find(e.Source) is not { } field) + { + return; + } + vm.SelectedField = field; + CollectionTabViewModel owner = vm.Owner; + Loc loc = vm.Loc; + bool writable = owner.IsEditable && !row.IsDeleted && field.Path != "_id"; + BsonValue? value = field.Value; + string path = field.Path; + var items = new List + { + CollectionMenus.Item(loc["Cw_MenuEditValue"], "Mongo.pencil", "Enter", () => vm.BeginEdit(field), accent: true, enabled: writable), + CollectionMenus.Submenu(loc["Cw_MenuChangeType"], "Mongo.shapes", CollectionMenus.KindItems(field.Kind, kind => vm.ChangeType(field, kind)), writable), + CollectionMenus.Item(loc["Cw_MenuAddField"], "Mongo.plus", "Ctrl+↵", + () => owner.PromptAddField(row, field.Kind == Bson.BsonKind.Object ? path : ParentOf(path)), enabled: owner.IsEditable), + CollectionMenus.Separator(), + CollectionMenus.Item(loc["Cw_MenuCopyValue"], "Mongo.copy", "Ctrl+C", () => _ = owner.CopyValueAsync(value)), + CollectionMenus.Item(loc["Cw_MenuCopyPath"], "Mongo.route", path, () => _ = owner.CopyTextAsync(path)), + CollectionMenus.Item(loc["Cw_MenuCopyJson"], "Mongo.braces", null, () => _ = owner.CopyValueAsJsonAsync(value)), + CollectionMenus.Separator(), + CollectionMenus.Item(loc["Cw_MenuFilterBy"], "Mongo.funnel", null, () => _ = owner.FilterByAsync(CollectionSample.NormalizePath(path), value)), + CollectionMenus.Separator(), + CollectionMenus.Item(loc["Cw_MenuDeleteField"], "Mongo.trash-2", "Del", () => owner.DeleteField(row, path), danger: true, + enabled: writable && !field.IsMissing), + CollectionMenus.FilterHint(loc, CollectionSample.NormalizePath(path), value, () => _ = owner.FilterByAsync(CollectionSample.NormalizePath(path), value)) + }; + CollectionMenus.Open(e.Source as Control ?? FieldList, items); + e.Handled = true; + } + + private static void OpenTypeMenu(Control anchor, DocInspectorViewModel vm, InspectorField field) + { + if (field.Path == "_id") + { + return; + } + CollectionMenus.Open(anchor, CollectionMenus.KindItems(field.Kind, kind => vm.ChangeType(field, kind))); + } + + private static string ParentOf(string path) => path.LastIndexOf('.') is var dot and > 0 ? path[..dot] : ""; + + private static T? IsInside(object? source, string className) where T : Control + { + for (var visual = source as Avalonia.Visual; visual is not null; visual = Avalonia.VisualTree.VisualExtensions.GetVisualParent(visual)) + { + if (visual is T control && control.Classes.Contains(className)) + { + return control; + } + if (visual is ListBoxItem) + { + break; + } + } + return null; + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DocInspectorViewModel.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DocInspectorViewModel.cs new file mode 100644 index 0000000..10dd5ac --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DocInspectorViewModel.cs @@ -0,0 +1,585 @@ +using System.Collections.ObjectModel; +using System.Globalization; +using MongoDB.Bson; +using VelaShell.Plugin.Mongo.Analysis; +using VelaShell.Plugin.Mongo.Bson; +using VelaShell.Plugin.Mongo.Core; +using VelaShell.Plugin.Mongo.Staging; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 检查器字段树的一行(设计稿 01 右侧:键 : 值 + 类型标签)。 +internal sealed class InspectorField : ObservableObject +{ + private InlineValueEditor? _editor; + + /// 构造。 + public InspectorField(string path, string key, int depth, BsonValue? value, bool isExpanded, ChangeRelation change, Loc loc) + { + Path = path; + Key = key; + Depth = depth; + Value = value; + Kind = BsonKinds.Of(value); + IsExpanded = isExpanded; + Change = change; + ValueText = Format(value, isExpanded, loc); + } + + /// 路径。 + public string Path { get; } + + /// 键名(数组元素是下标)。 + public string Key { get; } + + /// 层级。 + public int Depth { get; } + + /// 左内边距(设计稿:顶层 10,子级 24)。 + public double Indent => 10 + Depth * 14; + + /// 值;字段不存在为 。 + public BsonValue? Value { get; } + + /// 类型。 + public BsonKind Kind { get; } + + /// 字段不存在(这一页里别的文档有这个字段,这份没有)。 + public bool IsMissing => Kind == BsonKind.Missing; + + /// 能展开。 + public bool IsExpandable => Value is BsonDocument { ElementCount: > 0 } or BsonArray { Count: > 0 }; + + /// 展开着。 + public bool IsExpanded { get; } + + /// 展开箭头图标。 + public string Chevron => IsExpanded ? "Mongo.chevron-down" : "Mongo.chevron-right"; + + /// 与暂存修改的关系。 + public ChangeRelation Change { get; } + + /// 改过(值橙色)。 + public bool IsModified => Change is ChangeRelation.Exact or ChangeRelation.InsideChange; + + /// 值文字。 + public string ValueText { get; } + + /// 值颜色。 + public string ValueToken => IsModified ? "VelaWarning" : IsMissing ? "VelaTextMuted" : BsonKinds.ColorToken(Kind); + + /// 类型标签文字(缺失写「缺失」)。 + public string TypeText { get; init; } = ""; + + /// 内联编辑器。 + public InlineValueEditor? Editor + { + get => _editor; + set + { + if (SetProperty(ref _editor, value)) + { + RaisePropertyChanged(nameof(IsEditing)); + } + } + } + + /// 编辑中。 + public bool IsEditing => _editor is not null; + + /// + /// 检查器里的值写法:ObjectId 缩写、字符串带引号、Decimal 原样、日期 ISO; + /// 展开着的容器只给 {3} / [4],收起的文档列出键名({ sku, qty, price }), + /// 收起的短数组直接给字面量(["企业","开票"])。 + /// + private static string Format(BsonValue? value, bool expanded, Loc loc) + { + switch (BsonKinds.Of(value)) + { + case BsonKind.Missing: + return loc["Cw_FieldMissing"]; + case BsonKind.ObjectId: + return BsonText.Shorten(value!.AsObjectId.ToString()); + case BsonKind.Decimal128: + return value!.AsDecimal128.ToString(); + case BsonKind.Date: + return BsonText.IsoDate(value!); + case BsonKind.Object: + { + BsonDocument doc = value!.AsBsonDocument; + if (expanded || doc.ElementCount == 0) + { + return $"{{{doc.ElementCount}}}"; + } + string keys = string.Join(", ", doc.Names.Take(4)) + (doc.ElementCount > 4 ? ", …" : ""); + return $"{{ {keys} }}"; + } + case BsonKind.Array: + { + BsonArray array = value!.AsBsonArray; + if (expanded) + { + return $"[{array.Count}]"; + } + string literal = "[" + string.Join(",", array.Select(static v => v is BsonDocument or BsonArray ? "…" : BsonText.Literal(v))) + "]"; + return literal.Length <= 40 ? literal : $"[{array.Count}]"; + } + default: + return BsonText.Inline(value, loc, shortenIds: true); + } + } +} + +/// 「集合信息」页的一行。 +/// 标签。 +/// 值。 +internal sealed record CollectionInfoLine(string Label, string Value); + +/// +/// 网格右侧的文档检查器(288px):字段树(嵌套展开、值内联编辑、类型下拉)、JSON、集合信息三页, +/// 底部是 $jsonSchema 预检状态与「放弃 / 应用修改」(只作用于这一份文档)。 +/// +internal sealed class DocInspectorViewModel : ObservableObject +{ + private readonly CollectionTabViewModel _owner; + private readonly HashSet _expanded = new(StringComparer.Ordinal); + private readonly HashSet _collapsed = new(StringComparer.Ordinal); + private CollectionRow? _row; + private int _page; + private ObservableCollection _fields = []; + private InspectorField? _selected; + private string _jsonText = ""; + private string _validationText = ""; + private string _validationIcon = "Mongo.shield"; + private string _validationToken = "VelaTextMuted"; + private IReadOnlyList _infoLines = []; + + /// 构造。 + public DocInspectorViewModel(CollectionTabViewModel owner) + { + _owner = owner; + ShowJsonCommand = new RelayCommand(() => Page = 1); + CopyCommand = new AsyncCommand(() => _row is null ? Task.CompletedTask : owner.CopyDocumentsAsync([_row])); + CloneCommand = new RelayCommand(() => + { + if (_row is not null) + { + owner.Clone(_row); + } + }); + DeleteCommand = new RelayCommand(() => + { + if (_row is not null) + { + owner.StageDelete([_row]); + } + }); + AddFieldCommand = new RelayCommand(() => + { + if (_row is not null) + { + owner.PromptAddField(_row, ""); + } + }); + DiscardCommand = new RelayCommand(() => + { + if (_row is not null) + { + owner.DiscardDocument(_row); + } + }); + ApplyCommand = new AsyncCommand(() => _row switch + { + { Insert: { } insert } => owner.CommitAsync(new CommitScope(OnlyInsert: insert)), + { Id: { } id } => owner.CommitAsync(new CommitScope(id)), + _ => Task.CompletedTask + }); + } + + /// 文案表。 + public Loc Loc => _owner.Loc; + + /// 所属工作台。 + public CollectionTabViewModel Owner => _owner; + + /// 看着的行。 + public CollectionRow? Row => _row; + + /// 有没有行。 + public bool HasRow => _row is not null; + + /// 头部的 #4。 + public string NumberText => _row is null ? "" : "#" + _row.NumberText; + + /// 页签:0 字段、1 JSON、2 集合信息。 + public int Page + { + get => _page; + set + { + if (SetProperty(ref _page, value)) + { + RaisePropertiesChanged(nameof(IsFieldsPage), nameof(IsJsonPage), nameof(IsInfoPage)); + if (value == 1) + { + RefreshJson(); + } + else if (value == 2) + { + _ = LoadInfoAsync(); + } + } + } + } + + /// 字段页。 + public bool IsFieldsPage + { + get => _page == 0; + set + { + if (value) + { + Page = 0; + } + } + } + + /// JSON 页。 + public bool IsJsonPage + { + get => _page == 1; + set + { + if (value) + { + Page = 1; + } + } + } + + /// 集合信息页。 + public bool IsInfoPage + { + get => _page == 2; + set + { + if (value) + { + Page = 2; + } + } + } + + /// 字段树的可见行。 + public ObservableCollection Fields + { + get => _fields; + private set => SetProperty(ref _fields, value); + } + + /// 选中的字段。 + public InspectorField? SelectedField + { + get => _selected; + set => SetProperty(ref _selected, value); + } + + /// JSON 页的文本。 + public string JsonText + { + get => _jsonText; + private set => SetProperty(ref _jsonText, value); + } + + /// JSON 页的语法。 + public CodeLanguage JsonLanguage => _owner.Ejson == EjsonMode.Shell ? CodeLanguage.Shell : CodeLanguage.Json; + + /// 底部验证状态文字。 + public string ValidationText + { + get => _validationText; + private set => SetProperty(ref _validationText, value); + } + + /// 验证状态图标。 + public string ValidationIcon + { + get => _validationIcon; + private set => SetProperty(ref _validationIcon, value); + } + + /// 验证状态图标颜色。 + public string ValidationToken + { + get => _validationToken; + private set => SetProperty(ref _validationToken, value); + } + + /// 这份文档有暂存修改(「放弃 / 应用修改」可用)。 + public bool HasChanges => _row is { State: CollectionRowState.Modified or CollectionRowState.Added or CollectionRowState.Deleted }; + + /// 「集合信息」页。 + public IReadOnlyList InfoLines + { + get => _infoLines; + private set => SetProperty(ref _infoLines, value); + } + + /// JSON 页。 + public RelayCommand ShowJsonCommand { get; } + + /// 复制。 + public AsyncCommand CopyCommand { get; } + + /// 克隆。 + public RelayCommand CloneCommand { get; } + + /// 删除(暂存)。 + public RelayCommand DeleteCommand { get; } + + /// 添加字段。 + public RelayCommand AddFieldCommand { get; } + + /// 放弃这份文档的修改。 + public RelayCommand DiscardCommand { get; } + + /// 只提交这份文档。 + public AsyncCommand ApplyCommand { get; } + + /// 换一行看。 + public void Load(CollectionRow? row) + { + if (!ReferenceEquals(row, _row)) + { + _expanded.Clear(); + _collapsed.Clear(); + } + _row = row; + RaisePropertiesChanged(nameof(Row), nameof(HasRow), nameof(NumberText)); + Reload(); + } + + /// 重建字段树(暂存区变了、展开变了)。 + public void Reload() + { + string? keep = _selected?.Path; + if (_row is null) + { + Fields = []; + ValidationText = ""; + RaisePropertyChanged(nameof(HasChanges)); + return; + } + BsonDocument doc = _row.Document; + StagedEdit? edit = _row.Insert is null ? _owner.Staging.EditOf(_row.Id) : null; + var list = new List(); + Append(list, doc, "", 0, edit); + // 这一页里别的文档有、这一份没有的顶层字段:「— 字段不存在」+「缺失」。 + foreach (CollectionColumn column in _owner.Columns) + { + if (!doc.Contains(column.Name)) + { + list.Add(new InspectorField(column.Name, column.Name, 0, null, false, ChangeRelation.None, Loc) { TypeText = Loc["Cw_TypeMissing"] }); + } + } + Fields = new ObservableCollection(list); + _selected = keep is null ? null : list.FirstOrDefault(f => f.Path == keep); + RaisePropertiesChanged(nameof(SelectedField), nameof(HasChanges), nameof(NumberText)); + RefreshValidation(); + if (_page == 1) + { + RefreshJson(); + } + } + + private void Append(List list, BsonValue container, string parent, int depth, StagedEdit? edit) + { + IEnumerable<(string Name, BsonValue Value)> children = container switch + { + BsonDocument doc => doc.Elements.Select(static e => (e.Name, e.Value)), + BsonArray array => array.Select(static (v, i) => (i.ToString(CultureInfo.InvariantCulture), v)), + _ => [] + }; + foreach ((string name, BsonValue value) in children) + { + string path = parent.Length == 0 ? name : $"{parent}.{name}"; + bool expanded = IsExpanded(path, value, depth); + var field = new InspectorField(path, name, depth, value, expanded, edit?.RelationOf(path) ?? ChangeRelation.None, Loc) + { + TypeText = BsonKinds.Name(BsonKinds.Of(value)) + }; + list.Add(field); + if (expanded && field.IsExpandable) + { + Append(list, value, path, depth + 1, edit); + } + } + } + + /// + /// 默认展开:顶层的子文档与"文档数组"展开一层(设计稿:customer、items 展开,items 的元素收起); + /// 用户点过的以用户为准。 + /// + private bool IsExpanded(string path, BsonValue value, int depth) + { + if (_expanded.Contains(path)) + { + return true; + } + if (_collapsed.Contains(path)) + { + return false; + } + return depth == 0 && value switch + { + BsonDocument doc => doc.ElementCount is > 0 and <= 12, + BsonArray array => array.Count is > 0 and <= 8 && array.All(static v => v is BsonDocument), + _ => false + }; + } + + /// 展开 / 收起。 + public void Toggle(InspectorField field) + { + if (!field.IsExpandable) + { + return; + } + if (field.IsExpanded) + { + _expanded.Remove(field.Path); + _collapsed.Add(field.Path); + } + else + { + _collapsed.Remove(field.Path); + _expanded.Add(field.Path); + } + Reload(); + } + + /// 开始编辑一个字段的值。 + public void BeginEdit(InspectorField field) + { + if (_row is null || field.Path == "_id" && _row.Insert is null || !_owner.EnsureCanWrite()) + { + return; + } + foreach (InspectorField other in _fields.Where(static f => f.IsEditing)) + { + other.Editor = null; + } + BsonKind fallback = _owner.Columns.FirstOrDefault(c => c.Name == field.Path)?.Kind ?? _owner.Sample.KindOf(field.Path); + field.Editor = _owner.CreateEditor(field.Value, field.Path, fallback); + SelectedField = field; + } + + /// 提交一个字段的编辑(写入暂存区)。 + public bool CommitEdit(InspectorField field) => _row is not null && CommitEdit(field, _row); + + /// + /// 把一个字段的编辑提交到指定的那份文档。失焦提交要用它:点网格另一行时, + /// 检查器在提交真正执行之前就已经换成了新文档。 + /// + internal bool CommitEdit(InspectorField field, CollectionRow row) + { + if (field.Editor is not { } editor || !_owner.CommitEditor(editor, row, field.Path)) + { + return false; + } + field.Editor = null; + return true; + } + + /// 取消编辑。 + public static void CancelEdit(InspectorField field) => field.Editor = null; + + /// 换类型。 + public void ChangeType(InspectorField field, BsonKind kind) + { + if (_row is not null && field.Path != "_id") + { + _owner.ChangeType(_row, field.Path, kind); + } + } + + /// JSON 页文本(当前写法)。 + public void RefreshJson() + { + JsonText = _row is null ? "" : BsonText.Pretty(_row.Document, _owner.Ejson); + RaisePropertyChanged(nameof(JsonLanguage)); + } + + /// + /// 底部的验证状态:集合有 $jsonSchema 时客户端预检这份文档(含暂存修改); + /// 只是预检 —— 最终以服务器为准,这里的意义是在提交之前就看到"这样改过不了"。 + /// + public void RefreshValidation() + { + if (_row is null) + { + return; + } + if (_owner.Info.Validator is not { } validator) + { + ValidationText = Loc["Cw_NoValidator"]; + ValidationIcon = "Mongo.shield"; + ValidationToken = "VelaTextMuted"; + return; + } + IReadOnlyList violations = JsonSchemaValidator.Validate(validator, _row.Document, Loc); + if (violations.Count == 0) + { + ValidationText = Loc["Cw_SchemaOk"]; + ValidationIcon = "Mongo.shield-check"; + ValidationToken = "VelaStatusConnected"; + } + else + { + ValidationText = Loc.Format("Cw_SchemaViolations", violations.Count, violations[0].Message); + ValidationIcon = "Mongo.shield-alert"; + ValidationToken = "VelaError"; + } + } + + /// 「集合信息」页:形态、文档数、大小、索引、验证规则。 + private async Task LoadInfoAsync() + { + CollectionInfo info = _owner.Info; + InfoLines = BuildInfo(info, _owner.Stats); + await _owner.LoadStatsAsync().ConfigureAwait(true); + InfoLines = BuildInfo(info, _owner.Stats); + } + + private List BuildInfo(CollectionInfo info, CollectionStats? stats) + { + var lines = new List + { + new(Loc["Cw_InfoNamespace"], info.Namespace), + new(Loc["Cw_InfoKind"], info.Kind.ToString()) + }; + if (info.ViewOn is { } source) + { + lines.Add(new(Loc["Cw_InfoViewOn"], source)); + } + if (stats is not null) + { + lines.Add(new(Loc["Cw_InfoCount"], BsonText.Grouped(stats.Count))); + lines.Add(new(Loc["Cw_InfoSize"], BsonText.Bytes(stats.Size))); + lines.Add(new(Loc["Cw_InfoAvg"], BsonText.Bytes(stats.AvgObjSize))); + lines.Add(new(Loc["Cw_InfoStorage"], BsonText.Bytes(stats.StorageSize))); + lines.Add(new(Loc["Cw_InfoIndexes"], $"{stats.IndexCount} · {BsonText.Bytes(stats.TotalIndexSize)}")); + foreach ((string name, long size) in stats.IndexSizes) + { + lines.Add(new(" " + name, BsonText.Bytes(size))); + } + } + if (info.CappedSize is { } capped) + { + lines.Add(new(Loc["Cw_InfoCapped"], BsonText.Bytes(capped))); + } + lines.Add(new(Loc["Cw_InfoValidator"], info.Validator is null ? Loc["Common_None"] : $"{info.ValidationLevel} · {info.ValidationAction}")); + return lines; + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DocTreeModels.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DocTreeModels.cs new file mode 100644 index 0000000..771792f --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DocTreeModels.cs @@ -0,0 +1,256 @@ +using System.Globalization; +using MongoDB.Bson; +using VelaShell.Plugin.Mongo.Bson; +using VelaShell.Plugin.Mongo.Staging; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 树视图(设计稿 13)的一行:文档行((2) 66f5c2a1…)或字段行(键 / 值 / 类型)。 +/// 树被摊平成一张列表交给虚拟化的 ListBox —— 展开一份 12 个字段的文档就是插入 12 行,而不是嵌套控件树。 +/// +internal sealed class DocTreeRow : ObservableObject +{ + private bool _isExpanded; + private InlineValueEditor? _editor; + + /// 构造。 + /// 所属网格行(同一份文档)。 + /// 字段路径;文档行为空串。 + /// 键名显示(数组元素是 [0])。 + /// 值;文档行是整份文档。 + /// 缩进层级(文档行 0)。 + /// 与暂存修改的关系。 + /// 被改字段的原值(原值 2);没改为 。 + /// 文案表。 + public DocTreeRow(CollectionRow row, string path, string key, BsonValue? value, int depth, ChangeRelation change, BsonValue? original, Loc loc) + { + Row = row; + Path = path; + Key = key; + Value = value; + Depth = depth; + Change = change; + Kind = path.Length == 0 ? BsonKind.Object : BsonKinds.Of(value); + ValueText = path.Length == 0 ? DocumentSummary(row, loc) : ValueOf(value, loc); + OriginalText = change == ChangeRelation.Exact && original is not null + ? loc.Format("Cw_TreeOriginal", BsonText.Inline(original, loc, shortenIds: true)) + : change == ChangeRelation.Exact ? loc["Cw_TreeAdded"] : ""; + } + + /// 所属网格行。 + public CollectionRow Row { get; } + + /// 字段路径;文档行为空串。 + public string Path { get; } + + /// 是不是文档行。 + public bool IsDocument => Path.Length == 0; + + /// 键名显示。 + public string Key { get; } + + /// 文档行的序号((2))。 + public string NumberText => IsDocument ? $"({Row.NumberText})" : ""; + + /// 值。 + public BsonValue? Value { get; } + + /// 类型。 + public BsonKind Kind { get; } + + /// 缩进层级。 + public int Depth { get; } + + /// 键列左内边距(设计稿:文档行 10,每层 +18)。 + public double Indent => 10 + Depth * 18; + + /// 与暂存修改的关系。 + public ChangeRelation Change { get; } + + /// 这一格本身被改了(橙底 + 左橙条)。 + public bool IsModified => Change is ChangeRelation.Exact or ChangeRelation.InsideChange; + + /// 值列文字。 + public string ValueText { get; } + + /// 值列后面的小字(原值 2 / 新增)。 + public string OriginalText { get; } + + /// 有没有那行小字。 + public bool HasOriginal => OriginalText.Length > 0; + + /// 值列颜色:容器 / 文档行退成三级灰,标量按类型。 + public string ValueToken => IsModified ? "VelaWarning" + : IsDocument || Kind is BsonKind.Object or BsonKind.Array ? "VelaTextTertiary" + : BsonKinds.ColorToken(Kind); + + /// 类型名(文档行写 Document)。 + public string TypeName => IsDocument ? "Document" : BsonKinds.Name(Kind); + + /// 类型色块令牌。 + public string TypeToken => IsDocument ? "VelaWarning" : BsonKinds.ColorToken(Kind); + + /// 能展开。 + public bool IsExpandable => IsDocument || Kind is BsonKind.Object or BsonKind.Array && ChildCount > 0; + + /// 子项个数。 + public int ChildCount => Value switch + { + BsonDocument doc => doc.ElementCount, + BsonArray array => array.Count, + _ => 0 + }; + + /// 展开着。 + public bool IsExpanded + { + get => _isExpanded; + set + { + if (SetProperty(ref _isExpanded, value)) + { + RaisePropertyChanged(nameof(Chevron)); + } + } + } + + /// 展开箭头。 + public string Chevron => _isExpanded ? "Mongo.chevron-down" : "Mongo.chevron-right"; + + /// 值列的内联编辑器。 + public InlineValueEditor? Editor + { + get => _editor; + set + { + if (SetProperty(ref _editor, value)) + { + RaisePropertyChanged(nameof(IsEditing)); + } + } + } + + /// 编辑中。 + public bool IsEditing => _editor is not null; + + /// 父路径(数组元素的父是数组)。 + public string ParentPath => Path.LastIndexOf('.') is var dot and > 0 ? Path[..dot] : ""; + + /// 是不是数组里的一个元素。 + public bool IsArrayElement { get; init; } + + /// + /// 「按此值筛选」生成的条件:数组元素下的字段按 MongoDB 的语义写成去掉下标的路径 + /// (items.0.sku → items.sku,匹配"任意一项的 sku 等于它")—— 这才是用户想要的那种筛选。 + /// + public string FilterPath => string.Join('.', Path.Split('.').Where(static s => !int.TryParse(s, NumberStyles.None, CultureInfo.InvariantCulture, out _))); + + /// 值的单行文字:字符串带引号、Decimal 写构造器(树里要看得出类型)。 + internal static string ValueOf(BsonValue? value, Loc loc) => BsonKinds.Of(value) switch + { + BsonKind.Object => loc.Format("Bson_FieldsSummary", value!.AsBsonDocument.ElementCount), + BsonKind.Array => loc.Format("Bson_ItemsSummary", value!.AsBsonArray.Count), + BsonKind.Decimal128 => $"NumberDecimal(\"{value!.AsDecimal128}\")", + BsonKind.Int64 => $"NumberLong({value!.AsInt64.ToString(CultureInfo.InvariantCulture)})", + BsonKind.Date => BsonText.FormatDate(value!), + _ => BsonText.Inline(value, loc) + }; + + /// 文档行的摘要:{ 12 个字段 } · SO2609-10403 · 张伟。 + private static string DocumentSummary(CollectionRow row, Loc loc) + { + BsonDocument doc = row.Document; + string head = loc.Format("Bson_FieldsSummary", doc.ElementCount); + string summary = DeepSummary(doc); + return summary.Length > 0 ? $"{head} · {summary}" : head; + } + + /// 头两个"像名字"的字符串(顶层或一层嵌套里):单号、客户名比 ObjectId 有辨识度。 + private static string DeepSummary(BsonDocument doc) + { + var parts = new List(2); + foreach (BsonElement element in doc) + { + if (element.Name == "_id") + { + continue; + } + if (element.Value is BsonString s && s.Value.Length is > 0 and <= 40) + { + parts.Add(s.Value); + } + else if (element.Value is BsonDocument nested + && nested.Elements.FirstOrDefault(static e => e.Value is BsonString { Value.Length: > 0 and <= 40 }) is { Value: BsonString inner }) + { + parts.Add(inner.Value); + } + if (parts.Count == 2) + { + break; + } + } + return BsonText.OneLine(string.Join(" · ", parts)); + } +} + +/// 把网格行摊平成树视图的行(按展开状态)。 +internal static class DocTreeBuilder +{ + /// 展开状态的键:行号:路径(文档行路径为空)。 + public static string ExpansionKey(CollectionRow row, string path) => $"{row.Number}{(row.Insert is null ? "" : "*" + row.Insert.Key)}:{path}"; + + /// 生成全部可见行。 + public static List Build(IEnumerable rows, IReadOnlySet expanded, StagingArea staging, Loc loc) + { + var list = new List(); + foreach (CollectionRow row in rows) + { + AppendDocument(list, row, expanded, staging, loc); + } + return list; + } + + /// 一份文档(及其展开的字段)的行。 + public static void AppendDocument(List list, CollectionRow row, IReadOnlySet expanded, StagingArea staging, Loc loc) + { + string key = row.Document.GetValue("_id", BsonNull.Value) is { IsBsonNull: false } id ? BsonText.Cell(id) : loc["Cw_AutoId"]; + var docRow = new DocTreeRow(row, "", key, row.Document, 0, ChangeRelation.None, null, loc) + { + IsExpanded = expanded.Contains(ExpansionKey(row, "")) + }; + list.Add(docRow); + if (docRow.IsExpanded) + { + AppendChildren(list, row, row.Document, "", 1, expanded, row.Insert is null ? staging.EditOf(row.Id) : null, loc); + } + } + + /// 一个容器的子行(递归展开)。 + public static void AppendChildren(List list, CollectionRow row, BsonValue container, string parent, int depth, + IReadOnlySet expanded, StagedEdit? edit, Loc loc) + { + IEnumerable<(string Name, string Key, BsonValue Value, bool IsElement)> children = container switch + { + BsonDocument doc => doc.Elements.Select(static e => (e.Name, e.Name, e.Value, false)), + BsonArray array => array.Select(static (v, i) => (i.ToString(CultureInfo.InvariantCulture), $"[{i}]", v, true)), + _ => [] + }; + foreach ((string name, string key, BsonValue value, bool isElement) in children) + { + string path = BsonPath.Join(parent.Length == 0 ? null : parent, name); + ChangeRelation relation = edit?.RelationOf(path) ?? (row.IsAdded ? ChangeRelation.None : ChangeRelation.None); + BsonValue? original = relation == ChangeRelation.Exact ? edit!.Find(path)?.Original : null; + var child = new DocTreeRow(row, path, key, value, depth, relation, original, loc) + { + IsArrayElement = isElement, + IsExpanded = expanded.Contains(ExpansionKey(row, path)) + }; + list.Add(child); + if (child.IsExpanded && child.IsExpandable) + { + AppendChildren(list, row, value, path, depth + 1, expanded, edit, loc); + } + } + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DocTreeView.axaml b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DocTreeView.axaml new file mode 100644 index 0000000..bc5b97b --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DocTreeView.axaml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DocTreeView.axaml.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DocTreeView.axaml.cs new file mode 100644 index 0000000..743fc29 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DocTreeView.axaml.cs @@ -0,0 +1,242 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using MongoDB.Bson; +using VelaShell.Plugin.Mongo.Bson; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 树视图:点箭头 / 双击文档行展开,Enter 编辑值、Ctrl+D 添加字段、Del 删除字段、Ctrl+C 复制值, +/// 右键菜单与设计稿 13 逐项一致(底部「筛选将生成 …」)。修改走与网格同一个暂存区。 +/// +public sealed partial class DocTreeView : UserControl +{ + private CollectionTabViewModel? _viewModel; + + /// 构造。 + public DocTreeView() + { + InitializeComponent(); + TreeList.AddHandler(PointerPressedEvent, OnPointerPressed, RoutingStrategies.Tunnel); + TreeList.AddHandler(KeyDownEvent, OnKeyDown, RoutingStrategies.Tunnel); + TreeList.AddHandler(Button.ClickEvent, OnDatePickClick); + TreeList.DoubleTapped += OnDoubleTapped; + TreeList.ContextRequested += OnContextRequested; + } + + /// 树列表(测试用)。 + internal ListBox List => TreeList; + + /// 接上视图模型。 + internal void Attach(CollectionTabViewModel viewModel) => _viewModel = viewModel; + + private void OnPointerPressed(object? sender, PointerPressedEventArgs e) + { + if (_viewModel is null || !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + { + return; + } + for (var visual = e.Source as Avalonia.Visual; visual is not null; visual = Avalonia.VisualTree.VisualExtensions.GetVisualParent(visual)) + { + if (visual is Panel { Classes: var classes } panel && classes.Contains("chevron") && panel.DataContext is DocTreeRow row) + { + e.Handled = true; + _viewModel.SelectedTreeRow = row; + _viewModel.ToggleTree(row); + return; + } + if (visual is ListBoxItem) + { + return; + } + } + } + + private void OnDoubleTapped(object? sender, TappedEventArgs e) + { + if (_viewModel is null || GridPaneView.Find(e.Source) is not { IsEditing: false } row) + { + return; + } + if (row.IsExpandable) + { + _viewModel.ToggleTree(row); + } + else + { + BeginEdit(row); + } + } + + private void BeginEdit(DocTreeRow row) + { + if (_viewModel is not { } vm || row.IsDocument || row.Row.IsDeleted) + { + return; + } + if (row.Path == "_id" && row.Row.Insert is null) + { + vm.Workspace.Toast(new() { Title = vm.Loc["Cw_IdImmutable"], Kind = ToastKind.Warning }); + return; + } + if (!vm.EnsureCanWrite()) + { + return; + } + foreach (DocTreeRow other in vm.TreeRows.Where(static r => r.IsEditing)) + { + other.Editor = null; + } + vm.SelectedTreeRow = row; + row.Editor = vm.CreateEditor(row.Value, row.Path, vm.Sample.KindOf(row.Path)); + } + + /// 日期值编辑框右边的日历按钮:选一天 / 改时刻即写入暂存区。 + private void OnDatePickClick(object? sender, RoutedEventArgs e) + { + if (e.Source is not Button { Classes: var classes } button || !classes.Contains("datepick") + || _viewModel is not { } vm || GridPaneView.Find(button) is not { Editor: { } editor } row) + { + return; + } + e.Handled = true; + DatePickFlyout.Show(button, vm.Loc, editor.Text, text => + { + editor.Text = text; + if (vm.CommitEditor(editor, row.Row, row.Path)) + { + row.Editor = null; + TreeList.Focus(); + } + }); + } + + private void OnKeyDown(object? sender, KeyEventArgs e) + { + if (_viewModel is not { } vm) + { + return; + } + if (e.Source is TextBox && GridPaneView.Find(e.Source) is { IsEditing: true, Editor: { } editor } editing) + { + if (e.Key == Key.Enter) + { + e.Handled = true; + if (vm.CommitEditor(editor, editing.Row, editing.Path)) + { + editing.Editor = null; + TreeList.Focus(); + } + } + else if (e.Key == Key.Escape) + { + e.Handled = true; + editing.Editor = null; + TreeList.Focus(); + } + return; + } + if (e.Source is TextBox || vm.SelectedTreeRow is not { } row) + { + return; + } + bool control = e.KeyModifiers.HasFlag(KeyModifiers.Control); + switch (e.Key) + { + case Key.Enter or Key.F2 when !control: + e.Handled = true; + if (row.IsExpandable && e.Key == Key.Enter) + { + vm.ToggleTree(row); + } + else + { + BeginEdit(row); + } + break; + case Key.Right when row.IsExpandable && !row.IsExpanded: + case Key.Left when row.IsExpandable && row.IsExpanded: + e.Handled = true; + vm.ToggleTree(row); + break; + case Key.D when control: + e.Handled = true; + vm.PromptAddField(row.Row, ContainerFor(row)); + break; + case Key.C when control: + e.Handled = true; + _ = vm.CopyValueAsync(row.IsDocument ? row.Row.Document : row.Value); + break; + case Key.Delete: + e.Handled = true; + if (row.IsDocument) + { + vm.StageDelete([row.Row]); + } + else if (row.Path != "_id") + { + vm.DeleteField(row.Row, row.Path); + } + break; + } + } + + /// 右键菜单(设计稿 13 逐项)。 + private void OnContextRequested(object? sender, ContextRequestedEventArgs e) + { + if (_viewModel is not { } vm || GridPaneView.Find(e.Source) is not { } row) + { + return; + } + vm.SelectedTreeRow = row; + Loc loc = vm.Loc; + CollectionRow doc = row.Row; + bool writable = vm.IsEditable && !doc.IsDeleted; + BsonValue? value = row.IsDocument ? doc.Document : row.Value; + string path = row.Path; + string? arrayPath = row.Kind == BsonKind.Array ? path : row.IsArrayElement ? row.ParentPath : null; + var items = new List + { + CollectionMenus.Item(loc["Cw_MenuEditValue"], "Mongo.pencil", "Enter", () => BeginEdit(row), accent: true, + enabled: writable && !row.IsDocument && path != "_id"), + CollectionMenus.Submenu(loc["Cw_MenuChangeType"], "Mongo.shapes", + CollectionMenus.KindItems(row.Kind, kind => vm.ChangeType(doc, path, kind)), writable && !row.IsDocument && path != "_id"), + CollectionMenus.Item(loc["Cw_MenuAddFieldEllipsis"], "Mongo.plus", "Ctrl+D", () => vm.PromptAddField(doc, ContainerFor(row)), enabled: writable), + CollectionMenus.Item(loc["Cw_MenuInsertElement"], "Mongo.list-plus", null, () => vm.InsertArrayElement(doc, arrayPath!), + enabled: writable && arrayPath is not null), + CollectionMenus.Separator(), + CollectionMenus.Item(loc["Cw_MenuCopyValue"], "Mongo.copy", "Ctrl+C", () => _ = vm.CopyValueAsync(value)), + CollectionMenus.Item(loc["Cw_MenuCopyPath"], "Mongo.route", path.Length == 0 ? null : path, () => _ = vm.CopyTextAsync(path), + enabled: path.Length > 0), + CollectionMenus.Item(loc["Cw_MenuCopyJson"], "Mongo.braces", null, () => _ = vm.CopyValueAsJsonAsync(value)), + CollectionMenus.Separator() + }; + if (!row.IsDocument) + { + string filterPath = row.FilterPath; + items.Add(CollectionMenus.Item(loc["Cw_MenuFilterBy"], "Mongo.funnel", null, () => _ = vm.FilterByAsync(filterPath, value))); + items.Add(CollectionMenus.Item(loc["Cw_MenuOpenQuery"], "Mongo.file-code", null, () => vm.OpenInQuery(filterPath, value))); + items.Add(CollectionMenus.Separator()); + items.Add(CollectionMenus.Item(loc["Cw_MenuDeleteField"], "Mongo.trash-2", "Del", () => vm.DeleteField(doc, path), danger: true, + enabled: writable && path != "_id")); + items.Add(CollectionMenus.FilterHint(loc, filterPath, value, () => _ = vm.FilterByAsync(filterPath, value))); + } + else + { + items.Add(CollectionMenus.Item(loc["Cw_MenuOpenEditor"], "Mongo.file-pen-line", null, () => vm.OpenInEditor(doc), enabled: vm.IsEditable)); + items.Add(CollectionMenus.Separator()); + items.Add(CollectionMenus.Item(doc.IsDeleted ? loc["Cw_MenuUndelete"] : loc["Cw_MenuDeleteDoc"], "Mongo.trash-2", "Del", + () => vm.StageDelete([doc]), danger: true, enabled: vm.IsEditable)); + } + CollectionMenus.Open(e.Source as Control ?? TreeList, items); + e.Handled = true; + } + + /// 「添加字段」加在哪个对象下:文档行加在顶层,对象行加在它里面,其余加在它的父对象里。 + private static string ContainerFor(DocTreeRow row) => + row.IsDocument ? "" + : row.Kind == BsonKind.Object ? row.Path + : row.IsArrayElement ? (row.ParentPath.LastIndexOf('.') is var dot and > 0 ? row.ParentPath[..dot] : "") + : row.ParentPath; +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DocumentEditorDialogView.axaml b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DocumentEditorDialogView.axaml new file mode 100644 index 0000000..4a73dfb --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DocumentEditorDialogView.axaml @@ -0,0 +1,730 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DocumentEditorDialogView.axaml.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DocumentEditorDialogView.axaml.cs new file mode 100644 index 0000000..5f1c2e7 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DocumentEditorDialogView.axaml.cs @@ -0,0 +1,459 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Input.Platform; +using Avalonia.Interactivity; +using Avalonia.Threading; +using Avalonia.VisualTree; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 文档编辑器的视图。code-behind 只做视图模型做不了的那几样:读剪贴板、拖动排序的指针跟踪、 +/// 字段名补全弹层的键盘导航、日历弹层、把焦点放到新加的字段上,以及 Ctrl+S / Ctrl+Enter / Ctrl+F。 +/// +public sealed partial class DocumentEditorDialogView : UserControl +{ + private DocumentEditorDialogViewModel? _subscribed; + private TopLevel? _topLevel; + private DocumentEditorRow? _dragRow; + private DocumentEditorRow? _dropRow; + private bool _dropAfter; + private TextBox? _suggestBox; + private DocumentEditorRow? _suggestRow; + + /// 用给定的视图模型初始化。 + internal DocumentEditorDialogView(DocumentEditorDialogViewModel viewModel) + : this() + { + DataContext = viewModel; + } + + /// 设计器 / headless 用的无参构造。 + public DocumentEditorDialogView() + { + InitializeComponent(); + } + + private DocumentEditorDialogViewModel? ViewModel => DataContext as DocumentEditorDialogViewModel; + + // ── 生命周期:订阅"把焦点放到新字段上" ─────────────────────────────────── + + /// + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + Subscribe(ViewModel); + // 快捷键挂在窗口的隧道上:焦点落在空处(关掉查找框、点了不可聚焦的地方)时 Ctrl+S 照样要能保存。 + // 是否接手由 OwnsKeyboard 判断 —— 宿主切到别的标签页时这个视图不可见,不能抢别人的 Ctrl+S。 + _topLevel = TopLevel.GetTopLevel(this); + _topLevel?.AddHandler(KeyDownEvent, OnPreviewKeyDown, RoutingStrategies.Tunnel); + } + + /// + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + // 外壳弹确认框时会把这个视图摘下来、确认完再建一个新的:订阅跟着视图走,不跟视图模型走。 + Subscribe(null); + _topLevel?.RemoveHandler(KeyDownEvent, OnPreviewKeyDown); + _topLevel = null; + NamePopup.IsOpen = false; + base.OnDetachedFromVisualTree(e); + } + + /// + protected override void OnDataContextChanged(EventArgs e) + { + base.OnDataContextChanged(e); + if (this.IsAttachedToVisualTree()) + { + Subscribe(ViewModel); + } + } + + private void Subscribe(DocumentEditorDialogViewModel? viewModel) + { + if (_subscribed is not null) + { + _subscribed.FocusRequested -= OnFocusRequested; + _subscribed.PropertyChanged -= OnViewModelPropertyChanged; + } + _subscribed = viewModel; + if (viewModel is not null) + { + viewModel.FocusRequested += OnFocusRequested; + viewModel.PropertyChanged += OnViewModelPropertyChanged; + } + } + + private void OnViewModelPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + // 点「查找字段」打开查找框时直接把光标放进去。 + if (e.PropertyName == nameof(DocumentEditorDialogViewModel.IsSearching) && _subscribed is { IsSearching: true }) + { + Dispatcher.UIThread.Post(() => SearchBox.Focus(), DispatcherPriority.Loaded); + } + } + + private void OnFocusRequested(DocumentEditorRow row) => + // 新行要等列表布局完、容器实现出来才找得到它的字段名框。 + Dispatcher.UIThread.Post(() => FocusRow(row, retry: true), DispatcherPriority.Loaded); + + private void FocusRow(DocumentEditorRow row, bool retry) + { + if (FormList.ContainerFromItem(row) is not Control container) + { + if (retry) + { + FormList.ScrollIntoView(row); + Dispatcher.UIThread.Post(() => FocusRow(row, retry: false), DispatcherPriority.Loaded); + } + return; + } + container.BringIntoView(); + TextBox? box = container.GetVisualDescendants().OfType() + .FirstOrDefault(static t => t.Name == "KeyBox" && t.IsEffectivelyVisible) + ?? container.GetVisualDescendants().OfType().FirstOrDefault(static t => t.IsEffectivelyVisible && !t.IsReadOnly); + if (box is null) + { + return; + } + box.Focus(); + // 占位名(newField)全选:一打字就替换掉。 + box.SelectAll(); + } + + // ── 键盘 ──────────────────────────────────────────────────────────────── + + private void OnPreviewKeyDown(object? sender, KeyEventArgs e) + { + if (ViewModel is not { } vm || e.Handled || !OwnsKeyboard()) + { + return; + } + bool control = e.KeyModifiers.HasFlag(KeyModifiers.Control); + if (NamePopup.IsOpen && _suggestBox is { IsFocused: true } && HandleSuggestKey(e.Key)) + { + e.Handled = true; + return; + } + if (control && e.Key == Key.S) + { + if (vm.SaveCommand.CanExecute(null)) + { + vm.SaveCommand.Execute(null); + } + e.Handled = true; + } + else if (control && e.Key == Key.Enter) + { + if (vm.IsFormMode) + { + if (FocusedRow() is { } row) + { + vm.AddAfter(row); + } + else + { + vm.AddFieldAtEnd(); + } + } + e.Handled = true; + } + else if (control && e.Key == Key.F && vm.IsFormMode) + { + if (vm.IsSearching) + { + Dispatcher.UIThread.Post(() => SearchBox.Focus(), DispatcherPriority.Loaded); + } + vm.IsSearching = true; + e.Handled = true; + } + else if (e.Key == Key.Escape && SearchBox.IsFocused) + { + vm.IsSearching = false; + e.Handled = true; + } + } + + /// + /// 键盘归不归这个对话框:它得看得见(宿主没切到别的标签),且焦点在它里面或哪儿都不在 + /// (对话框是模态的,焦点"不在任何控件上"时按键只可能是冲着它来的)。 + /// + private bool OwnsKeyboard() + { + if (!IsEffectivelyVisible) + { + return false; + } + object? focused = _topLevel?.FocusManager?.GetFocusedElement(); + return focused is null or TopLevel || (focused is Visual visual && (ReferenceEquals(visual, this) || this.IsVisualAncestorOf(visual))); + } + + /// 焦点所在的那一行(焦点控件的数据上下文)。 + private DocumentEditorRow? FocusedRow() + { + for (Visual? visual = TopLevel.GetTopLevel(this)?.FocusManager?.GetFocusedElement() as Visual; + visual is not null && !ReferenceEquals(visual, this); + visual = visual.GetVisualParent()) + { + if (visual is Control { DataContext: DocumentEditorRow row }) + { + return row; + } + } + return null; + } + + // ── 剪贴板 ────────────────────────────────────────────────────────────── + + private async void OnPasteClick(object? sender, RoutedEventArgs e) + { + if (ViewModel is not { } vm) + { + return; + } + string? text = null; + try + { + if (TopLevel.GetTopLevel(this)?.Clipboard is { } clipboard) + { + text = await clipboard.TryGetTextAsync().ConfigureAwait(true); + } + } + catch (Exception ex) when (ex is InvalidOperationException or NotSupportedException or System.Runtime.InteropServices.COMException) + { + // 剪贴板被别的进程占着之类:当作空,交给视图模型提示"剪贴板里没有文档"。 + text = null; + } + vm.ApplyPastedText(text); + } + + // ── 拖动排序 ──────────────────────────────────────────────────────────── + + private void OnGripPressed(object? sender, PointerPressedEventArgs e) + { + if (sender is not Control { DataContext: DocumentEditorRow row } grip || !row.CanDrag + || !e.GetCurrentPoint(grip).Properties.IsLeftButtonPressed) + { + return; + } + _dragRow = row; + _dropRow = null; + e.Pointer.Capture(grip); + e.Handled = true; + } + + private void OnGripMoved(object? sender, PointerEventArgs e) + { + if (_dragRow is null) + { + return; + } + Point at = e.GetPosition(FormList); + _dropRow = null; + foreach (Control container in FormList.GetRealizedContainers()) + { + if (container.DataContext is not DocumentEditorRow row || container.TranslatePoint(default, FormList) is not { } origin) + { + continue; + } + double top = origin.Y; + double height = container.Bounds.Height; + if (at.Y >= top && at.Y < top + height) + { + _dropRow = row; + _dropAfter = at.Y > top + (height / 2); + DropLine.Width = FormList.Bounds.Width; + Canvas.SetTop(DropLine, (_dropAfter ? top + height : top) - 1); + DropLine.IsVisible = !ReferenceEquals(row, _dragRow); + break; + } + } + if (_dropRow is null) + { + DropLine.IsVisible = false; + } + } + + private void OnGripReleased(object? sender, PointerReleasedEventArgs e) + { + if (_dragRow is { } row && _dropRow is { } target && !ReferenceEquals(row, target)) + { + ViewModel?.Move(row, target, _dropAfter); + } + EndDrag(); + e.Pointer.Capture(null); + } + + private void OnGripCaptureLost(object? sender, PointerCaptureLostEventArgs e) => EndDrag(); + + private void EndDrag() + { + _dragRow = null; + _dropRow = null; + DropLine.IsVisible = false; + } + + // ── 字段名补全 ────────────────────────────────────────────────────────── + + private void OnKeyBoxGotFocus(object? sender, FocusChangedEventArgs e) + { + if (sender is not TextBox { DataContext: DocumentEditorRow row } box || ViewModel is not { } vm) + { + return; + } + _suggestBox = box; + _suggestRow = row; + // 只给新加的字段主动弹:点进一个已有字段的名字多半是想改个字母,弹一屏建议是打扰。 + if (row.OriginalValue is null) + { + _ = SuggestAfterSampleAsync(vm, box); + } + } + + private async Task SuggestAfterSampleAsync(DocumentEditorDialogViewModel vm, TextBox box) + { + await vm.EnsureSampleAsync(reportErrors: false).ConfigureAwait(true); + if (ReferenceEquals(_suggestBox, box) && box.IsFocused) + { + UpdateSuggestions(); + } + } + + private void OnKeyBoxTextChanged(object? sender, TextChangedEventArgs e) + { + if (sender is TextBox { IsFocused: true } box && ReferenceEquals(box, _suggestBox)) + { + UpdateSuggestions(); + } + } + + private void OnKeyBoxLostFocus(object? sender, RoutedEventArgs e) => + Dispatcher.UIThread.Post(() => + { + if (_suggestBox is not { IsFocused: true }) + { + NamePopup.IsOpen = false; + } + }, DispatcherPriority.Background); + + private void UpdateSuggestions() + { + if (ViewModel is not { } vm || _suggestBox is not { } box || _suggestRow is not { } row) + { + return; + } + IReadOnlyList suggestions = vm.SuggestNames(row, box.Text ?? ""); + if (suggestions.Count == 0) + { + NamePopup.IsOpen = false; + return; + } + SuggestList.ItemsSource = suggestions; + SuggestList.SelectedIndex = 0; + NamePopup.PlacementTarget = box; + NamePopup.IsOpen = true; + } + + private bool HandleSuggestKey(Key key) + { + int count = SuggestList.ItemCount; + switch (key) + { + case Key.Down: + SuggestList.SelectedIndex = count == 0 ? -1 : (SuggestList.SelectedIndex + 1) % count; + return true; + case Key.Up: + SuggestList.SelectedIndex = count == 0 ? -1 : (SuggestList.SelectedIndex - 1 + count) % count; + return true; + case Key.Enter or Key.Tab: + AcceptSuggestion(); + return true; + case Key.Escape: + NamePopup.IsOpen = false; + return true; + default: + return false; + } + } + + private void OnSuggestTapped(object? sender, TappedEventArgs e) => AcceptSuggestion(); + + private void AcceptSuggestion() + { + if (ViewModel is { } vm && _suggestRow is { } row && SuggestList.SelectedItem is DocumentEditorSuggestion suggestion) + { + vm.AcceptSuggestion(row, suggestion); + if (_suggestBox is { } box) + { + box.CaretIndex = box.Text?.Length ?? 0; + } + } + NamePopup.IsOpen = false; + } + + // ── 芯片行的「+ String」 ──────────────────────────────────────────────── + + private void OnItemBoxPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) + { + if (e.Property == IsVisibleProperty && e.NewValue is true && sender is TextBox box) + { + Dispatcher.UIThread.Post(() => box.Focus(), DispatcherPriority.Loaded); + } + } + + private void OnItemKeyDown(object? sender, KeyEventArgs e) + { + if (sender is not TextBox { DataContext: DocumentEditorRow row }) + { + return; + } + if (e.Key == Key.Enter) + { + row.CommitItemCommand.Execute(null); + e.Handled = true; + } + else if (e.Key == Key.Escape) + { + row.CancelItemCommand.Execute(null); + e.Handled = true; + } + } + + private void OnItemLostFocus(object? sender, RoutedEventArgs e) + { + if (sender is TextBox { DataContext: DocumentEditorRow row }) + { + row.FinishItem(); + } + } + + // ── 日历 ──────────────────────────────────────────────────────────────── + + private void OnCalendarClick(object? sender, RoutedEventArgs e) + { + if (sender is not Button { DataContext: DocumentEditorRow row } button || !row.IsValueEditable) + { + return; + } + DateTime day = row.LocalDate ?? DateTime.Now; + var calendar = new Calendar + { + SelectionMode = CalendarSelectionMode.SingleDate, + SelectedDate = day.Date, + DisplayDate = day.Date + }; + var flyout = new Flyout { Content = calendar, Placement = PlacementMode.BottomEdgeAlignedLeft }; + calendar.SelectedDatesChanged += (_, _) => + { + if (calendar.SelectedDate is { } picked) + { + row.PickDate(picked); + flyout.Hide(); + } + }; + flyout.ShowAt(button); + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DocumentEditorDialogViewModel.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DocumentEditorDialogViewModel.cs new file mode 100644 index 0000000..6b9ffab --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DocumentEditorDialogViewModel.cs @@ -0,0 +1,1816 @@ +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Globalization; +using Avalonia.Threading; +using MongoDB.Bson; +using MongoDB.Driver; +using VelaShell.Plugin.Mongo.Analysis; +using VelaShell.Plugin.Mongo.Bson; +using VelaShell.Plugin.Mongo.Core; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 文档编辑器的三种模式(子标题行的分段)。 +internal enum DocumentEditorMode +{ + /// 表单。 + Form, + + /// JSON(mongosh 写法,与表单双向同步)。 + Json, + + /// 对比(原文档 vs 当前,逐行)。 + Diff +} + +/// +/// 文档编辑器(设计稿 05:表单 + 实时预览)。 +/// +/// 一份文档的编辑过程有三样东西要时刻对得上:表单里的行树(编辑中的文档本身)、 +/// 与原文档的差异(决定行的橙 / 绿底与将要发出的 $set / $unset)、 +/// 集合的验证规则(决定红底与能不能保存)。每次编辑之后在 UI 线程空闲时统一重算一遍 —— +/// 而不是让每一行各自维护状态:一行是"新增"还是"修改",取决于它在整份文档里的路径, +/// 拖动、改名、换类型都会让它变。 +/// +/// +/// 保存只提交变更字段;原文档带 updatedAt 时筛选里一并带上原值做乐观并发 —— +/// 匹配不到就是别人先改了,提示冲突而不是覆盖回去。 +/// +/// +internal sealed class DocumentEditorDialogViewModel : DialogViewModel +{ + /// "按 Schema 补全"的出现率门槛:三成以上的文档都有、这份没有,才算"缺"。 + internal const double FillThreshold = 0.3; + + private readonly List _roots = []; + private readonly DocumentEditorTail _tail; + private readonly HashSet _expanded = new(StringComparer.Ordinal); + private readonly HashSet _collapsed = new(StringComparer.Ordinal); + private DocumentEditorMode _mode; + private bool _isSearching; + private string _search = ""; + private bool _recomputePending; + private bool _jsonStale = true; + private bool _applyingJson; + private bool _regeneratingJson; + private DispatcherTimer? _jsonTimer; + private string _jsonText = ""; + private string? _jsonError; + private IReadOnlyList _jsonDiagnostics = []; + private string _previewText = ""; + private IReadOnlyList _previewMarks = []; + private string _commandName = ""; + private string _commandText = ""; + private string _commandNote = ""; + private string _diffLeft = ""; + private string _diffRight = ""; + private IReadOnlyList _diffLeftMarks = []; + private IReadOnlyList _diffRightMarks = []; + private string _idText = ""; + private int _modified; + private int _added; + private int _removed; + private int _errors; + private int _warnings; + private bool _reordered; + private bool _dirty; + private bool _busy; + private bool _closed; + private DocumentEditorPlan? _plan; + private DocumentEditorSchema? _schema; + private Task? _sampling; + private string? _sampleError; + + /// 构造。 + /// 外壳服务。 + /// 所属集合。 + /// 要编辑的文档; = 新建。 + /// 保存成功后回调(参数是写进去的那份文档;删除为 null)—— 集合工作台据此刷新。 + public DocumentEditorDialogViewModel(IMongoWorkspace workspace, CollectionInfo collection, BsonDocument? document, Func? saved) + : base(workspace) + { + Collection = collection; + // 没有 _id 的文档是**新文档的模板**(网格的「克隆」就这样传进来):按插入处理, + // 而不是当成一份已存在的文档去 updateOne —— 那会因为筛选里没有 _id 而改不到任何东西。 + bool template = document is not null && !document.Contains("_id"); + // 拷一份:调用方(网格)手里那份文档之后还会被它自己改,原文档必须是打开那一刻的样子。 + Original = template ? null : document?.DeepClone().AsBsonDocument; + Saved = saved; + Title = workspace.Loc[!collection.IsEditable ? "Doc_TitleView" : Original is null ? "Doc_TitleNew" : "Doc_TitleEdit"]; + Subtitle = collection.Namespace; + _tail = new(this); + + SaveCommand = new AsyncCommand(() => SaveAsync(asNew: false), () => CanSave); + SaveAsNewCommand = new AsyncCommand(() => SaveAsync(asNew: true), () => CanSaveAsNew); + FillCommand = new AsyncCommand(FillFromSchemaAsync, () => CanEdit); + AddFieldCommand = new RelayCommand(() => AddFieldAtEnd(), () => CanEdit); + CopyCommand = new AsyncCommand(CopyAsync); + ToggleSearchCommand = new RelayCommand(() => IsSearching = !IsSearching); + + BsonDocument initial = Original?.DeepClone().AsBsonDocument ?? new BsonDocument("_id", ObjectId.GenerateNewId()); + if (template) + { + initial.AddRange(document!.DeepClone().AsBsonDocument); + } + LoadRows(initial, keepExpansion: false); + _dirty = false; + Workspace.Guard.Changed += OnGuardChanged; + RecomputeNow(); + } + + // ── 约定的公开面 ──────────────────────────────────────────────────────── + + /// 所属集合。 + public CollectionInfo Collection { get; } + + /// 原文档(冲突后"基于最新版本重做"会换成服务器上的最新版本)。 + public BsonDocument? Original { get; private set; } + + /// 保存回调。 + public Func? Saved { get; } + + /// + public override string IconKey => "Mongo.file-pen-line"; + + /// + public override double Width => 1100; + + /// + public override double Height => 708; + + /// 有未保存的修改时 Esc 不关(误触一下就丢掉一屏编辑,代价太大)。 + public override bool CanCloseWithEscape => !_dirty && !_busy; + + // ── 状态 ──────────────────────────────────────────────────────────────── + + /// 编辑的是库里已有的文档(否则是新建)。 + public bool IsExisting => Original is not null; + + /// 视图 / 系统集合:只能看。 + public bool IsReadOnlyView => !Collection.IsEditable; + + /// 能编辑(不是只读视图)。 + public bool CanEdit => !IsReadOnlyView; + + /// 正在保存。 + public bool IsBusy + { + get => _busy; + private set + { + if (SetProperty(ref _busy, value)) + { + RaiseSaveState(); + } + } + } + + /// 子标题行的 _id 芯片:ObjectId("…")。 + public string IdText + { + get => _idText; + private set => SetProperty(ref _idText, value); + } + + /// 当前模式。 + public DocumentEditorMode Mode + { + get => _mode; + set + { + if (_mode == value) + { + return; + } + if (_mode == DocumentEditorMode.Json) + { + LeaveJson(); + } + _mode = value; + if (value == DocumentEditorMode.Json && _jsonStale) + { + RegenerateJson(); + } + RaisePropertiesChanged(nameof(Mode), nameof(IsFormMode), nameof(IsJsonMode), nameof(IsDiffMode)); + RecomputeNow(); + } + } + + /// 表单模式(分段按钮)。 + public bool IsFormMode + { + get => _mode == DocumentEditorMode.Form; + set + { + if (value) + { + Mode = DocumentEditorMode.Form; + } + } + } + + /// JSON 模式。 + public bool IsJsonMode + { + get => _mode == DocumentEditorMode.Json; + set + { + if (value) + { + Mode = DocumentEditorMode.Json; + } + } + } + + /// 对比模式。 + public bool IsDiffMode + { + get => _mode == DocumentEditorMode.Diff; + set + { + if (value) + { + Mode = DocumentEditorMode.Diff; + } + } + } + + // ── 查找字段 ──────────────────────────────────────────────────────────── + + /// 查找框开着。 + public bool IsSearching + { + get => _isSearching; + set + { + if (SetProperty(ref _isSearching, value) && !value) + { + Search = ""; + } + } + } + + /// 查找的字段名片段(过滤表单行;命中嵌套字段时连同它的祖先一起显示)。 + public string Search + { + get => _search; + set + { + if (SetProperty(ref _search, value ?? "")) + { + RefreshVisible(); + } + } + } + + // ── 表单 ──────────────────────────────────────────────────────────────── + + /// 表单里看得见的行(展开、查找之后的扁平列表;末尾是「添加字段」那一格)。 + public ObservableCollection FormItems { get; } = []; + + /// 落不到具体哪一行上的问题(缺少必填字段、根上的类型…)。 + public ObservableCollection Issues { get; } = []; + + /// 顶层行(测试与视图的拖动用)。 + internal IReadOnlyList Roots => _roots; + + /// 请视图把焦点放到某一行的字段名上(新加的字段)。 + public event Action? FocusRequested; + + // ── JSON 与对比 ───────────────────────────────────────────────────────── + + /// JSON 模式的文本(mongosh 写法;改它 = 改表单)。 + public string JsonText + { + get => _jsonText; + set + { + value ??= ""; + if (_jsonText == value) + { + return; + } + _jsonText = value; + RaisePropertyChanged(); + if (_regeneratingJson || !CanEdit) + { + return; + } + _dirty = true; + // 边打字边解析会让表单在半截文本上来回跳:停手 300ms 再应用。 + _jsonTimer ??= CreateJsonTimer(); + _jsonTimer.Stop(); + _jsonTimer.Start(); + } + } + + /// JSON 的语法诊断。 + public IReadOnlyList JsonDiagnostics + { + get => _jsonDiagnostics; + private set => SetProperty(ref _jsonDiagnostics, value); + } + + /// 对比:原文档。 + public string DiffLeftText + { + get => _diffLeft; + private set => SetProperty(ref _diffLeft, value); + } + + /// 对比:当前。 + public string DiffRightText + { + get => _diffRight; + private set => SetProperty(ref _diffRight, value); + } + + /// 对比:原文档里被删掉的行(红)。 + public IReadOnlyList DiffLeftMarks + { + get => _diffLeftMarks; + private set => SetProperty(ref _diffLeftMarks, value); + } + + /// 对比:当前里新增的行(绿)。 + public IReadOnlyList DiffRightMarks + { + get => _diffRightMarks; + private set => SetProperty(ref _diffRightMarks, value); + } + + // ── 预览与命令 ────────────────────────────────────────────────────────── + + /// 实时预览(紧凑的 mongosh 文本)。 + public string PreviewText + { + get => _previewText; + private set => SetProperty(ref _previewText, value); + } + + /// 预览里与原文档不同的行。 + public IReadOnlyList PreviewMarks + { + get => _previewMarks; + private set => SetProperty(ref _previewMarks, value); + } + + /// 将执行的命令名(updateOne / replaceOne / insertOne)。 + public string CommandName + { + get => _commandName; + private set => SetProperty(ref _commandName, value); + } + + /// 将执行的命令全文。 + public string CommandText + { + get => _commandText; + private set => SetProperty(ref _commandText, value); + } + + /// 命令下面那行说明(只提交变更字段 · 乐观并发…)。 + public string CommandNote + { + get => _commandNote; + private set => SetProperty(ref _commandNote, value); + } + + // ── 底栏 ──────────────────────────────────────────────────────────────── + + /// 1 处修改。 + public string ModifiedText => Loc.Format("Doc_CountModified", _modified); + + /// 有修改。 + public bool HasModified => _modified > 0; + + /// 1 个新字段。 + public string AddedText => Loc.Format("Doc_CountAdded", _added); + + /// 有新字段。 + public bool HasAdded => _added > 0; + + /// 1 处删除。 + public string RemovedText => Loc.Format("Doc_CountRemoved", _removed); + + /// 有删除。 + public bool HasRemoved => _removed > 0; + + /// 1 个错误。 + public string ErrorText => Loc.Format("Doc_CountErrors", _errors); + + /// 有错误(挡保存)。 + public bool HasErrors => _errors > 0; + + /// 1 个警告(validationAction = warn)。 + public string WarningText => Loc.Format("Doc_CountWarnings", _warnings); + + /// 有警告。 + public bool HasWarnings => _warnings > 0; + + /// 顶层字段顺序变了(要整份替换)。 + public bool HasReordered => _reordered; + + /// 字段顺序已调整。 + public string ReorderedText => Loc["Doc_Reordered"]; + + /// 新文档:新文档 · 9 个字段。 + public string NewDocumentText => Loc.Format("Doc_NewFields", _roots.Count); + + /// 显示新文档的字段数。 + public bool ShowNewDocument => !IsExisting; + + /// 有要提交的东西。 + public bool HasChanges => !IsExisting || _plan is { IsEmpty: false }; + + /// 底栏的灰字提示。 + public string FooterHint => + IsReadOnlyView ? Loc["Doc_ViewReadOnly"] + : HasErrors ? Loc["Doc_FixToSave"] + : Workspace.Guard.IsReadOnly ? Loc["Doc_ReadOnlyMode"] + : !HasChanges ? Loc["Doc_NoChanges"] + : ""; + + /// 能保存。 + public bool CanSave => CanEdit && !_busy && _errors == 0 && _jsonError is null && HasChanges; + + /// 能另存为新文档。 + public bool CanSaveAsNew => CanEdit && !_busy && _errors == 0 && _jsonError is null; + + // ── 命令 ──────────────────────────────────────────────────────────────── + + /// 保存(Ctrl+S)。 + public AsyncCommand SaveCommand { get; } + + /// 另存为新文档(去掉 _id 插入)。 + public AsyncCommand SaveAsNewCommand { get; } + + /// 按 Schema 补全缺失字段。 + public AsyncCommand FillCommand { get; } + + /// 添加字段(末尾)。 + public RelayCommand AddFieldCommand { get; } + + /// 复制当前文档(完整的 mongosh 文本)。 + public AsyncCommand CopyCommand { get; } + + /// 开 / 关查找框。 + public RelayCommand ToggleSearchCommand { get; } + + // ── 行树 ──────────────────────────────────────────────────────────────── + + /// 当前文档(由行树拼出)。 + public BsonDocument BuildDocument() => DocumentEditorRow.BuildDocument(_roots); + + /// 按路径找行(只穿过对象,不穿过数组)。 + internal DocumentEditorRow? FindRow(string path) + { + IReadOnlyList level = _roots; + DocumentEditorRow? found = null; + foreach (string segment in path.Split('.')) + { + found = level.FirstOrDefault(r => r.Name == segment); + if (found is null) + { + return null; + } + level = found.Children; + } + return found; + } + + /// 整份换掉行树(打开、JSON 应用、粘贴、冲突重做);展开状态按路径保留。 + private void LoadRows(BsonDocument document, bool keepExpansion) + { + if (!keepExpansion) + { + _expanded.Clear(); + _collapsed.Clear(); + } + _roots.Clear(); + foreach (BsonElement element in document) + { + var row = new DocumentEditorRow(this, element.Name, element.Value, null); + _roots.Add(row); + ApplyExpansion(row); + } + if (!_applyingJson) + { + _jsonStale = true; + } + _dirty = true; + RaisePropertyChanged(nameof(NewDocumentText)); + RefreshVisible(); + ScheduleRecompute(); + } + + /// + /// 默认展开:顶层的小对象(customer 那种三五个字段的)展开,数组与深层对象收起 —— + /// 设计稿就是这样:customer 展开、items 与 tags 收起。用户点过的按路径记住。 + /// + private void ApplyExpansion(DocumentEditorRow row) + { + // 每行只定一次:之后的结构性编辑(删兄弟、加子项)不该把用户或程序已经定好的展开状态冲掉。 + if (row.IsContainer && !row.ExpansionDecided) + { + string path = row.Path; + row.IsExpanded = _expanded.Contains(path) + || (!_collapsed.Contains(path) && row.Kind == BsonKind.Object && row.Depth == 0 && row.Children.Count is > 0 and <= 12); + row.ExpansionDecided = true; + } + foreach (DocumentEditorRow child in row.Children) + { + ApplyExpansion(child); + } + } + + /// 某一行所在的兄弟列表。 + private IList SiblingsOf(DocumentEditorRow row) => row.Parent?.Children ?? (IList)_roots; + + /// 重排表单里看得见的行。按引用增量同步 —— 整表重建会把正在输入的那个框销毁掉。 + internal void RefreshVisible() + { + var visible = new List(); + string query = _search.Trim(); + foreach (DocumentEditorRow root in _roots) + { + AddVisible(root, query, visible); + } + visible.Add(_tail); + Sync(FormItems, visible); + } + + private static void AddVisible(DocumentEditorRow row, string query, List visible) + { + if (query.Length > 0) + { + bool self = row.Name.Contains(query, StringComparison.OrdinalIgnoreCase); + bool below = row.Children.Any(c => Matches(c, query)); + if (!self && !below) + { + return; + } + visible.Add(row); + if (below || row.IsExpanded) + { + foreach (DocumentEditorRow child in row.Children) + { + if (below) + { + AddVisible(child, query, visible); + } + else + { + AddVisible(child, "", visible); + } + } + } + return; + } + visible.Add(row); + if (row.IsContainer && row.IsExpanded) + { + foreach (DocumentEditorRow child in row.Children) + { + AddVisible(child, query, visible); + } + } + } + + private static bool Matches(DocumentEditorRow row, string query) => + row.Name.Contains(query, StringComparison.OrdinalIgnoreCase) || row.Children.Any(c => Matches(c, query)); + + /// 把 增量改成 的样子(按引用)。 + internal static void Sync(ObservableCollection target, IReadOnlyList source) + { + var keep = new HashSet(source, ReferenceEqualityComparer.Instance); + for (int k = target.Count - 1; k >= 0; k--) + { + if (!keep.Contains(target[k])) + { + target.RemoveAt(k); + } + } + for (int i = 0; i < source.Count; i++) + { + if (i < target.Count && ReferenceEquals(target[i], source[i])) + { + continue; + } + int existing = -1; + for (int j = i + 1; j < target.Count; j++) + { + if (ReferenceEquals(target[j], source[i])) + { + existing = j; + break; + } + } + if (existing >= 0) + { + target.Move(existing, i); + } + else + { + target.Insert(i, source[i]); + } + } + } + + // ── 行操作(由行与视图调用)────────────────────────────────────────────── + + /// 一行被编辑了。 = 行数或展开形态变了。 + internal void OnEdited(DocumentEditorRow row, bool structural) + { + _dirty = true; + if (!_applyingJson) + { + _jsonStale = true; + } + for (DocumentEditorRow? ancestor = row.Parent; ancestor is not null; ancestor = ancestor.Parent) + { + ancestor.RaiseShape(); + } + if (structural) + { + // 新建出来的子行按默认规则定展开;这一行自己的展开状态由调用方定(换成容器时已经展开)。 + foreach (DocumentEditorRow child in row.Children) + { + ApplyExpansion(child); + } + RefreshVisible(); + } + ScheduleRecompute(); + } + + /// 展开 / 收起。 + internal void Toggle(DocumentEditorRow row) + { + if (!row.IsContainer) + { + return; + } + row.IsExpanded = !row.IsExpanded; + string path = row.Path; + (row.IsExpanded ? _expanded : _collapsed).Add(path); + (row.IsExpanded ? _collapsed : _expanded).Remove(path); + RefreshVisible(); + ScheduleRecompute(); + } + + /// 行尾的「+」:容器加子项,标量加同级。 + internal void AddFrom(DocumentEditorRow row) + { + if (!CanEdit) + { + return; + } + if (row.IsContainer) + { + AddChild(row); + } + else + { + AddAfter(row); + } + } + + /// 在容器里加一项(对象加字段、数组加一项)。 + internal DocumentEditorRow? AddChild(DocumentEditorRow container) + { + if (!CanEdit || !container.IsContainer) + { + return null; + } + DocumentEditorRow child = container.Kind == BsonKind.Array + ? new DocumentEditorRow(this, container.Children.Count.ToString(CultureInfo.InvariantCulture), BsonEdit.Empty(container.NewItemKind), container) + : new DocumentEditorRow(this, NewFieldName(container.Children), new BsonString(""), container); + container.Children.Add(child); + container.OnChildrenChanged(); + if (!container.IsExpanded) + { + container.IsExpanded = true; + _expanded.Add(container.Path); + _collapsed.Remove(container.Path); + } + OnEdited(child, structural: true); + RequestFocus(child); + return child; + } + + /// 在某一行后面加一个同级字段(Ctrl+Enter)。 + internal DocumentEditorRow? AddAfter(DocumentEditorRow row) + { + if (!CanEdit) + { + return null; + } + IList siblings = SiblingsOf(row); + DocumentEditorRow fresh = row.IsArrayItem + ? new DocumentEditorRow(this, "0", BsonEdit.Empty(row.IsContainer ? BsonKind.String : row.Kind), row.Parent) + : new DocumentEditorRow(this, NewFieldName(siblings), new BsonString(""), row.Parent); + siblings.Insert(siblings.IndexOf(row) + 1, fresh); + row.Parent?.OnChildrenChanged(); + OnEdited(fresh, structural: true); + RequestFocus(fresh); + return fresh; + } + + /// 在末尾加一个顶层字段(「添加字段」)。 + internal DocumentEditorRow? AddFieldAtEnd() + { + if (!CanEdit) + { + return null; + } + var row = new DocumentEditorRow(this, NewFieldName(_roots), new BsonString(""), null); + _roots.Add(row); + RaisePropertyChanged(nameof(NewDocumentText)); + OnEdited(row, structural: true); + RequestFocus(row); + return row; + } + + /// 芯片行的「+ String」确定之后追加一项。 + internal void AppendItem(DocumentEditorRow array, BsonValue item) + { + if (!CanEdit || array.Kind != BsonKind.Array) + { + return; + } + array.Children.Add(new DocumentEditorRow(this, array.Children.Count.ToString(CultureInfo.InvariantCulture), item, array)); + array.OnChildrenChanged(); + OnEdited(array, structural: true); + } + + /// 删掉一行(数组项删除后重排下标)。 + internal void Remove(DocumentEditorRow row) + { + if (!row.CanRemove) + { + return; + } + SiblingsOf(row).Remove(row); + row.Parent?.OnChildrenChanged(); + RaisePropertyChanged(nameof(NewDocumentText)); + if (row.Parent is { } parent) + { + OnEdited(parent, structural: true); + } + else + { + _dirty = true; + _jsonStale = true; + RefreshVisible(); + ScheduleRecompute(); + } + } + + /// 还原成原文档里的值(连同类型)。 + internal void Revert(DocumentEditorRow row) + { + if (CanEdit && row.OriginalValue is { } original) + { + row.SetValue(original.DeepClone()); + } + } + + /// + /// 拖动排序:把 放到 之前或之后。 + /// 只在同一层之间挪;落点在别的层时取落点那一侧与它同层的祖先 —— 把字段拖进别的对象 + /// 是"移动 + 改路径",那该用剪切粘贴,不该在一次拖动里悄悄发生。 + /// + /// 是否挪动了。 + internal bool Move(DocumentEditorRow row, DocumentEditorRow target, bool after) + { + if (!row.CanDrag || ReferenceEquals(row, target)) + { + return false; + } + DocumentEditorRow? anchor = target; + while (anchor is not null && !ReferenceEquals(anchor.Parent, row.Parent)) + { + anchor = anchor.Parent; + } + if (anchor is null || ReferenceEquals(anchor, row)) + { + return false; + } + if (!ReferenceEquals(anchor, target)) + { + // 落在祖先的子孙上:视作放在那个祖先之后。 + after = true; + } + IList siblings = SiblingsOf(row); + if (anchor.IsId && !after) + { + // _id 留在第一位(mongosh 与驱动都这么写,网格的列序也以它打头)。 + after = true; + } + siblings.Remove(row); + int index = siblings.IndexOf(anchor) + (after ? 1 : 0); + siblings.Insert(Math.Clamp(index, 0, siblings.Count), row); + row.Parent?.OnChildrenChanged(); + OnEdited(row, structural: true); + return true; + } + + /// newField、newField2…(新字段的占位名;聚焦时全选,一打字就替换掉)。 + private static string NewFieldName(IEnumerable siblings) + { + var taken = new HashSet(siblings.Select(static s => s.Name), StringComparer.Ordinal); + if (!taken.Contains("newField")) + { + return "newField"; + } + for (int i = 2; ; i++) + { + string name = "newField" + i.ToString(CultureInfo.InvariantCulture); + if (!taken.Contains(name)) + { + return name; + } + } + } + + private void RequestFocus(DocumentEditorRow row) => FocusRequested?.Invoke(row); + + /// 行里的提示(换类型时值被重置…)。 + internal void Toast(string title, ToastKind kind, string? detail = null) => + Workspace.Toast(new ToastRequest { Title = title, Kind = kind, Detail = detail }); + + // ── 重算 ──────────────────────────────────────────────────────────────── + + /// 空闲时重算一次(一阵连续输入合并成一次)。 + private void ScheduleRecompute() + { + if (_recomputePending) + { + return; + } + _recomputePending = true; + Dispatcher.UIThread.Post(RecomputeNow, DispatcherPriority.Background); + } + + /// + /// 重算:拼出当前文档 → 校验 → 逐行定状态 → 计数 → 预览、命令、对比、JSON。 + /// 测试直接调它,不必等调度器。 + /// + internal void RecomputeNow() + { + _recomputePending = false; + if (_closed) + { + return; + } + BsonDocument current = BuildDocument(); + BsonDocument? validator = Collection.Validator; + bool warnOnly = string.Equals(Collection.ValidationAction, "warn", StringComparison.Ordinal); + IReadOnlyList violations = validator is null ? [] : JsonSchemaValidator.Validate(validator, current, Loc); + var context = new EvaluationContext(violations, validator, warnOnly); + + EvaluateGroup(_roots, Original, parentIsArray: false, ancestorNew: false, context); + if (Original is not null) + { + context.Removed += Original.Names.Count(n => _roots.All(r => r.Name != n)); + } + + // 文档级的问题:JSON 语法、_id 被删、落不到任何一行上的违规。 + var issues = new List(); + if (_jsonError is not null) + { + issues.Add(Loc.Format("Doc_JsonError", _jsonError)); + context.Errors++; + } + if (Original is not null && !current.Contains("_id")) + { + issues.Add(Loc["Doc_IdChanged"]); + context.Errors++; + } + foreach (SchemaViolation violation in violations) + { + if (context.Consumed.Contains(violation)) + { + continue; + } + issues.Add(Loc.Format("Doc_RuleViolation", violation.Message)); + if (warnOnly) + { + context.Warnings++; + } + else + { + context.Errors++; + } + } + SyncIssues(issues); + + _modified = context.Modified; + _added = context.Added; + _removed = context.Removed; + _errors = context.Errors; + _warnings = context.Warnings; + + BuildCommand(current); + BuildPreview(current, context); + if (_mode == DocumentEditorMode.Diff) + { + BuildDiff(current); + } + if (_mode == DocumentEditorMode.Json && _jsonStale && !_applyingJson && _jsonError is null) + { + RegenerateJson(current); + } + IdText = current.TryGetValue("_id", out BsonValue id) + ? id.IsObjectId ? $"ObjectId(\"{id.AsObjectId}\")" : BsonText.Literal(id) + : "—"; + RaisePropertiesChanged(nameof(ModifiedText), nameof(HasModified), nameof(AddedText), nameof(HasAdded), + nameof(RemovedText), nameof(HasRemoved), nameof(ErrorText), nameof(HasErrors), nameof(WarningText), + nameof(HasWarnings), nameof(HasReordered), nameof(HasChanges), nameof(NewDocumentText)); + RaiseSaveState(); + } + + private void RaiseSaveState() + { + RaisePropertiesChanged(nameof(CanSave), nameof(CanSaveAsNew), nameof(FooterHint), nameof(CanCloseWithEscape)); + SaveCommand.RaiseCanExecuteChanged(); + SaveAsNewCommand.RaiseCanExecuteChanged(); + } + + private void SyncIssues(List issues) + { + if (!Issues.SequenceEqual(issues)) + { + Issues.Clear(); + foreach (string issue in issues) + { + Issues.Add(issue); + } + } + } + + /// 一趟重算里各行共用的东西。 + private sealed class EvaluationContext(IReadOnlyList violations, BsonDocument? validator, bool warnOnly) + { + public Dictionary ByPath { get; } = violations + .GroupBy(static v => v.Path, StringComparer.Ordinal) + .ToDictionary(static g => g.Key, static g => g.First(), StringComparer.Ordinal); + + public HashSet Consumed { get; } = []; + + public BsonDocument? Validator { get; } = validator; + + public bool WarnOnly { get; } = warnOnly; + + /// 非"没变"的行:路径 → 状态(预览标行用)。 + public List<(string Path, DocumentEditorRowState State)> States { get; } = []; + + public int Modified { get; set; } + + public int Added { get; set; } + + public int Removed { get; set; } + + public int Errors { get; set; } + + public int Warnings { get; set; } + } + + private void EvaluateGroup(IReadOnlyList rows, BsonValue? parentOriginal, bool parentIsArray, bool ancestorNew, + EvaluationContext context) + { + Dictionary? names = parentIsArray + ? null + : rows.GroupBy(static r => r.Name, StringComparer.Ordinal).ToDictionary(static g => g.Key, static g => g.Count(), StringComparer.Ordinal); + foreach (DocumentEditorRow row in rows) + { + Evaluate(row, parentOriginal, names, ancestorNew, context); + } + } + + private void Evaluate(DocumentEditorRow row, BsonValue? parentOriginal, Dictionary? siblingNames, bool ancestorNew, + EvaluationContext context) + { + BsonValue? original = parentOriginal switch + { + BsonDocument doc when !row.IsArrayItem => doc.TryGetValue(row.Name, out BsonValue v) ? v : null, + BsonArray array when row.IsArrayItem && int.TryParse(row.Name, NumberStyles.None, CultureInfo.InvariantCulture, out int i) + && i < array.Count => array[i], + _ => null + }; + // 新建文档没有"原文档",也就无所谓修改 / 新增:只校验。 + bool tracked = Original is not null; + row.OriginalValue = tracked ? original : null; + row.NameError = siblingNames is null ? null : NameProblem(row, siblingNames, original); + if (context.Validator is { } validator && row.Kind == BsonKind.String + && JsonSchemaValidator.EnumAt(validator, row.Segments) is { } options && options.All(static o => o.IsString)) + { + row.EnumOptions = [.. options.Select(static o => o.AsString)]; + } + else + { + row.EnumOptions = []; + } + + string? error = row.NameError ?? row.ParseError; + if (error is null && row.IsId && tracked && (original is null || !row.BuildValue().Equals(original))) + { + error = Loc["Doc_IdChanged"]; + } + SchemaViolation? violation = context.ByPath.GetValueOrDefault(row.Path); + if (violation is not null) + { + context.Consumed.Add(violation); + } + + DocumentEditorRowState state; + string? message = null; + if (error is not null) + { + state = DocumentEditorRowState.Error; + message = error; + } + else if (violation is not null) + { + state = context.WarnOnly ? DocumentEditorRowState.Warning : DocumentEditorRowState.Error; + message = Loc.Format("Doc_RuleViolation", violation.Message); + } + else if (tracked && original is null) + { + state = DocumentEditorRowState.Added; + } + else if (tracked && Differs(row, original!)) + { + state = DocumentEditorRowState.Modified; + } + else + { + state = DocumentEditorRowState.None; + } + + bool lifted = false; + if (row.IsContainer) + { + EvaluateGroup(row.Children, original, row.Kind == BsonKind.Array, ancestorNew || (tracked && original is null), context); + if (tracked && row.Kind == BsonKind.Object && original is BsonDocument before) + { + context.Removed += before.Names.Count(n => row.Children.All(c => c.Name != n)); + } + // 收起的容器把子孙里的错误提到自己这一行 —— 错误藏在看不见的地方,用户只会看到一个灰掉的保存按钮。 + // 提上来的只是显示,不再计数(子孙那一行已经数过了)。 + if (!row.IsExpanded && state is not (DocumentEditorRowState.Error or DocumentEditorRowState.Warning) + && FirstProblem(row) is { } inner) + { + state = inner.State; + // 违规说明自带完整路径;值 / 字段名的错误不带,补上相对路径(qty:不是 32 位整数)。 + string relative = inner.Path.Length > row.Path.Length ? inner.Path[(row.Path.Length + 1)..] : inner.Path; + message = inner.Message is { } text && !text.Contains(inner.Path, StringComparison.Ordinal) + ? Loc.Format("Doc_LiftedProblem", relative, text) + : inner.Message; + lifted = true; + } + } + if (!lifted) + { + switch (state) + { + case DocumentEditorRowState.Error: + context.Errors++; + break; + case DocumentEditorRowState.Warning: + context.Warnings++; + break; + case DocumentEditorRowState.Added when !ancestorNew: + // 新加的容器下面那些子字段不再各算一个"新字段"。 + context.Added++; + break; + case DocumentEditorRowState.Modified: + context.Modified++; + break; + } + } + row.SetState(state, message); + if (state != DocumentEditorRowState.None) + { + context.States.Add((row.Path, state)); + } + } + + private static DocumentEditorRow? FirstProblem(DocumentEditorRow container) + { + foreach (DocumentEditorRow child in container.Children) + { + if (child.State is DocumentEditorRowState.Error or DocumentEditorRowState.Warning) + { + return child; + } + if (child.IsContainer && FirstProblem(child) is { } deeper) + { + return deeper; + } + } + return null; + } + + /// + /// 字段名的问题。$ 开头、含点号的名字 MongoDB 5.0 起能存,但写不进 $set 路径; + /// 原文档里本来就有的这种字段不拦(拦了就再也保存不了这份文档),只拦新起的名字。 + /// + private string? NameProblem(DocumentEditorRow row, Dictionary siblingNames, BsonValue? original) + { + string name = row.Name; + if (name.Length == 0) + { + return Loc["Doc_NameEmpty"]; + } + if (siblingNames.GetValueOrDefault(name) > 1) + { + return Loc.Format("Doc_NameDuplicate", name); + } + if (original is null && name[0] == '$') + { + return Loc["Doc_NameDollar"]; + } + if (original is null && name.Contains('.')) + { + return Loc["Doc_NameDot"]; + } + return null; + } + + /// + /// 一行算不算"改过"。标量与收起的容器比值;展开的容器只看自己这一层: + /// 类型变了、数组长度变了、对象删了字段或已有字段的顺序变了(子行自己的修改由子行标)。 + /// + private static bool Differs(DocumentEditorRow row, BsonValue original) + { + if (BsonKinds.Of(original) != row.Kind) + { + return true; + } + if (!row.IsContainer || !row.IsExpanded) + { + return !row.BuildValue().Equals(original); + } + if (row.Kind == BsonKind.Array) + { + return row.Children.Count != original.AsBsonArray.Count; + } + BsonDocument before = original.AsBsonDocument; + BsonDocument now = DocumentEditorRow.BuildDocument(row.Children); + return before.Names.Any(n => !now.Contains(n)) || !DocumentEditorDiff.SameOrder(before, now, skipId: false); + } + + // ── 命令与预览 ────────────────────────────────────────────────────────── + + /// 乐观并发的版本字段(原文档里有才用)。 + private const string VersionField = "updatedAt"; + + /// updateOne / replaceOne 的筛选:_id,原文档有 updatedAt 时带上它的原值。 + internal BsonDocument? BuildFilter() + { + if (Original is null || !Original.TryGetValue("_id", out BsonValue id)) + { + return null; + } + var filter = new BsonDocument("_id", id); + if (Original.TryGetValue(VersionField, out BsonValue version)) + { + filter[VersionField] = version; + } + return filter; + } + + /// + /// 更新文档:$set / $unset,外加刷新版本字段 —— 只校验不刷新的话, + /// 下一个人拿着同一个 updatedAt 照样能把这次修改覆盖掉,乐观并发就形同虚设。 + /// 用户自己改了 updatedAt 就不替他刷新。 + /// + internal BsonDocument BuildUpdate(DocumentEditorPlan plan) + { + var update = new BsonDocument(); + if (plan.Set.ElementCount > 0) + { + update["$set"] = plan.Set; + } + if (plan.Unset.ElementCount > 0) + { + update["$unset"] = plan.Unset; + } + if (VersionBump(plan) is { } bump) + { + update["$currentDate"] = new BsonDocument(VersionField, bump); + } + return update; + } + + /// 版本字段怎么刷:Date → true,Timestamp → { $type: "timestamp" };别的类型不碰。 + private BsonValue? VersionBump(DocumentEditorPlan plan) + { + if (Original is null || !Original.TryGetValue(VersionField, out BsonValue version) || plan.IsEmpty + || plan.Paths.Any(static p => p == VersionField || p.StartsWith(VersionField + ".", StringComparison.Ordinal))) + { + return null; + } + return version.BsonType switch + { + BsonType.DateTime => BsonBoolean.True, + BsonType.Timestamp => new BsonDocument("$type", "timestamp"), + _ => null + }; + } + + private void BuildCommand(BsonDocument current) + { + string collectionRef = MongoWorkspaceViewModel.ShellCollectionRef(Collection.Name); + if (Original is null) + { + _plan = null; + _reordered = false; + CommandName = "insertOne"; + CommandText = DocumentEditorFormat.Command(collectionRef, "insertOne", null, current); + CommandNote = Loc["Doc_InsertNote"]; + return; + } + DocumentEditorPlan plan = DocumentEditorDiff.Plan(Original, current); + _plan = plan; + _reordered = plan.Replace && !DocumentEditorDiff.SameOrder(Original, current, skipId: true); + BsonDocument? filter = BuildFilter(); + bool versioned = Original.Contains(VersionField); + if (plan.IsEmpty) + { + CommandName = "updateOne"; + CommandText = Loc["Doc_NoCommand"]; + } + else if (plan.Replace) + { + CommandName = "replaceOne"; + CommandText = DocumentEditorFormat.Command(collectionRef, "replaceOne", filter, current); + } + else + { + CommandName = "updateOne"; + CommandText = DocumentEditorFormat.Command(collectionRef, "updateOne", filter, BuildUpdate(plan)); + } + CommandNote = plan.Replace + ? Loc[versioned ? "Doc_ReplaceNote" : "Doc_ReplaceNoteNoVersion"] + : Loc[versioned ? "Doc_OnlyChanged" : "Doc_OnlyChangedNoVersion"]; + } + + private void BuildPreview(BsonDocument current, EvaluationContext context) + { + DocumentEditorPreview preview = DocumentEditorFormat.Preview(current, Original, Loc); + var lineOf = new Dictionary(StringComparer.Ordinal); + for (int i = 0; i < preview.LinePaths.Count; i++) + { + if (preview.LinePaths[i] is { } path) + { + lineOf.TryAdd(path, i + 1); + } + } + var marks = new Dictionary(); + foreach ((string path, DocumentEditorRowState state) in context.States) + { + // 预览里折起来的结构(对象数组写成 [ …4 项 ])没有子路径的行:落到最近的祖先那一行。 + string? at = path; + while (at is not null && !lineOf.ContainsKey(at)) + { + int dot = at.LastIndexOf('.'); + at = dot < 0 ? null : at[..dot]; + } + if (at is null) + { + continue; + } + LineMarkKind kind = state switch + { + DocumentEditorRowState.Error => LineMarkKind.Removed, + DocumentEditorRowState.Added => LineMarkKind.Added, + _ => LineMarkKind.Modified + }; + int line = lineOf[at]; + if (!marks.TryGetValue(line, out LineMarkKind existing) || Rank(kind) > Rank(existing)) + { + marks[line] = kind; + } + } + foreach (int line in preview.RemovedLines) + { + marks[line] = LineMarkKind.Removed; + } + PreviewText = preview.Text; + PreviewMarks = [.. marks.OrderBy(static m => m.Key).Select(static m => new LineMark(m.Key, m.Value))]; + + static int Rank(LineMarkKind kind) => kind switch + { + LineMarkKind.Removed => 3, + LineMarkKind.Modified => 2, + _ => 1 + }; + } + + private void BuildDiff(BsonDocument current) + { + string left = Original is null ? "" : BsonText.Pretty(Original, EjsonMode.Shell); + string right = BsonText.Pretty(current, EjsonMode.Shell); + (HashSet removed, HashSet added) = DocumentEditorDiff.Lines(SplitLines(left), SplitLines(right)); + DiffLeftText = left; + DiffRightText = right; + DiffLeftMarks = [.. removed.Order().Select(static l => new LineMark(l, LineMarkKind.Removed))]; + DiffRightMarks = [.. added.Order().Select(static l => new LineMark(l, LineMarkKind.Added))]; + } + + private static string[] SplitLines(string text) => text.Length == 0 ? [] : text.Split('\n'); + + // ── JSON 模式 ─────────────────────────────────────────────────────────── + + private DispatcherTimer CreateJsonTimer() + { + var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) }; + timer.Tick += (_, _) => ApplyJson(); + return timer; + } + + /// JSON 文本 → 表单(停手之后;测试直接调)。 + internal void ApplyJson() + { + _jsonTimer?.Stop(); + if (_closed || !CanEdit) + { + return; + } + List diagnostics = [.. ShellJson.Diagnose(_jsonText).Select(d => new EditorDiagnostic( + d.Offset, Math.Max(1, d.Length), Loc.Format(d.MessageKey, d.Argument), DiagnosticSeverity.Error, d.Fix, d.Fix is null ? null : "Alt+↵"))]; + BsonDocument document; + try + { + document = ShellJson.ParseDocument(_jsonText); + } + catch (ShellJsonException ex) + { + _jsonError = ex.Message; + if (diagnostics.Count == 0) + { + int offset = ex.Offset >= 0 ? Math.Min(ex.Offset, Math.Max(0, _jsonText.Length - 1)) : Math.Max(0, _jsonText.TrimEnd().Length - 1); + diagnostics.Add(new(offset, 1, Loc.Format("Doc_JsonError", ex.Message))); + } + JsonDiagnostics = diagnostics; + RecomputeNow(); + return; + } + _jsonError = null; + JsonDiagnostics = []; + _applyingJson = true; + try + { + LoadRows(document, keepExpansion: true); + RecomputeNow(); + } + finally + { + _applyingJson = false; + } + } + + private void RegenerateJson(BsonDocument? current = null) + { + _regeneratingJson = true; + try + { + JsonText = BsonText.Pretty(current ?? BuildDocument(), EjsonMode.Shell); + } + finally + { + _regeneratingJson = false; + } + _jsonStale = false; + _jsonError = null; + JsonDiagnostics = []; + } + + /// 离开 JSON 模式:还没应用的文本先应用;有语法错误就丢掉它,回到上一份有效内容。 + private void LeaveJson() + { + if (_jsonTimer?.IsEnabled == true) + { + ApplyJson(); + } + if (_jsonError is not null) + { + _jsonError = null; + JsonDiagnostics = []; + _jsonStale = true; + Toast(Loc["Doc_JsonDiscarded"], ToastKind.Warning); + } + } + + // ── 粘贴与补全 ────────────────────────────────────────────────────────── + + /// + /// 剪贴板里的 JSON / EJSON / mongosh 文档载入表单(视图读剪贴板后调用)。 + /// 已有文档保留原 _id:粘贴的是"内容",换 _id 等于换一份文档,那该用「另存为新文档」。 + /// + internal void ApplyPastedText(string? text) + { + if (!CanEdit) + { + return; + } + if (string.IsNullOrWhiteSpace(text)) + { + Toast(Loc["Doc_PasteEmpty"], ToastKind.Warning); + return; + } + BsonDocument document; + try + { + BsonValue value = ShellJson.ParseValue(text.Trim()); + document = value as BsonDocument + ?? (value as BsonArray)?.OfType().FirstOrDefault() + ?? throw new ShellJsonException("Expected a document { … }", 0); + } + catch (ShellJsonException ex) + { + Toast(Loc.Format("Doc_PasteBad", ex.Message), ToastKind.Error); + return; + } + BsonValue? keepId = null; + if (Original is not null && Original.TryGetValue("_id", out BsonValue originalId)) + { + keepId = originalId; + } + else if (!document.Contains("_id") && BuildDocument().TryGetValue("_id", out BsonValue currentId)) + { + keepId = currentId; + } + if (keepId is not null) + { + document.Remove("_id"); + document.InsertAt(0, new BsonElement("_id", keepId)); + } + LoadRows(document, keepExpansion: true); + Toast(Loc.Format("Doc_Pasted", document.ElementCount), ToastKind.Success, + Original is not null ? Loc["Doc_PasteKeptId"] : null); + } + + /// 抽样(只抽一次;失败可以再试)。 + internal async Task EnsureSampleAsync(bool reportErrors) + { + if (_schema is not null) + { + return _schema; + } + _sampling ??= SampleAsync(); + DocumentEditorSchema? schema = await _sampling.ConfigureAwait(true); + if (schema is null) + { + _sampling = null; + if (reportErrors && _sampleError is { } error) + { + Toast(Loc.Format("Doc_SampleFailed", error), ToastKind.Error); + } + } + return schema; + } + + private async Task SampleAsync() + { + try + { + int size = Math.Clamp(Workspace.Connection.Settings.SampleSize, 50, 10_000); + _schema = await DocumentEditorSchema.SampleAsync(Workspace.Connection.Collection(Collection.Database, Collection.Name), size, + CancellationToken.None).ConfigureAwait(true); + _sampleError = null; + return _schema; + } + catch (Exception ex) when (ex is MongoException or TimeoutException or OperationCanceledException or InvalidOperationException) + { + // 抽样是锦上添花:失败只让补全与"按 Schema 补全"没有候选,不影响编辑本身。 + _sampleError = MongoConnector.Describe(ex); + Workspace.Log.Info($"Document editor: sampling {Collection.Namespace} failed: {_sampleError}"); + return null; + } + } + + /// 直接给一份抽样结果(测试与"已经抽过"的场合)。 + internal void UseSchema(DocumentEditorSchema schema) => _schema = schema; + + /// + /// 按 Schema 补全缺失字段:抽样里出现率 ≥ 30%、本文档没有的字段,按主导类型补一个空值。 + /// 只补到对象里(数组里的字段属于每一项,补哪一项都不对)。 + /// + internal async Task FillFromSchemaAsync() + { + if (!CanEdit) + { + return; + } + DocumentEditorSchema? schema = await EnsureSampleAsync(reportErrors: true).ConfigureAwait(true); + if (schema is null) + { + return; + } + List added = FillFrom(schema); + if (added.Count == 0) + { + Toast(Loc["Doc_FillNone"], ToastKind.Info); + return; + } + Toast(Loc.Format("Doc_Filled", added.Count, string.Join(", ", added.Take(6)) + (added.Count > 6 ? " …" : "")), ToastKind.Success); + } + + /// 补全的核心(同步,测试直接调)。返回补上的路径。 + internal List FillFrom(DocumentEditorSchema schema) + { + var added = new List(); + IEnumerable candidates = schema.Fields + .Where(f => schema.Ratio(f) >= FillThreshold && f.Path != "_id") + .OrderBy(static f => f.Path.Count(static c => c == '.')) + .ThenByDescending(static f => f.Documents); + foreach (DocumentEditorFieldStat field in candidates) + { + DocumentEditorRow? parent = null; + IList siblings = _roots; + if (field.Parent.Length > 0) + { + parent = FindRow(field.Parent); + if (parent is null || parent.Kind != BsonKind.Object) + { + continue; + } + siblings = parent.Children; + } + if (siblings.Any(s => s.Name == field.Name)) + { + continue; + } + siblings.Add(new DocumentEditorRow(this, field.Name, FillValue(field.Dominant), parent)); + if (parent is not null) + { + parent.OnChildrenChanged(); + parent.IsExpanded = true; + _expanded.Add(parent.Path); + } + added.Add(field.Path); + } + if (added.Count > 0) + { + _dirty = true; + _jsonStale = true; + RaisePropertyChanged(nameof(NewDocumentText)); + RefreshVisible(); + ScheduleRecompute(); + } + return added; + } + + /// 补全用的空值:容器给空容器,少见类型给 null,其余用 (与新增字段同一口径)。 + private static BsonValue FillValue(BsonKind kind) => kind switch + { + BsonKind.Object => new BsonDocument(), + BsonKind.Array => new BsonArray(), + BsonKind.Null or BsonKind.Missing or BsonKind.Binary or BsonKind.Regex or BsonKind.Timestamp or BsonKind.Other => BsonNull.Value, + _ => BsonEdit.Empty(kind) + }; + + /// 字段名补全:同一层里抽样见过、这份文档还没有的名字,按出现率排。 + internal IReadOnlyList SuggestNames(DocumentEditorRow row, string prefix) + { + if (!row.IsNameEditable) + { + return []; + } + if (_schema is null) + { + // 第一次要补全时才去抽样:打开编辑器不该先打一次 $sample。 + _ = EnsureSampleAsync(reportErrors: false); + return []; + } + string parent = row.Parent?.SchemaPath ?? ""; + var taken = new HashSet(SiblingsOf(row).Where(s => !ReferenceEquals(s, row)).Select(static s => s.Name), StringComparer.Ordinal); + string typed = prefix.Trim(); + return [.. _schema.ChildrenOf(parent) + .Where(f => !taken.Contains(f.Name) && f.Name != typed + && (typed.Length == 0 || typed.StartsWith("newField", StringComparison.Ordinal) + || f.Name.Contains(typed, StringComparison.OrdinalIgnoreCase))) + .Take(8) + .Select(f => new DocumentEditorSuggestion(f.Name, f.Dominant, _schema.Ratio(f)))]; + } + + /// 选了一个补全:改名;刚加的空字段顺手换成抽样里的主导类型。 + internal void AcceptSuggestion(DocumentEditorRow row, DocumentEditorSuggestion suggestion) + { + bool fresh = row.OriginalValue is null && row.Kind == BsonKind.String && row.Text.Length == 0; + row.Name = suggestion.Name; + if (fresh && suggestion.Kind != BsonKind.String) + { + row.SetValue(FillValue(suggestion.Kind)); + } + } + + // ── 保存 ──────────────────────────────────────────────────────────────── + + private async Task SaveAsync(bool asNew) + { + // JSON 模式里刚敲完就按 Ctrl+S:停手计时器还没到点,先把文本应用到表单再算。 + if (_jsonTimer?.IsEnabled == true) + { + ApplyJson(); + } + RecomputeNow(); + if (asNew ? !CanSaveAsNew : !CanSave) + { + return; + } + if (!Workspace.EnsureWritable(Collection.Database)) + { + return; + } + BsonDocument current = BuildDocument(); + bool insert = asNew || Original is null; + if (asNew) + { + current.Remove("_id"); + } + DocumentEditorPlan? plan = insert ? null : DocumentEditorDiff.Plan(Original!, current); + if (plan is { IsEmpty: true }) + { + Close(); + return; + } + string method = insert ? "insertOne" : plan!.Replace ? "replaceOne" : "updateOne"; + if (Workspace.Guard.ConfirmWrites && !await ConfirmAsync(method, plan, current).ConfigureAwait(true)) + { + return; + } + + IsBusy = true; + try + { + IMongoCollection collection = Workspace.Connection.Collection(Collection.Database, Collection.Name); + var watch = Stopwatch.StartNew(); + BsonDocument stored; + if (insert) + { + // 驱动在插入时把生成的 _id 写回这份文档 —— 回调拿到的就是库里那份。 + await collection.InsertOneAsync(current).ConfigureAwait(true); + stored = current; + Workspace.Toast(new ToastRequest + { + Title = Loc.Format("Doc_Inserted", Collection.Namespace), + Detail = Loc.Format("Common_Ms", watch.ElapsedMilliseconds), + Kind = ToastKind.Success + }); + } + else + { + BsonDocument filter = BuildFilter()!; + long matched; + if (plan!.Replace) + { + BsonDocument replacement = current.DeepClone().AsBsonDocument; + if (VersionBump(plan) is BsonBoolean) + { + // replaceOne 用不了 $currentDate:日期型的版本字段在客户端填当前时刻。 + replacement[VersionField] = new BsonDateTime(DateTime.UtcNow); + } + ReplaceOneResult result = await collection.ReplaceOneAsync(filter, replacement).ConfigureAwait(true); + matched = result.MatchedCount; + } + else + { + UpdateResult result = await collection.UpdateOneAsync(filter, new BsonDocumentUpdateDefinition(BuildUpdate(plan))) + .ConfigureAwait(true); + matched = result.MatchedCount; + } + if (matched == 0) + { + // 乐观并发失败:别人先改了(或删了)。不覆盖,编辑留在原处,给一个"基于最新版本重做"。 + Workspace.Toast(new ToastRequest + { + Title = Loc["Doc_Conflict"], + Detail = Loc[Original!.Contains(VersionField) ? "Doc_ConflictDetail" : "Doc_ConflictDeleted"], + Kind = ToastKind.Warning, + ActionLabel = Loc["Doc_Rebase"], + Action = RebaseAsync, + Duration = TimeSpan.FromSeconds(10) + }); + return; + } + stored = await collection.Find(new BsonDocument("_id", filter["_id"])).FirstOrDefaultAsync().ConfigureAwait(true) ?? current; + Workspace.Toast(new ToastRequest + { + Title = Loc.Format("Doc_Saved", Collection.Namespace), + Detail = Loc.Format("Doc_SavedDetail", method, plan.Set.ElementCount + plan.Unset.ElementCount, watch.ElapsedMilliseconds), + Kind = ToastKind.Success + }); + } + _dirty = false; + if (Saved is { } saved) + { + await saved(stored).ConfigureAwait(true); + } + Close(); + } + catch (MongoWriteException ex) when (ex.WriteError?.Code == 121) + { + // 121 = DocumentValidationFailure:客户端预检放过了(或规则里有它认不出的关键字),服务器拒了。 + Workspace.Toast(new ToastRequest + { + Title = Loc["Doc_ServerRejected"], + Detail = MongoConnector.Describe(ex), + Kind = ToastKind.Error, + Duration = TimeSpan.FromSeconds(8) + }); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Workspace.Toast(new ToastRequest { Title = Loc.Format("Common_Failed", MongoConnector.Describe(ex)), Kind = ToastKind.Error }); + } + finally + { + IsBusy = false; + } + } + + /// 生产连接(写前确认)保存前确认一次:列出命令与涉及的字段。 + private Task ConfirmAsync(string method, DocumentEditorPlan? plan, BsonDocument current) + { + string fields = plan is null + ? Loc.Format("Doc_NewFields", current.ElementCount) + : plan.Replace ? Loc["Doc_WholeDocument"] : string.Join(", ", plan.Paths.Take(8)) + (plan.Paths.Count() > 8 ? " …" : ""); + return Workspace.ConfirmAsync(new ConfirmRequest + { + Title = Loc["Doc_ConfirmTitle"], + Message = Loc.Format("Doc_ConfirmBody", Workspace.ConnectionName, Collection.Namespace, method), + ConfirmLabel = Loc["Common_Save"], + IconKey = "Mongo.save", + Danger = false, + Facts = [new(Loc["Doc_ConfirmCommand"], method), new(Loc["Doc_ConfirmFields"], fields)] + }); + } + + /// 冲突之后:取服务器上的最新版本,把自己的修改重放上去,继续编辑。 + private async Task RebaseAsync() + { + if (_closed || Original is null) + { + return; + } + try + { + BsonDocument? latest = await Workspace.Connection.Collection(Collection.Database, Collection.Name) + .Find(new BsonDocument("_id", Original["_id"])).FirstOrDefaultAsync().ConfigureAwait(true); + if (latest is null) + { + Toast(Loc["Doc_Deleted"], ToastKind.Warning); + return; + } + BsonDocument current = BuildDocument(); + DocumentEditorPlan plan = DocumentEditorDiff.Plan(Original, current); + BsonDocument rebased = plan.Replace ? current : Apply(plan, latest.DeepClone().AsBsonDocument); + Original = latest; + RaisePropertyChanged(nameof(Original)); + LoadRows(rebased, keepExpansion: true); + RecomputeNow(); + Toast(Loc["Doc_Rebased"], ToastKind.Success); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Toast(Loc.Format("Common_Failed", MongoConnector.Describe(ex)), ToastKind.Error); + } + } + + /// 把一份更新计划在内存里应用到文档上(冲突重做用)。 + internal static BsonDocument Apply(DocumentEditorPlan plan, BsonDocument target) + { + foreach (BsonElement unset in plan.Unset) + { + BsonPath.Unset(target, unset.Name); + } + foreach (BsonElement set in plan.Set) + { + BsonPath.Set(target, set.Name, set.Value.DeepClone()); + } + return target; + } + + private async Task CopyAsync() + { + await Workspace.CopyAsync(BsonText.Pretty(BuildDocument(), EjsonMode.Shell)).ConfigureAwait(true); + Toast(Loc["Common_Copied"], ToastKind.Success); + } + + // ── 生命周期 ──────────────────────────────────────────────────────────── + + private void OnGuardChanged() => RaiseSaveState(); + + /// + internal override void OnClosed() + { + _closed = true; + _jsonTimer?.Stop(); + Workspace.Guard.Changed -= OnGuardChanged; + base.OnClosed(); + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DocumentEditorDiff.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DocumentEditorDiff.cs new file mode 100644 index 0000000..f85584b --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DocumentEditorDiff.cs @@ -0,0 +1,225 @@ +using System.Globalization; +using MongoDB.Bson; +using VelaShell.Plugin.Mongo.Bson; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 一次保存要发给服务器的东西:$set / $unset 两张表,或者"整份替换"。 +/// +internal sealed class DocumentEditorPlan +{ + /// $set:点路径 → 新值。 + public BsonDocument Set { get; } = []; + + /// $unset:点路径 → ""。 + public BsonDocument Unset { get; } = []; + + /// + /// 要整份替换(replaceOne):顶层已有字段的**相对顺序**变了(用户拖动排了序), + /// 或者有字段名没法写成点路径($ 开头、含点号)—— 这两样 $set 都表达不了。 + /// + public bool Replace { get; set; } + + /// 什么也不用发。 + public bool IsEmpty => !Replace && Set.ElementCount == 0 && Unset.ElementCount == 0; + + /// 涉及的字段路径(确认框里列出来)。 + public IEnumerable Paths => Set.Names.Concat(Unset.Names); +} + +/// +/// 文档编辑器的两种"差异":字段级的更新计划(,决定发什么命令) +/// 与文本级的逐行对比(,对比模式与预览的红绿行)。 +/// +internal static class DocumentEditorDiff +{ + /// + /// 原文档 → 当前文档的最小更新:只 $set 真变了的路径、$unset 删掉的字段。 + /// + /// 嵌套文档两边都在、键序没变时下钻到子路径(customer.level),而不是把整个 customer 覆盖回去 —— + /// 别人同时改了 customer.name 的话,整块覆盖会把那次修改悄悄抹掉。等长数组同理按下标下钻; + /// 长度变了的数组只能整体 $set(按下标写会和并发的 $push 错位)。 + /// + /// + public static DocumentEditorPlan Plan(BsonDocument original, BsonDocument current) + { + var plan = new DocumentEditorPlan(); + if (!SameOrder(original, current, skipId: true)) + { + plan.Replace = true; + return plan; + } + DiffDocument(original, current, null, plan); + if (plan.Replace) + { + plan.Set.Clear(); + plan.Unset.Clear(); + } + return plan; + } + + private static void DiffDocument(BsonDocument original, BsonDocument current, string? prefix, DocumentEditorPlan plan) + { + foreach (BsonElement gone in original) + { + if ((prefix is null && gone.Name == "_id") || current.Contains(gone.Name)) + { + continue; + } + if (!Addressable(gone.Name)) + { + plan.Replace = true; + return; + } + plan.Unset[BsonPath.Join(prefix, gone.Name)] = ""; + } + foreach (BsonElement element in current) + { + if (prefix is null && element.Name == "_id") + { + continue; + } + if (!original.TryGetValue(element.Name, out BsonValue before)) + { + if (!Addressable(element.Name)) + { + plan.Replace = true; + return; + } + plan.Set[BsonPath.Join(prefix, element.Name)] = element.Value; + continue; + } + if (before.Equals(element.Value)) + { + continue; + } + if (!Addressable(element.Name)) + { + plan.Replace = true; + return; + } + DiffValue(before, element.Value, BsonPath.Join(prefix, element.Name), plan); + if (plan.Replace) + { + return; + } + } + } + + private static void DiffValue(BsonValue before, BsonValue after, string path, DocumentEditorPlan plan) + { + if (before is BsonDocument a && after is BsonDocument b && a.ElementCount > 0 && b.ElementCount > 0 && SameOrder(a, b, skipId: false)) + { + DiffDocument(a, b, path, plan); + return; + } + // 等长数组:改动少时按下标下钻(items.1.qty);过半的项都变了就整体 $set —— 一串 tags.0 / tags.1 既难读, + // 也不比整体替换更"局部"。 + if (before is BsonArray x && after is BsonArray y && x.Count == y.Count && x.Count > 0 + && Enumerable.Range(0, x.Count).Count(i => !x[i].Equals(y[i])) * 2 <= x.Count) + { + for (int i = 0; i < x.Count; i++) + { + if (!x[i].Equals(y[i])) + { + DiffValue(x[i], y[i], BsonPath.Join(path, i.ToString(CultureInfo.InvariantCulture)), plan); + if (plan.Replace) + { + return; + } + } + } + return; + } + plan.Set[path] = after; + } + + /// 两边都有的键,相对顺序一致(新增的键在哪儿不管 —— $set 会把它追加到末尾)。 + internal static bool SameOrder(BsonDocument original, BsonDocument current, bool skipId) + { + List before = [.. original.Names.Where(n => current.Contains(n) && !(skipId && n == "_id"))]; + List after = [.. current.Names.Where(n => original.Contains(n) && !(skipId && n == "_id"))]; + return before.SequenceEqual(after, StringComparer.Ordinal); + } + + /// 能不能写进点路径:空名、$ 开头、含点号的都不行。 + internal static bool Addressable(string name) => name.Length > 0 && name[0] != '$' && !name.Contains('.'); + + /// + /// 逐行对比(对比模式):返回左侧被删掉的行与右侧新增的行(行号 1 起)。 + /// 先剥掉两头相同的行,中间段做 LCS;中间段太大(两边行数乘积超过四百万)就整段标红 / 标绿 —— + /// 那种规模的改动,逐行对齐也没人看得过来。 + /// + public static (HashSet Removed, HashSet Added) Lines(IReadOnlyList left, IReadOnlyList right) + { + var removed = new HashSet(); + var added = new HashSet(); + int start = 0; + while (start < left.Count && start < right.Count && left[start] == right[start]) + { + start++; + } + int endLeft = left.Count; + int endRight = right.Count; + while (endLeft > start && endRight > start && left[endLeft - 1] == right[endRight - 1]) + { + endLeft--; + endRight--; + } + int n = endLeft - start; + int m = endRight - start; + if ((long)n * m > 4_000_000) + { + for (int i = start; i < endLeft; i++) + { + removed.Add(i + 1); + } + for (int j = start; j < endRight; j++) + { + added.Add(j + 1); + } + return (removed, added); + } + // lcs[i, j] = left[start+i..] 与 right[start+j..] 的最长公共子序列长度。 + int[,] lcs = new int[n + 1, m + 1]; + for (int i = n - 1; i >= 0; i--) + { + for (int j = m - 1; j >= 0; j--) + { + lcs[i, j] = left[start + i] == right[start + j] + ? lcs[i + 1, j + 1] + 1 + : Math.Max(lcs[i + 1, j], lcs[i, j + 1]); + } + } + int p = 0; + int q = 0; + while (p < n && q < m) + { + if (left[start + p] == right[start + q]) + { + p++; + q++; + } + else if (lcs[p + 1, q] >= lcs[p, q + 1]) + { + removed.Add(start + p + 1); + p++; + } + else + { + added.Add(start + q + 1); + q++; + } + } + for (; p < n; p++) + { + removed.Add(start + p + 1); + } + for (; q < m; q++) + { + added.Add(start + q + 1); + } + return (removed, added); + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DocumentEditorFormat.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DocumentEditorFormat.cs new file mode 100644 index 0000000..9cbaf79 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DocumentEditorFormat.cs @@ -0,0 +1,170 @@ +using System.Text; +using MongoDB.Bson; +using VelaShell.Plugin.Mongo.Bson; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 右侧预览栏的文本,以及每一行对应哪个字段(好把表单里的行状态映射成整行标记)。 +/// 全文。 +/// 每一行对应的字段路径(行号 - 1 为下标);大括号、注释行为 。 +/// "已删除字段"那几行(注释形式,行号 1 起)。 +internal sealed record DocumentEditorPreview(string Text, IReadOnlyList LinePaths, IReadOnlyList RemovedLines); + +/// +/// 文档编辑器右栏的两段文本:当前文档的紧凑预览,与"将执行的命令"。 +/// +internal static class DocumentEditorFormat +{ + /// 预览里嵌套文档最多展开几层;更深的给 { …N }。 + private const int MaxDepth = 4; + + /// + /// 紧凑预览(设计稿 05 右栏):330px 宽放不下完整的 mongosh 文本,所以 + /// ObjectId 缩写成 66f5c2a1…e3b7、对象数组折成 [ …4 项 ]、短的标量数组写一行; + /// 原文档里有而现在没有的字段以注释行 // note: null 留在原处,标成删除。 + /// 复制按钮复制的是完整文本,不是这份预览。 + /// + public static DocumentEditorPreview Preview(BsonDocument current, BsonDocument? original, Loc loc) + { + var lines = new List(); + var paths = new List(); + var removed = new List(); + lines.Add("{"); + paths.Add(null); + WriteFields(current, original, null, 1, loc, lines, paths, removed); + lines.Add("}"); + paths.Add(null); + return new(string.Join('\n', lines), paths, removed); + } + + private static void WriteFields(BsonDocument doc, BsonDocument? original, string? prefix, int depth, Loc loc, + List lines, List paths, List removed) + { + string indent = new(' ', depth * 2); + List gone = original?.Elements.Where(e => !doc.Contains(e.Name)).ToList() ?? []; + int index = 0; + foreach (BsonElement element in doc) + { + bool last = ++index == doc.ElementCount; + string comma = last ? "" : ","; + string path = BsonPath.Join(prefix, element.Name); + string key = BsonText.FieldName(element.Name); + if (element.Value is BsonDocument child && child.ElementCount > 0 && depth < MaxDepth) + { + lines.Add($"{indent}{key}: {{"); + paths.Add(path); + BsonDocument? before = original is not null && original.TryGetValue(element.Name, out BsonValue b) ? b as BsonDocument : null; + WriteFields(child, before, path, depth + 1, loc, lines, paths, removed); + lines.Add(indent + "}" + comma); + paths.Add(path); + continue; + } + lines.Add($"{indent}{key}: {Short(element.Value, loc, depth)}{comma}"); + paths.Add(path); + } + foreach (BsonElement element in gone) + { + lines.Add($"{indent}// {BsonText.FieldName(element.Name)}: {Short(element.Value, loc, depth)}"); + paths.Add(null); + removed.Add(lines.Count); + } + } + + /// 预览里的单行值。 + internal static string Short(BsonValue value, Loc loc, int depth = 0) + { + switch (value.BsonType) + { + case BsonType.ObjectId: + return $"ObjectId(\"{BsonText.Shorten(value.AsObjectId.ToString())}\")"; + case BsonType.String: + { + string text = BsonText.OneLine(value.AsString); + return BsonText.Quote(text.Length > 60 ? text[..60] + "…" : text); + } + case BsonType.Document: + { + BsonDocument doc = value.AsBsonDocument; + return doc.ElementCount == 0 ? "{}" : $"{{ …{doc.ElementCount} }}"; + } + case BsonType.Array: + { + BsonArray array = value.AsBsonArray; + if (array.Count == 0) + { + return "[]"; + } + if (array.Count <= 6 && array.All(static v => v.BsonType is not (BsonType.Document or BsonType.Array))) + { + string inline = "[ " + string.Join(", ", array.Select(v => Short(v, loc, depth + 1))) + " ]"; + if (inline.Length <= 60) + { + return inline; + } + } + return loc.Format("Doc_PreviewItems", array.Count); + } + default: + return BsonText.Literal(value); + } + } + + /// + /// 将执行的命令(mongosh 写法,可以原样粘进查询编辑器): + /// db.orders.updateOne({ _id: … }, { $set: { … }, $unset: { … } }),新建时 insertOne, + /// 键序变了时 replaceOne。 + /// + /// orders 或 getCollection("weird-name")。 + /// updateOne / replaceOne / insertOne。 + /// 筛选(插入时为 null)。 + /// 更新文档({ $set: …, $unset: … })、替换文档或要插入的文档。 + public static string Command(string collectionRef, string method, BsonDocument? filter, BsonDocument body) + { + var b = new StringBuilder(); + b.Append("db.").Append(collectionRef).Append('.').Append(method).Append("(\n"); + if (filter is not null) + { + b.Append(" ").Append(BsonText.Literal(filter)).Append(",\n"); + } + if (method == "updateOne") + { + AppendUpdate(b, body); + } + else + { + b.Append(Indent(BsonText.Pretty(body, EjsonMode.Shell), " ")); + } + b.Append("\n)"); + return b.ToString(); + } + + /// + /// 更新文档的写法照设计稿:{ $set: { 起头,字段缩进六格,} } 收尾; + /// 多个操作符之间 }, 换行接下一个。 + /// + private static void AppendUpdate(StringBuilder b, BsonDocument update) + { + int op = 0; + foreach (BsonElement element in update) + { + b.Append(op++ == 0 ? " { " : " ").Append(element.Name).Append(": {"); + if (element.Value is BsonDocument fields) + { + int i = 0; + foreach (BsonElement field in fields) + { + b.Append(i++ == 0 ? "\n" : ",\n"); + b.Append(" ").Append(BsonText.FieldName(field.Name)).Append(": ").Append(BsonText.Literal(field.Value)); + } + } + b.Append(op == update.ElementCount ? "\n } }" : "\n },\n"); + } + if (update.ElementCount == 0) + { + b.Append(" {}"); + } + } + + private static string Indent(string text, string indent) => + indent + text.Replace("\n", "\n" + indent, StringComparison.Ordinal); +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DocumentEditorRow.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DocumentEditorRow.cs new file mode 100644 index 0000000..1a1589c --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DocumentEditorRow.cs @@ -0,0 +1,779 @@ +using System.Collections.ObjectModel; +using System.Globalization; +using Avalonia; +using MongoDB.Bson; +using VelaShell.Plugin.Mongo.Bson; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 表单行的状态(决定整行底色、左侧 2px 色条与行尾提示)。 +internal enum DocumentEditorRowState +{ + /// 没变。 + None, + + /// 改了值或类型(橙)。 + Modified, + + /// 原文档里没有(绿)。 + Added, + + /// 违反验证规则,但集合的 validationAction 是 warn(橙字,不挡保存)。 + Warning, + + /// 错误:字段名不合法、值解析不了、违反验证规则(红,挡保存)。 + Error +} + +/// +/// 表单里的一行 = 文档里的一个字段(或数组里的一项)。 +/// +/// 行树就是编辑中的文档本身:容器行的值由子行拼出来,标量行持有值与编辑文本。 +/// 行只管"自己这一格怎么编辑";增删、排序、展开、校验与差异由对话框视图模型统一算 —— +/// 一行的状态取决于它在整份文档里的位置(原文档同一路径上有没有值、验证规则落在哪条路径上), +/// 单看一行算不出来。 +/// +/// +internal sealed class DocumentEditorRow : ObservableObject +{ + private readonly DocumentEditorDialogViewModel _owner; + private string _name; + private BsonKind _kind; + private BsonValue _value = BsonNull.Value; + private string _text = ""; + private string? _parseError; + private string? _nameError; + private bool _isExpanded; + private DocumentEditorRowState _state; + private string? _message; + private BsonValue? _originalValue; + private bool _isAddingItem; + private string _newItemText = ""; + private bool _newItemInvalid; + private IReadOnlyList _enumOptions = []; + + /// 构造(容器值会递归建出子行)。 + /// 所属对话框。 + /// 字段名(数组项为下标)。 + /// 值。 + /// 父行;顶层为 。 + public DocumentEditorRow(DocumentEditorDialogViewModel owner, string name, BsonValue value, DocumentEditorRow? parent) + { + _owner = owner; + _name = name; + Parent = parent; + LoadValue(value); + ToggleCommand = new RelayCommand(() => _owner.Toggle(this), () => IsContainer); + AddCommand = new RelayCommand(() => _owner.AddFrom(this), () => _owner.CanEdit); + RemoveCommand = new RelayCommand(() => _owner.Remove(this), () => CanRemove); + RevertCommand = new RelayCommand(() => _owner.Revert(this)); + GenerateCommand = new RelayCommand(GenerateId, () => IsValueEditable); + SetStringCommand = new RelayCommand(() => ChangeKind(BsonKind.String), () => IsValueEditable); + BeginItemCommand = new RelayCommand(() => IsAddingItem = true, () => IsValueEditable); + CommitItemCommand = new RelayCommand(CommitItem); + CancelItemCommand = new RelayCommand(() => IsAddingItem = false); + } + + /// 文案表(行模板里直接绑)。 + public Loc Loc => _owner.Loc; + + // ── 位置 ──────────────────────────────────────────────────────────────── + + /// 父行。 + public DocumentEditorRow? Parent { get; internal set; } + + /// 子行(对象的字段 / 数组的项)。 + public ObservableCollection Children { get; } = []; + + /// 嵌套深度(顶层 0)。 + public int Depth => Parent is null ? 0 : Parent.Depth + 1; + + /// 字段名列的内边距:每深一层缩进 16(设计稿 customer 的子行左距 20 = 4 + 16)。 + public Thickness KeyPadding => new(4 + (Depth * 16), 0, 8, 0); + + /// 点路径(customer.level、items.0.qty)。 + public string Path => Parent is null ? _name : Parent.Path + "." + _name; + + /// 路径分段。 + public IReadOnlyList Segments + { + get + { + var segments = new List(); + for (DocumentEditorRow? row = this; row is not null; row = row.Parent) + { + segments.Insert(0, row._name); + } + return segments; + } + } + + /// + /// 抽样里的合并路径:数组下标那一段去掉(items.0.sku → items.sku), + /// 与 的路径口径一致。 + /// + public string SchemaPath => Parent is null ? _name : IsArrayItem ? Parent.SchemaPath : BsonPath.Join(Parent.SchemaPath, _name); + + /// 是不是数组里的一项(名字是下标,不能改)。 + public bool IsArrayItem => Parent?._kind == BsonKind.Array; + + /// 顶层 _id。 + public bool IsId => Parent is null && _name == "_id"; + + /// + /// 锁定的 _id:已有文档的 _id 不可改(服务器上它不可变;要换 _id 走「另存为新文档」)。 + /// 新建文档的 _id 名字锁定、值可改(products 那种字符串主键)。 + /// + public bool IsIdLocked => IsId && _owner.IsExisting; + + /// 字段名能不能改。 + public bool IsNameEditable => !IsId && !IsArrayItem && _owner.CanEdit; + + /// 值能不能改。 + public bool IsValueEditable => !IsIdLocked && _owner.CanEdit; + + /// 值框只读(锁定的 _id、只读视图)。 + public bool IsValueReadOnly => !IsValueEditable; + + /// 类型下拉能不能动。 + public bool IsKindEditable => IsValueEditable; + + /// 能不能删(_id 不能)。 + public bool CanRemove => !IsId && _owner.CanEdit; + + /// 能不能拖动排序(数组项与对象字段都能;_id 不动)。 + public bool CanDrag => !IsId && _owner.CanEdit; + + // ── 名字 ──────────────────────────────────────────────────────────────── + + /// 字段名(数组项是下标)。 + public string Name + { + get => _name; + set + { + if (!IsNameEditable || value is null || _name == value) + { + return; + } + _name = value; + RaisePropertiesChanged(nameof(Name), nameof(DisplayName), nameof(Path)); + _owner.OnEdited(this, structural: false); + } + } + + /// 显示名:数组项写成 [0]。 + public string DisplayName => IsArrayItem ? $"[{_name}]" : _name; + + /// 字段名本身的问题(空、重复、$ 开头、含点号);由视图模型在重算时填。 + public string? NameError + { + get => _nameError; + internal set => SetProperty(ref _nameError, value); + } + + /// 数组项重排之后改下标。 + internal void SetIndex(int index) + { + string name = index.ToString(CultureInfo.InvariantCulture); + if (_name != name) + { + _name = name; + RaisePropertiesChanged(nameof(Name), nameof(DisplayName), nameof(Path)); + } + } + + // ── 类型与值 ──────────────────────────────────────────────────────────── + + /// 类型。 + public BsonKind Kind => _kind; + + /// 类型下拉的选中项(设它 = 换类型)。 + public BsonKind SelectedKind + { + get => _kind; + set + { + if (value != _kind) + { + ChangeKind(value); + } + } + } + + /// 类型下拉的可选项(设计稿 01 的类型菜单;当前是少见类型时把它也放进去,否则下拉显示为空)。 + public IReadOnlyList KindOptions => + BsonKinds.Editable.Contains(_kind) ? BsonKinds.Editable : [.. BsonKinds.Editable, _kind]; + + /// 编辑框里的文本(标量)。 + public string Text + { + get => _text; + set + { + value ??= ""; + if (_text == value) + { + return; + } + _text = value; + RaisePropertyChanged(); + ParseText(); + _owner.OnEdited(this, structural: false); + } + } + + /// 值解析失败的原因(已本地化);没有为 。 + public string? ParseError + { + get => _parseError; + private set => SetProperty(ref _parseError, value); + } + + /// 布尔值(开关)。 + public bool BoolValue + { + get => _value is BsonBoolean { Value: true }; + set + { + if (BoolValue == value || !IsValueEditable) + { + return; + } + _value = value ? BsonBoolean.True : BsonBoolean.False; + _text = value ? "true" : "false"; + RaisePropertiesChanged(nameof(BoolValue), nameof(Text)); + _owner.OnEdited(this, structural: false); + } + } + + /// 枚举下拉的选中项(= 文本)。 + public string? EnumValue + { + get => _text; + set + { + if (value is not null) + { + Text = value; + RaisePropertyChanged(); + } + } + } + + /// 验证规则里这条路径的 enum(字符串)。由视图模型在重算时填。 + public IReadOnlyList EnumOptions + { + get => _enumOptions; + internal set + { + if (!_enumOptions.SequenceEqual(value)) + { + _enumOptions = value; + RaisePropertiesChanged(nameof(EnumOptions), nameof(EnumHint)); + RaiseShape(); + } + } + } + + /// enum: 普通 · VIP · SVIP。 + public string EnumHint => _owner.Loc.Format("Doc_EnumHint", string.Join(" · ", _enumOptions)); + + /// 当前值(容器由子行拼出;解析失败的标量保留上一个合法值 —— 反正挡着保存)。 + public BsonValue BuildValue() => _kind switch + { + BsonKind.Object => BuildDocument(Children), + BsonKind.Array => new BsonArray(Children.Select(static c => c.BuildValue())), + _ => _value + }; + + /// + /// 一组字段行 → 文档。重名字段后者覆盖前者(重名本身已标成错误挡着保存, + /// 这里只是不让 BsonDocument 因重名抛出来)。 + /// + internal static BsonDocument BuildDocument(IEnumerable rows) + { + var document = new BsonDocument(); + foreach (DocumentEditorRow row in rows) + { + document[row._name] = row.BuildValue(); + } + return document; + } + + /// 换类型:按 换算;换算不了的值重置并提示。 + public void ChangeKind(BsonKind kind) + { + if (!IsValueEditable || kind == _kind) + { + RaisePropertyChanged(nameof(SelectedKind)); + return; + } + BsonValue converted = BsonEdit.Convert(BuildValue(), kind, out bool reset); + LoadValue(converted); + if (BsonKinds.IsContainer(kind)) + { + // 刚换成的容器是空的,展开着才好往里加东西。 + _isExpanded = true; + ExpansionDecided = true; + } + RaiseAll(); + if (reset) + { + _owner.Toast(_owner.Loc.Format("Doc_KindReset", Path, BsonKinds.Name(kind)), ToastKind.Warning); + } + _owner.OnEdited(this, structural: true); + } + + /// 整体换值(还原、补全、粘贴):容器重建子行。 + public void SetValue(BsonValue value) + { + LoadValue(value); + RaiseAll(); + _owner.OnEdited(this, structural: true); + } + + /// 设值但不通知(构造与重建时用)。 + private void LoadValue(BsonValue value) + { + _kind = BsonKinds.Of(value); + Children.Clear(); + _parseError = null; + if (value is BsonDocument document) + { + _value = BsonNull.Value; + _text = ""; + foreach (BsonElement element in document) + { + Children.Add(new DocumentEditorRow(_owner, element.Name, element.Value, this)); + } + return; + } + if (value is BsonArray array) + { + _value = BsonNull.Value; + _text = ""; + for (int i = 0; i < array.Count; i++) + { + Children.Add(new DocumentEditorRow(_owner, i.ToString(CultureInfo.InvariantCulture), array[i], this)); + } + return; + } + _value = value; + _text = BsonEdit.EditText(value); + } + + private void ParseText() + { + if (BsonEdit.TryParse(_text, _kind, out BsonValue parsed, out string? error)) + { + _value = parsed; + ParseError = null; + } + else + { + ParseError = _owner.Loc[error ?? "Edit_BadLiteral"]; + } + RaisePropertiesChanged(nameof(IsoEcho), nameof(TimeZoneText), nameof(ChipText)); + } + + private void GenerateId() + { + if (!IsValueEditable) + { + return; + } + _value = ObjectId.GenerateNewId(); + _text = BsonEdit.EditText(_value); + ParseError = null; + RaisePropertyChanged(nameof(Text)); + _owner.OnEdited(this, structural: false); + } + + // ── 容器 ──────────────────────────────────────────────────────────────── + + /// 是不是容器(对象 / 数组)。 + public bool IsContainer => BsonKinds.IsContainer(_kind); + + /// 展开着。 + public bool IsExpanded + { + get => _isExpanded; + internal set + { + if (SetProperty(ref _isExpanded, value)) + { + RaiseShape(); + } + } + } + + /// 展开状态已经定过(默认规则只对新出现的容器行生效一次)。 + internal bool ExpansionDecided { get; set; } + + /// 数组里全是标量(收起时画成一排芯片)。 + public bool IsScalarArray => _kind == BsonKind.Array && Children.All(static c => !c.IsContainer); + + /// 容器的个数徽记:{3} / [4]。 + public string ContainerCount => _kind == BsonKind.Object ? $"{{{Children.Count}}}" : $"[{Children.Count}]"; + + /// + /// 容器摘要:对象给字段名(id, name, level),对象数组给每项的"名字 ×数量" + /// (SKU-7710 ×2 · SKU-1182 ×1,与设计稿一致),标量数组给前几项。 + /// + public string ContainerSummary + { + get + { + if (_kind == BsonKind.Object) + { + return string.Join(", ", Children.Take(8).Select(static c => c._name)) + (Children.Count > 8 ? ", …" : ""); + } + IEnumerable parts = Children.Take(6).Select(static c => c.ItemSummary()); + return string.Join(" · ", parts) + (Children.Count > 6 ? " · …" : ""); + } + } + + /// 数组项的一句话摘要。 + private string ItemSummary() + { + if (_kind != BsonKind.Object) + { + return IsContainer ? ContainerCount : BsonText.Cell(_value); + } + string? head = Children.FirstOrDefault(static c => c._kind == BsonKind.String)?._value.AsString; + // 订单行那种"SKU ×数量":只认常见的数量字段名,免得把 { name, age } 写成"张三 ×30"。 + DocumentEditorRow? count = Children.FirstOrDefault(static c => + c._name is "qty" or "quantity" or "count" or "num" or "amount" && BsonKinds.IsNumeric(c._kind)); + string text = head is null ? ContainerCount : BsonText.OneLine(head); + return count is null ? text : $"{text} ×{BsonText.Cell(count._value)}"; + } + + /// 芯片上的字(标量数组的项)。 + public string ChipText => _kind == BsonKind.String ? BsonText.OneLine(_text) : BsonText.Cell(_value); + + /// 「+ String」:追加一项时用的类型 —— 跟最后一项走,空数组给字符串。 + public BsonKind NewItemKind => Children.Count > 0 && !Children[^1].IsContainer ? Children[^1]._kind : BsonKind.String; + + /// 「+ String」上的字。 + public string NewItemLabel => BsonKinds.Name(NewItemKind); + + /// 正在芯片行里输入新的一项。 + public bool IsAddingItem + { + get => _isAddingItem; + set + { + if (SetProperty(ref _isAddingItem, value) && !value) + { + NewItemText = ""; + } + } + } + + /// 新一项的文本。 + public string NewItemText + { + get => _newItemText; + set + { + if (SetProperty(ref _newItemText, value ?? "")) + { + NewItemInvalid = false; + } + } + } + + /// 新一项解析不了(输入框标红)。 + public bool NewItemInvalid + { + get => _newItemInvalid; + private set => SetProperty(ref _newItemInvalid, value); + } + + /// + /// 回车:追加这一项,输入框留着接着输下一项(打标签通常是一口气打好几个); + /// 空着回车等于收起。 + /// + private void CommitItem() + { + if (_newItemText.Length == 0) + { + IsAddingItem = false; + return; + } + if (!BsonEdit.TryParse(_newItemText, NewItemKind, out BsonValue item, out _)) + { + NewItemInvalid = true; + return; + } + _owner.AppendItem(this, item); + NewItemText = ""; + } + + /// 输入框失焦:有内容就追加,然后收起。 + internal void FinishItem() + { + if (!_isAddingItem) + { + return; + } + if (_newItemText.Trim().Length > 0) + { + CommitItem(); + if (_newItemInvalid) + { + // 解析不了的内容不悄悄丢掉:留着输入框(标红)让用户改。 + return; + } + } + IsAddingItem = false; + } + + /// 子行增删之后重排下标、刷新摘要。 + internal void OnChildrenChanged() + { + if (_kind == BsonKind.Array) + { + for (int i = 0; i < Children.Count; i++) + { + Children[i].SetIndex(i); + } + } + RaiseShape(); + } + + // ── 状态 ──────────────────────────────────────────────────────────────── + + /// 原文档同一路径上的值(没有 = 新增);由视图模型在重算时填。 + internal BsonValue? OriginalValue + { + get => _originalValue; + set + { + _originalValue = value; + RaisePropertyChanged(nameof(OriginalText)); + } + } + + /// 状态。 + public DocumentEditorRowState State => _state; + + /// 行尾那句话(错误 / 警告)。 + public string? Message => _message; + + /// 重算后设状态。 + internal void SetState(DocumentEditorRowState state, string? message) + { + if (_state == state && _message == message) + { + return; + } + _state = state; + _message = message; + RaisePropertiesChanged(nameof(State), nameof(Message), nameof(IsModified), nameof(IsAdded), nameof(IsError), + nameof(IsValueError), nameof(IsWarning), nameof(ShowMessage), nameof(ShowRevert), nameof(ShowEnumHint), nameof(ShowDateExtras), + nameof(IsoEcho), nameof(TimeZoneText)); + } + + /// 已修改(橙底)。 + public bool IsModified => _state == DocumentEditorRowState.Modified; + + /// 新增(绿底)。 + public bool IsAdded => _state == DocumentEditorRowState.Added; + + /// 错误(红底)。 + public bool IsError => _state == DocumentEditorRowState.Error; + + /// 错误出在值上(值框标红;字段名的错误只把字段名标红)。 + public bool IsValueError => _state == DocumentEditorRowState.Error && NameError is null; + + /// 警告(validationAction = warn 时的违规)。 + public bool IsWarning => _state == DocumentEditorRowState.Warning; + + /// 显示行尾的错误 / 警告。 + public bool ShowMessage => _message is not null && _state is DocumentEditorRowState.Error or DocumentEditorRowState.Warning; + + /// 显示「原值 … ↺ 还原」。 + public bool ShowRevert => _state == DocumentEditorRowState.Modified && _originalValue is not null && _owner.CanEdit; + + /// 原值 "paid"。 + public string OriginalText + { + get + { + if (_originalValue is null) + { + return ""; + } + // 原值只是提示:长字符串截到 16 个字符,别让它把输入框挤窄(完整的原值在对比模式里)。 + string text = _originalValue.IsString && _originalValue.AsString.Length > 16 + ? BsonText.Quote(BsonText.OneLine(_originalValue.AsString[..16]) + "…") + : BsonText.Inline(_originalValue, _owner.Loc, shortenIds: true); + return _owner.Loc.Format("Doc_WasValue", text); + } + } + + /// 显示 enum 提示(没有更要紧的提示时)。 + public bool ShowEnumHint => ShowEnum && !ShowMessage && !ShowRevert; + + // ── 值编辑器的形态 ────────────────────────────────────────────────────── + + /// 枚举下拉。 + public bool ShowEnum => _kind == BsonKind.String && _enumOptions.Count > 0 && !IsIdLocked; + + /// 宽文本框(字符串、UUID 与少见类型的字面量)。 + public bool ShowText => _kind is BsonKind.String or BsonKind.Uuid or BsonKind.Binary or BsonKind.Regex + or BsonKind.Timestamp or BsonKind.Other && !ShowEnum; + + /// 数值框(右对齐、160 宽)。 + public bool ShowNumber => BsonKinds.IsNumeric(_kind); + + /// 开关。 + public bool ShowBool => _kind == BsonKind.Boolean; + + /// 日期。 + public bool ShowDate => _kind == BsonKind.Date; + + /// ObjectId(+ 生成)。 + public bool ShowObjectId => _kind == BsonKind.ObjectId; + + /// null(+ 设为字符串)。 + public bool ShowNull => _kind == BsonKind.Null; + + /// 对象摘要。 + public bool ShowObjectSummary => _kind == BsonKind.Object; + + /// 芯片(收起的标量数组)。 + public bool ShowChips => _kind == BsonKind.Array && !_isExpanded && IsScalarArray; + + /// 数组摘要(对象数组,或展开着的数组)。 + public bool ShowArraySummary => _kind == BsonKind.Array && !ShowChips; + + /// 「展开 / 收起」链接上的字。 + public string ToggleText => _owner.Loc[_isExpanded ? "Doc_Collapse" : "Doc_Expand"]; + + /// 折叠箭头的图标。 + public string ChevronKey => _isExpanded ? "Mongo.chevron-down" : "Mongo.chevron-right"; + + /// 日期旁的时区芯片与 ISO 回显(改过、新加的日期才显示,免得每个日期行都拖一串)。 + public bool ShowDateExtras => ShowDate && _state is DocumentEditorRowState.Modified or DocumentEditorRowState.Added; + + /// 时区芯片:UTC+08:00(表单里的日期按本地时间填)。 + public string TimeZoneText + { + get + { + DateTime at = _value is BsonDateTime date && date.IsValidDateTime ? date.ToUniversalTime() : DateTime.UtcNow; + TimeSpan offset = TimeZoneInfo.Local.GetUtcOffset(at); + return "UTC" + (offset < TimeSpan.Zero ? "-" : "+") + offset.ToString(@"hh\:mm", CultureInfo.InvariantCulture); + } + } + + /// + /// ISO 回显:= 02:30Z —— 本地时间填进去,存进库的是 UTC;同一天只给时刻,跨了天给完整日期。 + /// + public string IsoEcho + { + get + { + if (_value is not BsonDateTime date || !date.IsValidDateTime) + { + return ""; + } + DateTime utc = date.ToUniversalTime(); + DateTime local = utc.ToLocalTime(); + string time = utc.ToString(utc.Second == 0 ? "HH:mm" : "HH:mm:ss", CultureInfo.InvariantCulture) + "Z"; + return "= " + (utc.Date == local.Date ? time : utc.ToString("yyyy-MM-dd'T'", CultureInfo.InvariantCulture) + time); + } + } + + /// 日期部分(日历弹层用,本地)。 + internal DateTime? LocalDate => _value is BsonDateTime date && date.IsValidDateTime ? date.ToUniversalTime().ToLocalTime() : null; + + /// 日历里选了一天:保留时刻,换日期。 + internal void PickDate(DateTime day) + { + DateTime current = LocalDate ?? DateTime.Now; + var picked = new DateTime(day.Year, day.Month, day.Day, current.Hour, current.Minute, current.Second, DateTimeKind.Local); + Text = picked.ToString(BsonText.DateFormat, CultureInfo.InvariantCulture); + } + + /// 「+」按钮的提示:容器加子项,标量加同级。 + public string AddTip => _owner.Loc[IsContainer ? "Doc_AddChild" : "Doc_AddSibling"]; + + // ── 命令 ──────────────────────────────────────────────────────────────── + + /// 展开 / 收起。 + public RelayCommand ToggleCommand { get; } + + /// +(容器加子项,标量加同级)。 + public RelayCommand AddCommand { get; } + + /// 删除。 + public RelayCommand RemoveCommand { get; } + + /// 还原成原值。 + public RelayCommand RevertCommand { get; } + + /// 生成新的 ObjectId。 + public RelayCommand GenerateCommand { get; } + + /// null → 空字符串。 + public RelayCommand SetStringCommand { get; } + + /// 「+ String」:开始输入新的一项。 + public RelayCommand BeginItemCommand { get; } + + /// 新的一项:确定。 + public RelayCommand CommitItemCommand { get; } + + /// 新的一项:取消。 + public RelayCommand CancelItemCommand { get; } + + // ── 通知 ──────────────────────────────────────────────────────────────── + + /// 形态相关的派生属性全部重发(换类型、展开、子行增删之后)。 + internal void RaiseShape() => RaisePropertiesChanged( + nameof(IsContainer), nameof(IsScalarArray), nameof(ContainerCount), nameof(ContainerSummary), + nameof(ShowEnum), nameof(ShowText), nameof(ShowNumber), nameof(ShowBool), nameof(ShowDate), nameof(ShowObjectId), + nameof(ShowNull), nameof(ShowObjectSummary), nameof(ShowChips), nameof(ShowArraySummary), nameof(ShowEnumHint), + nameof(ShowDateExtras), nameof(ToggleText), nameof(ChevronKey), nameof(NewItemKind), nameof(NewItemLabel), nameof(AddTip)); + + private void RaiseAll() + { + RaisePropertiesChanged(nameof(Kind), nameof(SelectedKind), nameof(KindOptions), nameof(Text), nameof(ParseError), + nameof(BoolValue), nameof(EnumValue), nameof(IsExpanded), nameof(IsoEcho), nameof(TimeZoneText), nameof(ChipText)); + RaiseShape(); + ToggleCommand.RaiseCanExecuteChanged(); + } + + /// 只读状态变了(视图 / 写护栏)。 + internal void RaiseEditability() + { + RaisePropertiesChanged(nameof(IsNameEditable), nameof(IsValueEditable), nameof(IsValueReadOnly), nameof(IsKindEditable), + nameof(CanRemove), nameof(CanDrag), nameof(ShowRevert), nameof(ShowEnum), nameof(ShowText)); + AddCommand.RaiseCanExecuteChanged(); + RemoveCommand.RaiseCanExecuteChanged(); + } + + /// + public override string ToString() => $"{Path} ({BsonKinds.Name(_kind)})"; +} + +/// 表单末尾那一格:文档级的问题(缺少必填字段…)+「添加字段」。 +/// 所属对话框。 +internal sealed record DocumentEditorTail(DocumentEditorDialogViewModel Owner); + +/// 字段名补全的一项(来自 Schema 抽样)。 +/// 字段名。 +/// 主导类型。 +/// 出现率(0–1)。 +internal sealed record DocumentEditorSuggestion(string Name, BsonKind Kind, double Ratio) +{ + /// 类型名。 + public string KindName => BsonKinds.Name(Kind); + + /// 出现率文字。 + public string RatioText => (Ratio * 100).ToString("0", CultureInfo.InvariantCulture) + "%"; +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DocumentEditorSchema.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DocumentEditorSchema.cs new file mode 100644 index 0000000..0d94f1b --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DocumentEditorSchema.cs @@ -0,0 +1,110 @@ +using MongoDB.Bson; +using MongoDB.Driver; +using VelaShell.Plugin.Mongo.Bson; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 抽样里的一个字段。 +internal sealed class DocumentEditorFieldStat +{ + /// 构造。 + /// 合并路径(数组不带下标:items.sku)。 + public DocumentEditorFieldStat(string path) + { + Path = path; + int dot = path.LastIndexOf('.'); + Parent = dot < 0 ? "" : path[..dot]; + Name = dot < 0 ? path : path[(dot + 1)..]; + } + + /// 合并路径。 + public string Path { get; } + + /// 父路径(顶层为空串)。 + public string Parent { get; } + + /// 字段名。 + public string Name { get; } + + /// 出现在几份文档里。 + public int Documents { get; set; } + + /// 各类型出现的次数。 + public Dictionary Kinds { get; } = []; + + /// 主导类型(出现最多的那种;并列取先出现的)。 + public BsonKind Dominant => Kinds.Count == 0 ? BsonKind.String : Kinds.MaxBy(static k => k.Value).Key; +} + +/// +/// 集合的字段抽样($sample 1000 份):给"按 Schema 补全缺失字段"与字段名补全用。 +/// +/// 只看字段名、出现率与主导类型,不做 Schema 分析页那种值分布 —— 编辑一份文档时要的只是 +/// "这个集合里的文档通常还有哪些字段、它们一般是什么类型"。 +/// +/// +internal sealed class DocumentEditorSchema +{ + private readonly Dictionary _fields; + + private DocumentEditorSchema(int sampled, Dictionary fields) + { + Sampled = sampled; + _fields = fields; + } + + /// 抽了几份。 + public int Sampled { get; } + + /// 全部字段(按合并路径)。 + public IReadOnlyCollection Fields => _fields.Values; + + /// 某字段的出现率(0–1)。 + public double Ratio(DocumentEditorFieldStat field) => Sampled == 0 ? 0 : (double)field.Documents / Sampled; + + /// 某父路径下的字段,按出现率从高到低。 + public IEnumerable ChildrenOf(string parent) => + _fields.Values.Where(f => f.Parent == parent).OrderByDescending(static f => f.Documents).ThenBy(static f => f.Name, StringComparer.Ordinal); + + /// 从一批文档统计。 + public static DocumentEditorSchema From(IEnumerable documents) + { + var fields = new Dictionary(StringComparer.Ordinal); + int sampled = 0; + var seen = new HashSet(StringComparer.Ordinal); + foreach (BsonDocument document in documents) + { + sampled++; + seen.Clear(); + foreach ((string path, BsonValue value) in BsonPath.Walk(document)) + { + if (!fields.TryGetValue(path, out DocumentEditorFieldStat? stat)) + { + stat = new(path); + fields[path] = stat; + } + // 数组里的对象会把同一路径走好几遍:文档数按一份算,类型按每次出现算。 + if (seen.Add(path)) + { + stat.Documents++; + } + BsonKind kind = BsonKinds.Of(value); + stat.Kinds[kind] = stat.Kinds.GetValueOrDefault(kind) + 1; + } + } + return new(sampled, fields); + } + + /// 抽样($sample,带 maxTimeMS;超时或出错由调用方处理)。 + public static async Task SampleAsync(IMongoCollection collection, int size, CancellationToken ct) + { + PipelineDefinition pipeline = new[] + { + new BsonDocument("$sample", new BsonDocument("size", size)) + }; + var options = new AggregateOptions { MaxTime = TimeSpan.FromSeconds(10), AllowDiskUse = false }; + using IAsyncCursor cursor = await collection.AggregateAsync(pipeline, options, ct).ConfigureAwait(false); + List documents = await cursor.ToListAsync(ct).ConfigureAwait(false); + return From(documents); + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DocumentEditorValuePanel.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DocumentEditorValuePanel.cs new file mode 100644 index 0000000..611ab6e --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DocumentEditorValuePanel.cs @@ -0,0 +1,77 @@ +using Avalonia; +using Avalonia.Controls; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 字符串值那一格的布局:没有行尾提示时输入框撑满;有提示(原值 "paid" ↺ 还原、违规说明)时 +/// 输入框收到"内容宽度,至少 160",提示紧跟其后 —— 与设计稿 status 那一行一致。 +/// +/// 换布局而不换控件:状态是在第一次按键之后才从"未改"变成"已修改"的, +/// 若用两套控件切换,正在输入的那个框会被销毁重建、焦点丢掉。 +/// +/// +public sealed class DocumentEditorValuePanel : Panel +{ + private const double Spacing = 6; + private const double CompactWidth = 160; + private double _editorWidth; + + /// + protected override Size MeasureOverride(Size availableSize) + { + Control? editor = Children.Count > 0 ? Children[0] : null; + Control? hint = Children.Count > 1 ? Children[1] : null; + double height = availableSize.Height; + double hintWidth = 0; + if (hint is { IsVisible: true }) + { + hint.Measure(new Size(double.PositiveInfinity, height)); + hintWidth = hint.DesiredSize.Width; + } + // 提示最多占一半:原值很长时它自己截断,不能把输入框挤没。 + if (!double.IsInfinity(availableSize.Width)) + { + hintWidth = Math.Min(hintWidth, availableSize.Width * 0.5); + } + double room = double.IsInfinity(availableSize.Width) + ? double.PositiveInfinity + : Math.Max(0, availableSize.Width - (hintWidth > 0 ? hintWidth + Spacing : 0)); + if (editor is null) + { + _editorWidth = 0; + } + else if (hintWidth > 0) + { + // 先按内容量一次,再收进 [160, 剩余宽度]。 + editor.Measure(new Size(double.PositiveInfinity, height)); + double wanted = Math.Max(CompactWidth, editor.DesiredSize.Width + 2); + _editorWidth = double.IsInfinity(room) ? wanted : Math.Min(wanted, room); + } + else + { + _editorWidth = double.IsInfinity(room) ? CompactWidth : room; + } + editor?.Measure(new Size(_editorWidth, height)); + double desiredHeight = Math.Max(editor?.DesiredSize.Height ?? 0, hint?.DesiredSize.Height ?? 0); + double desiredWidth = double.IsInfinity(availableSize.Width) + ? _editorWidth + (hintWidth > 0 ? hintWidth + Spacing : 0) + : availableSize.Width; + return new Size(desiredWidth, desiredHeight); + } + + /// + protected override Size ArrangeOverride(Size finalSize) + { + Control? editor = Children.Count > 0 ? Children[0] : null; + Control? hint = Children.Count > 1 ? Children[1] : null; + double editorWidth = Math.Min(_editorWidth, finalSize.Width); + editor?.Arrange(new Rect(0, 0, editorWidth, finalSize.Height)); + if (hint is { IsVisible: true }) + { + double x = Math.Min(editorWidth + Spacing, finalSize.Width); + hint.Arrange(new Rect(x, 0, Math.Max(0, Math.Min(hint.DesiredSize.Width, finalSize.Width - x)), finalSize.Height)); + } + return finalSize; + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DocumentText.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DocumentText.cs new file mode 100644 index 0000000..ad6ccd4 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/DocumentText.cs @@ -0,0 +1,313 @@ +using System.Globalization; +using System.Text; +using MongoDB.Bson; +using VelaShell.Plugin.Mongo.Bson; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 排版好的文档文本 + 每一行对应的字段路径(行标记"已修改 · 原值"要知道某个路径落在第几行)。 +/// 全文。 +/// 第 i 行(0 起)是哪个字段的起始行;收尾括号行为 。 +internal sealed record PrintedCard(string Text, IReadOnlyList LinePaths) +{ + /// 某个路径(或它最近的、印出来了的祖先)所在的行号(1 起);整份文档的头一行兜底。 + public int LineOf(string path) + { + string? probe = path; + while (probe is not null) + { + for (int i = 0; i < LinePaths.Count; i++) + { + if (LinePaths[i] == probe) + { + return i + 1; + } + } + int dot = probe.LastIndexOf('.'); + probe = dot > 0 ? probe[..dot] : null; + } + return 1; + } +} + +/// +/// JSON 卡片的排版。不直接用 :那是给复制 / 导出的"标准"形态, +/// 这里要的是"读着舒服 + 知道每行是哪个字段" —— 短的标量容器压成一行(customer: { id: …, name: "李娜" }), +/// 长的才展开;同时记下每一行的路径给行标记用。三种扩展 JSON 写法共用一套排版,只是标量的写法不同。 +/// +/// 只读卡片用预览写法(给了 fold):对象数组折成 [ …2 项 ]、嵌套里的 ObjectId 与长字符串截短 —— +/// 一页几十张卡片,每张都把 items 全摊开,一屏只看得到一两份文档。完整文本在「编辑」「复制」与检查器的 JSON 页里。 +/// +/// +internal static class CardPrinter +{ + private const int InlineLimit = 88; + + /// 排版一份文档。 + /// 文档。 + /// 扩展 JSON 写法。 + /// 预览写法里对象数组的折叠文字(…{0} 项); = 完整写法(编辑用)。 + public static PrintedCard Print(BsonDocument document, EjsonMode mode, Func? fold = null) + { + var builder = new StringBuilder(); + var lines = new List { null }; + new Writer(builder, lines, mode, fold).Container(document, null, 0); + return new(builder.ToString(), lines); + } + + /// 单行写法(容器写成 { a: 1, b: 2 },与 mongosh 的单行输出一致)。 + internal static string Inline(BsonValue value, EjsonMode mode) => new Writer(new StringBuilder(), [], mode, null).Inline(value, nested: false); + + private sealed class Writer(StringBuilder b, List lines, EjsonMode mode, Func? fold) + { + private bool Compact => fold is not null; + + /// 写一个容器(多行)。 + public void Container(BsonValue container, string? path, int depth) + { + bool isArray = container is BsonArray; + IReadOnlyList<(string Name, BsonValue Value)> children = container is BsonDocument doc + ? [.. doc.Elements.Select(static e => (e.Name, e.Value))] + : [.. container.AsBsonArray.Select(static (v, i) => (i.ToString(CultureInfo.InvariantCulture), v))]; + b.Append(isArray ? '[' : '{'); + if (children.Count == 0) + { + b.Append(isArray ? ']' : '}'); + return; + } + for (int i = 0; i < children.Count; i++) + { + (string name, BsonValue value) = children[i]; + string childPath = BsonPath.Join(path, name); + b.Append('\n'); + lines.Add(childPath); + Indent(depth + 1); + if (!isArray) + { + b.Append(Key(name)).Append(": "); + } + Value(value, childPath, depth + 1); + if (i < children.Count - 1) + { + b.Append(','); + } + } + b.Append('\n'); + lines.Add(null); + Indent(depth); + b.Append(isArray ? ']' : '}'); + } + + private void Value(BsonValue value, string path, int depth) + { + if (Compact && value is BsonArray { Count: > 0 } array && array.Any(static v => v is BsonDocument or BsonArray)) + { + b.Append("[ ").Append(fold!(array.Count)).Append(" ]"); + return; + } + if (value is BsonDocument or BsonArray && !Fits(value)) + { + Container(value, path, depth); + return; + } + b.Append(Inline(value, nested: false)); + } + + /// 单行写法; 为真时(容器内部)预览写法会截短 ObjectId 与长字符串。 + public string Inline(BsonValue value, bool nested) + { + switch (value) + { + case BsonDocument doc: + return doc.ElementCount == 0 + ? "{}" + : "{ " + string.Join(", ", doc.Elements.Select(e => Key(e.Name) + ": " + Inline(e.Value, nested: true))) + " }"; + case BsonArray array: + return array.Count == 0 ? "[]" : "[ " + string.Join(", ", array.Select(v => Inline(v, nested: true))) + " ]"; + case BsonObjectId id when Compact && nested && mode == EjsonMode.Shell: + return $"ObjectId(\"{id.Value.ToString()[..12]}…\")"; + case BsonString text when Compact && nested && text.Value.Length > 24: + return BsonText.Quote(text.Value[..20] + "…"); + default: + return mode == EjsonMode.Shell ? BsonText.Literal(value) : BsonText.Compact(value, mode); + } + } + + /// 压成一行放得下吗:只含标量、且不超过一行的宽度。 + private bool Fits(BsonValue value) + { + bool scalarsOnly = value switch + { + BsonDocument doc => doc.Elements.All(static e => e.Value is not (BsonDocument or BsonArray)), + BsonArray array => array.All(static v => v is not (BsonDocument or BsonArray)), + _ => true + }; + return scalarsOnly && Inline(value, nested: false).Length <= InlineLimit; + } + + private string Key(string name) => mode == EjsonMode.Shell ? BsonText.FieldName(name) : BsonText.Quote(name); + + private void Indent(int depth) => b.Append(' ', depth * 2); + } +} + + +/// +/// 在一段(可能写了一半的)mongosh / JSON 文本里认出"光标落在哪个字段的键或值上",以及每个键在第几行。 +/// 容错地扫:字符串、注释跳过,括号配对,键可以是裸标识符或带引号 —— 编辑器里边打字边问,文本多半不完整。 +/// +internal static class CaretPathScanner +{ + /// 扫描结果。 + /// 光标所在的字段路径(数组下标保留,items.0.sku);在根上为空串。 + /// 光标在值的位置(冒号之后)。 + /// 光标所在那个词 / 值的起点(补全的替换起点)。 + /// 光标所在的对象的路径(键位置补全时列它的子字段)。 + /// 每个键路径首次出现的行号(1 起)。 + internal sealed record Result(string Path, bool InValue, int TokenStart, string ParentPath, IReadOnlyDictionary KeyLines); + + private sealed class Frame(string path, bool isArray) + { + /// 这一层的路径。 + public string Path { get; } = path; + + /// 是数组(元素按下标计)。 + public bool IsArray { get; } = isArray; + + /// 当前键(对象里)。 + public string? Key { get; set; } + + /// 已过冒号、在值的位置。 + public bool InValue { get; set; } + + /// 当前下标(数组里)。 + public int Index { get; set; } + } + + /// 扫到 为止( 取 -1 扫全文,只要行号表)。 + public static Result Scan(string text, int caret) + { + int end = caret < 0 ? text.Length : Math.Min(caret, text.Length); + var stack = new Stack(); + var lines = new Dictionary(StringComparer.Ordinal); + int line = 1; + int i = 0; + int tokenStart = 0; + while (i < (caret < 0 ? text.Length : end)) + { + char c = text[i]; + if (c == '\n') + { + line++; + i++; + tokenStart = i; + continue; + } + if (c is '"' or '\'') + { + int close = ShellJson.SkipString(text, i); + if (stack.TryPeek(out Frame? frame) && !frame.IsArray && !frame.InValue) + { + // 带引号的键。 + string key = text[(i + 1)..Math.Max(i + 1, close - 1)]; + frame.Key = key; + lines.TryAdd(Join(frame.Path, key), line); + } + tokenStart = i; + i = close; + continue; + } + if (c == '/' && i + 1 < text.Length && text[i + 1] is '/' or '*') + { + int stop = text[i + 1] == '/' ? text.IndexOf('\n', i) : text.IndexOf("*/", i + 2, StringComparison.Ordinal); + i = stop < 0 ? text.Length : (text[i + 1] == '/' ? stop : stop + 2); + continue; + } + if (c is '{' or '[') + { + string path = ""; + if (stack.TryPeek(out Frame? parent)) + { + path = parent.IsArray + ? Join(parent.Path, parent.Index.ToString(CultureInfo.InvariantCulture)) + : Join(parent.Path, parent.Key ?? ""); + } + stack.Push(new Frame(path, c == '[')); + i++; + tokenStart = i; + continue; + } + if (c is '}' or ']') + { + if (stack.Count > 0) + { + stack.Pop(); + } + i++; + tokenStart = i; + continue; + } + if (c == ':' && stack.TryPeek(out Frame? obj) && !obj.IsArray) + { + obj.InValue = true; + i++; + tokenStart = i; + continue; + } + if (c == ',' && stack.TryPeek(out Frame? current)) + { + if (current.IsArray) + { + current.Index++; + } + else + { + current.Key = null; + current.InValue = false; + } + i++; + tokenStart = i; + continue; + } + if (ShellJson.IsIdentifierStart(c) && stack.TryPeek(out Frame? holder) && !holder.IsArray && !holder.InValue) + { + int start = i; + while (i < text.Length && (ShellJson.IsIdentifierPart(text[i]) || text[i] == '.')) + { + i++; + } + holder.Key = text[start..i]; + lines.TryAdd(Join(holder.Path, holder.Key), line); + tokenStart = start; + continue; + } + if (char.IsWhiteSpace(c)) + { + i++; + tokenStart = i; + continue; + } + i++; + } + + if (!stack.TryPeek(out Frame? top)) + { + return new("", false, end, "", lines); + } + // 光标所在的那个词:往回找到空白 / 标点为止。 + int wordStart = end; + while (wordStart > 0 && !char.IsWhiteSpace(text[wordStart - 1]) && text[wordStart - 1] is not (',' or ':' or '{' or '[' or '(' )) + { + wordStart--; + } + if (top.IsArray) + { + return new(Join(top.Path, top.Index.ToString(CultureInfo.InvariantCulture)), true, wordStart, top.Path, lines); + } + return top.InValue + ? new(Join(top.Path, top.Key ?? ""), true, wordStart, top.Path, lines) + : new(top.Path, false, wordStart, top.Path, lines); + } + + private static string Join(string parent, string name) => parent.Length == 0 ? name : name.Length == 0 ? parent : $"{parent}.{name}"; +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Collection/GridDrill.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/GridDrill.cs new file mode 100644 index 0000000..bf2f15f --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/GridDrill.cs @@ -0,0 +1,176 @@ +using System.Globalization; +using MongoDB.Bson; +using VelaShell.Plugin.Mongo.Bson; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 网格钻入的一层(Navicat 的「双击对象 / 数组单元格进入」):一份文档里某个对象或数组字段,摊成一张子表。 +/// +/// 数组:一个元素一行,行号是下标;元素是文档时列是它们字段的并集,有标量元素时多一列「值」。 +/// 对象:就一行,列是它的字段。 +/// +/// 子表的行与所属文档共用暂存身份(见 的子表构造),单元格路径从文档根算起。 +/// +internal sealed class GridDrill +{ + private GridDrill(CollectionRow root, string path, bool isArray, int count) + { + Root = root; + Path = path; + IsArray = isArray; + Count = count; + } + + /// 所属文档的那一行(顶层行)。 + public CollectionRow Root { get; } + + /// 容器从文档根算起的路径(items、items.2.attrs)。 + public string Path { get; } + + /// 是数组(否则是对象)。 + public bool IsArray { get; } + + /// 元素数 / 字段数。 + public int Count { get; } + + /// 这一层的列。 + public IReadOnlyList Columns { get; private set; } = []; + + /// + /// 在 的当前版本(原像 + 暂存修改)里取 ,是对象或数组就建一层; + /// 不是容器(字段被删了、改成了标量)返回 。 + /// + /// 集合工作台。 + /// 文档那一行。 + /// 容器路径。 + /// 同一路径上一次的列(沿用用户拖过的宽度)。 + /// 子表的行。 + public static GridDrill? Create(CollectionTabViewModel owner, CollectionRow root, string path, + IReadOnlyList? previous, out List rows) + { + rows = []; + switch (BsonPath.Get(root.Document, path)) + { + case BsonArray array: + { + var drill = new GridDrill(root, path, isArray: true, array.Count); + drill.Columns = ArrayColumns(owner.Loc, array, previous); + for (int i = 0; i < array.Count; i++) + { + rows.Add(new CollectionRow(owner, root, drill, i)); + } + return drill; + } + case BsonDocument document: + { + var drill = new GridDrill(root, path, isArray: false, document.ElementCount); + drill.Columns = DocumentColumns([document], previous); + rows.Add(new CollectionRow(owner, root, drill, -1)); + return drill; + } + default: + return null; + } + } + + /// + /// 筛选用的路径:去掉数组下标(items.2.sku → items.sku)。 + /// 带下标的筛选只匹配"第 3 个元素恰好是它"的文档,几乎从来不是想要的。 + /// + public static string FilterPath(string path) => + string.Join('.', path.Split('.').Where(static s => s.Length == 0 || !s.All(char.IsAsciiDigit))); + + /// 面包屑一段的文字:数组下标写成 [2]。 + public static string SegmentLabel(string segment) => + segment.Length > 0 && segment.All(char.IsAsciiDigit) ? $"[{segment}]" : segment; + + private static List ArrayColumns(Loc loc, BsonArray array, IReadOnlyList? previous) + { + var scalarKinds = new Dictionary(); + int scalarLength = 0; + var documents = new List(); + for (int i = 0; i < array.Count; i++) + { + if (array[i] is BsonDocument document) + { + documents.Add(document); + continue; + } + BsonKind kind = BsonKinds.Of(array[i]); + scalarKinds[kind] = scalarKinds.GetValueOrDefault(kind) + 1; + if (i < 200) + { + scalarLength = Math.Max(scalarLength, Math.Min(40, BsonText.Cell(array[i]).Length)); + } + } + var columns = new List(); + // 有标量元素(或者是空数组 —— 给一个列头,别让子表只剩一片空白)才有「值」这一列。 + if (scalarKinds.Count > 0 || array.Count == 0) + { + string name = loc["Cw_DrillValue"]; + BsonKind kind = CollectionTabViewModel.Dominant(scalarKinds); + double width = Width(previous, name, isElementValue: true) + ?? CollectionTabViewModel.EstimateWidth(name, kind, scalarLength); + columns.Add(new CollectionColumn(name, kind, width, isElementValue: true)); + } + columns.AddRange(DocumentColumns(documents, previous)); + return columns; + } + + private static List DocumentColumns(IReadOnlyList documents, IReadOnlyList? previous) + { + var order = new List(); + var kinds = new Dictionary>(StringComparer.Ordinal); + var lengths = new Dictionary(StringComparer.Ordinal); + for (int i = 0; i < documents.Count; i++) + { + foreach (BsonElement element in documents[i]) + { + if (!kinds.TryGetValue(element.Name, out Dictionary? counts)) + { + counts = []; + kinds[element.Name] = counts; + order.Add(element.Name); + } + BsonKind kind = BsonKinds.Of(element.Value); + counts[kind] = counts.GetValueOrDefault(kind) + 1; + if (i < 200 && lengths.GetValueOrDefault(element.Name) < 40) + { + lengths[element.Name] = Math.Max(lengths.GetValueOrDefault(element.Name), BsonText.Cell(element.Value).Length); + } + } + } + if (order.Remove("_id")) + { + order.Insert(0, "_id"); + } + return + [ + .. order.Select(name => + { + BsonKind kind = CollectionTabViewModel.Dominant(kinds[name]); + double width = Width(previous, name, isElementValue: false) + ?? CollectionTabViewModel.EstimateWidth(name, kind, lengths.GetValueOrDefault(name)); + return new CollectionColumn(name, kind, width); + }) + ]; + } + + private static double? Width(IReadOnlyList? previous, string name, bool isElementValue) => + previous?.FirstOrDefault(c => c.IsElementValue == isElementValue && c.Name == name)?.Width; + + /// 右侧那行小字:数组 · 4 项 / 对象 · 3 个字段。 + public string Summary(Loc loc) => + loc.Format(IsArray ? "Cw_DrillArray" : "Cw_DrillObject", Count.ToString("N0", CultureInfo.InvariantCulture)); +} + +/// 面包屑的一段。 +/// 文字(文档 #3、items、[2])。 +/// 点它回到的容器路径;文档那一段为空串(= 退出钻入)。 +/// 最后一段(当前所在,不可点)。 +internal sealed record DrillCrumb(string Label, string Path, bool IsLast) +{ + /// 最后一段之前都画一个「›」。 + public bool HasSeparator => !IsLast; +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Collection/GridEditBox.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/GridEditBox.cs new file mode 100644 index 0000000..2452fcf --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/GridEditBox.cs @@ -0,0 +1,33 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Threading; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 内联编辑用的文本框:一出现就拿焦点并全选。 +/// +/// 网格、树、检查器的编辑器都是"按需出现"的(进入编辑才挂上),它们出现那一刻用户的手已经在键盘上了 —— +/// 还得再点一下才能打字,是内联编辑最常见的毛病。挂到可视树上之后再派发一次聚焦, +/// 是因为挂上的那一拍布局还没跑完,立刻 Focus 会被虚拟化面板随后的重排抢走。 +/// +/// +public sealed class GridEditBox : TextBox +{ + /// + protected override Type StyleKeyOverride => typeof(TextBox); + + /// + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + Dispatcher.UIThread.Post(() => + { + if (IsEffectivelyVisible) + { + Focus(); + SelectAll(); + } + }, DispatcherPriority.Input); + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Collection/GridModels.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/GridModels.cs new file mode 100644 index 0000000..23ef9f1 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/GridModels.cs @@ -0,0 +1,510 @@ +using Avalonia.Media; +using MongoDB.Bson; +using VelaShell.Plugin.Mongo.Bson; +using VelaShell.Plugin.Mongo.Staging; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 集合工作台的三种视图(右上角分段)。 +internal enum CollectionViewMode +{ + /// 网格(设计稿 01)。 + Grid, + + /// 树(设计稿 13)。 + Tree, + + /// JSON 卡片(设计稿 02)。 + Json +} + +/// 一行在暂存区里的状态(设计稿 09「网格单元格状态」)。 +internal enum CollectionRowState +{ + /// 原样。 + Normal, + + /// 有暂存修改。 + Modified, + + /// 暂存的新文档。 + Added, + + /// 暂存删除。 + Deleted +} + +/// +/// 网格的一列:本页文档顶层字段的并集,按首次出现的顺序(_id 在前)。 +/// 列头是「字段名 + 主导类型小字」—— 同一列里混着几种类型很常见,标出主导类型让人一眼看出"这列大体是什么"。 +/// 钻进一个数组 / 对象之后(),列是那一层的子字段。 +/// +internal sealed class CollectionColumn : ObservableObject +{ + private double _width; + private int _sortDirection; + + /// 构造。 + /// 字段名( 时只是列头文字)。 + /// 主导类型。 + /// 初始宽度。 + /// 钻进数组后,标量元素自身的那一列(路径就是元素本身,不再往下接字段名)。 + public CollectionColumn(string name, BsonKind kind, double width, bool isElementValue = false) + { + Name = name; + Kind = kind; + _width = width; + IsElementValue = isElementValue; + } + + /// 字段名。 + public string Name { get; } + + /// 数组里标量元素自身的那一列。 + public bool IsElementValue { get; } + + /// 主导类型(本页里出现最多的那种,缺失不计)。 + public BsonKind Kind { get; } + + /// 列头小字(ObjectId、Decimal128、布尔写短一点:Bool)。 + public string KindLabel => Kind switch + { + BsonKind.Boolean => "Bool", + BsonKind.Missing => "", + _ => BsonKinds.Name(Kind) + }; + + /// 数值列右对齐(列头也跟着)。 + public bool IsNumeric => BsonKinds.IsNumeric(Kind); + + /// 宽度(可拖)。 + public double Width + { + get => _width; + set => SetProperty(ref _width, Math.Clamp(value, 48, 900)); + } + + /// 排序方向:1 升序、-1 降序、0 不排序(列头的箭头)。 + public int SortDirection + { + get => _sortDirection; + set + { + if (SetProperty(ref _sortDirection, value)) + { + RaisePropertiesChanged(nameof(IsSorted), nameof(SortIcon)); + } + } + } + + /// 按这一列排序着。 + public bool IsSorted => _sortDirection != 0; + + /// 排序箭头图标。 + public string SortIcon => _sortDirection > 0 ? "Mongo.arrow-up" : "Mongo.arrow-down"; +} + +/// +/// 网格的一行。单元格按需生成(虚拟化只给可见的几十行建容器,大页里绝大多数行的单元格从来不会被算出来)。 +/// +internal sealed class CollectionRow : ObservableObject +{ + private readonly CollectionTabViewModel _owner; + private IReadOnlyList? _cells; + private BsonDocument _document; + private CollectionRowState _state; + + /// 服务器上读到的一行。 + public CollectionRow(CollectionTabViewModel owner, int number, BsonDocument original) + { + _owner = owner; + Root = this; + Number = number; + Original = original; + _document = original; + Recompute(); + } + + /// 暂存的新文档那一行。 + public CollectionRow(CollectionTabViewModel owner, int number, StagedInsert insert) + { + _owner = owner; + Root = this; + Number = number; + Insert = insert; + _document = insert.Document; + Recompute(); + } + + /// + /// 钻入后子表里的一行:数组的第 个元素,或对象本身( 为 -1)。 + /// + /// 它与 共用同一份文档、同一个暂存身份( / 照抄), + /// 单元格的路径是从文档根算起的**绝对路径**(items.2.qty)—— 所以暂存、提交、撤销、冲突处理 + /// 一行都不用改:在子表里改一格,与在树视图里改那个嵌套字段是同一件事。 + /// + /// + public CollectionRow(CollectionTabViewModel owner, CollectionRow root, GridDrill drill, int index) + { + _owner = owner; + Root = root; + Drill = drill; + Number = index; + Original = root.Original; + Insert = root.Insert; + _document = root.Document; + Recompute(); + } + + /// 行号(1 起,跨页连续);子表里是数组下标。 + public int Number { get; } + + /// + /// 行号文字:新增行是 *;子表里是数组下标(与路径里的 items.2 同一个数,从 0 起),对象那一行留空。 + /// + public string NumberText => Drill is { } drill + ? drill.IsArray ? Number.ToString(System.Globalization.CultureInfo.InvariantCulture) : "" + : Insert is null ? Number.ToString(System.Globalization.CultureInfo.InvariantCulture) : "*"; + + /// 所属文档的那一行(顶层行就是自己)。 + public CollectionRow Root { get; } + + /// 子表那一层;顶层行为 。 + public GridDrill? Drill { get; } + + /// + /// 这一行在文档里的位置:顶层为空;数组元素是 items.2;对象那一行是对象自身的路径。 + /// + public string BasePath => Drill is not { } drill ? "" + : drill.IsArray ? $"{drill.Path}.{Number.ToString(System.Globalization.CultureInfo.InvariantCulture)}" + : drill.Path; + + /// 某一列在这一行里的绝对路径。 + public string PathOf(CollectionColumn column) => + Drill is null ? column.Name : column.IsElementValue ? BasePath : $"{BasePath}.{column.Name}"; + + /// 服务器版本;新增行为 。 + public BsonDocument? Original { get; } + + /// 暂存的新文档;已有文档为 。 + public StagedInsert? Insert { get; } + + /// _id(新增行还没有)。 + public BsonValue? Id => Original?.GetValue("_id", BsonNull.Value); + + /// 所属工作台(单元格取文案用)。 + internal CollectionTabViewModel Owner => _owner; + + /// 界面上该显示的版本(原像 + 暂存修改)。 + public BsonDocument Document => _document; + + /// 状态。 + public CollectionRowState State => _state; + + /// 有暂存修改。 + public bool IsModified => _state == CollectionRowState.Modified; + + /// 暂存的新文档。 + public bool IsAdded => _state == CollectionRowState.Added; + + /// 暂存删除。 + public bool IsDeleted => _state == CollectionRowState.Deleted; + + /// 行号格里的状态图标(删除 −、新增 +)。 + public bool HasStateIcon => _state is CollectionRowState.Added or CollectionRowState.Deleted; + + /// 行号格状态图标的键。 + public string StateIcon => _state == CollectionRowState.Added ? "Mongo.plus" : "Mongo.minus"; + + /// 行号格状态图标的颜色令牌。 + public string StateIconToken => _state == CollectionRowState.Added ? "VelaStatusConnected" : "VelaError"; + + /// 单元格。 + public IReadOnlyList Cells => _cells ??= BuildCells(); + + /// 暂存区变了之后重算这一行(只对已经生成过单元格的行做实事)。 + public void Recompute() + { + StagingArea staging = _owner.Staging; + if (Insert is not null) + { + _document = Insert.Document; + _state = CollectionRowState.Added; + } + else if (staging.IsDeleted(Id)) + { + _document = Original!; + _state = CollectionRowState.Deleted; + } + else + { + _document = staging.Current(Original!); + _state = staging.EditOf(Id) is { IsEmpty: false } ? CollectionRowState.Modified : CollectionRowState.Normal; + } + if (_cells is not null) + { + _cells = null; + RaisePropertyChanged(nameof(Cells)); + } + RaisePropertiesChanged(nameof(Document), nameof(State), nameof(IsModified), nameof(IsAdded), nameof(IsDeleted), + nameof(HasStateIcon), nameof(StateIcon), nameof(StateIconToken)); + } + + /// 某一列的单元格。 + public CollectionCell? CellOf(CollectionColumn column) => Cells.FirstOrDefault(c => ReferenceEquals(c.Column, column)); + + private List BuildCells() + { + StagedEdit? edit = Insert is null ? _owner.Staging.EditOf(Id) : null; + IReadOnlyList columns = Drill?.Columns ?? _owner.Columns; + var cells = new List(columns.Count); + foreach (CollectionColumn column in columns) + { + string path = PathOf(column); + // 顶层按字段名直取(字段名里带点也取得到);子表按绝对路径走。 + BsonValue? value = Drill is null + ? _document.TryGetValue(column.Name, out BsonValue v) ? v : null + : BsonPath.Get(_document, path); + ChangeRelation relation = edit?.RelationOf(path) ?? ChangeRelation.None; + cells.Add(new CollectionCell(this, column, path, value, relation != ChangeRelation.None, + _owner.CurrentColumn == column && _owner.IsCurrentRow(this))); + } + return cells; + } +} + +/// +/// 一个单元格。内容一经生成就不变(暂存区变了整行重建),只有"当前格"与"编辑中"两种状态会动。 +/// +internal sealed class CollectionCell : ObservableObject +{ + private bool _isCurrent; + private InlineValueEditor? _editor; + + /// 构造。 + public CollectionCell(CollectionRow row, CollectionColumn column, string path, BsonValue? value, bool isModified, bool isCurrent) + { + Row = row; + Column = column; + Path = path; + Value = value; + Kind = BsonKinds.Of(value); + IsModified = isModified; + _isCurrent = isCurrent; + (Prefix, Text) = Split(value, Kind, row); + } + + /// 所在行。 + public CollectionRow Row { get; } + + /// 所在列。 + public CollectionColumn Column { get; } + + /// 值;字段不存在为 。 + public BsonValue? Value { get; } + + /// 类型。 + public BsonKind Kind { get; } + + /// 容器的前缀({3}、[4]);标量为空。 + public string Prefix { get; } + + /// 主文字。 + public string Text { get; } + + /// 有没有前缀。 + public bool HasPrefix => Prefix.Length > 0; + + /// 暂存修改过(橙色淡底 + 左 2px 橙条)。 + public bool IsModified { get; } + + /// 空的对象 / 数组(后面跟一句淡色的「(空对象)」「0 项」)。 + public bool IsEmptyContainer => Value is BsonDocument { ElementCount: 0 } or BsonArray { Count: 0 }; + + /// 字段不存在。 + public bool IsMissing => Kind == BsonKind.Missing; + + /// null(斜体)。 + public bool IsNull => Kind == BsonKind.Null || (Row.IsAdded && Path == "_id"); + + /// 数值右对齐。 + public TextAlignment TextAlignment => BsonKinds.IsNumeric(Kind) ? TextAlignment.Right : TextAlignment.Left; + + /// + /// 主文字的颜色令牌。字符串用正文色而不是类型色(设计稿 01:单号、状态这些"读的东西"要显眼), + /// 容器的摘要退一档;改过的格子统一橙色。 + /// + public string TextToken => IsModified ? "VelaWarning" : Kind switch + { + BsonKind.Object or BsonKind.Array when IsEmptyContainer => "VelaTextMuted", + BsonKind.String => "VelaTextPrimary", + BsonKind.Object => "VelaTextSecondary", + BsonKind.Array => "VelaTextTertiary", + BsonKind.Missing => "VelaTextMuted", + _ when Row.IsAdded && Path == "_id" => "VelaTextMuted", + _ => BsonKinds.ColorToken(Kind) + }; + + /// + /// 主文字走界面字体还是等宽字体:等宽字体多半不带中文字形,回落出来的字宽忽大忽小; + /// 摘要(陈立 · VIP)与含中文的字符串用界面字体,与设计稿一致。 + /// + public bool UseUiFont => Kind is BsonKind.Object || (Kind is BsonKind.String or BsonKind.Array && !IsAscii(Text)) + || (Row.IsAdded && Path == "_id"); + + /// 当前格(键盘 F2 / Enter 编辑的那一格)。 + public bool IsCurrent + { + get => _isCurrent; + set => SetProperty(ref _isCurrent, value); + } + + /// 编辑器;不在编辑为 。 + public InlineValueEditor? Editor + { + get => _editor; + set + { + InlineValueEditor? previous = _editor; + if (SetProperty(ref _editor, value)) + { + if (previous is not null) + { + previous.PropertyChanged -= OnEditorChanged; + } + if (value is not null) + { + value.PropertyChanged += OnEditorChanged; + } + RaisePropertiesChanged(nameof(IsEditing), nameof(HasEditorError)); + } + } + } + + /// 正在编辑。 + public bool IsEditing => _editor is not null; + + /// + /// 编辑器里的值解析不了(单元格红框)。单元格模板绑它而不是 Editor.HasError: + /// 绝大多数格子不在编辑,路径中段的 null 每一格都会报一条绑定错误。 + /// + public bool HasEditorError => _editor?.HasError == true; + + private void OnEditorChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(InlineValueEditor.HasError)) + { + RaisePropertyChanged(nameof(HasEditorError)); + } + } + + /// 值从文档根算起的点路径(顶层字段就是列名;子表里是 items.2.qty)。 + public string Path { get; } + + /// 能钻进去看(对象 / 数组,空的也算 —— 进去才能往空数组里加元素)。 + public bool IsContainer => Kind is BsonKind.Object or BsonKind.Array; + + private (string Prefix, string Text) Split(BsonValue? value, BsonKind kind, CollectionRow row) + { + Loc loc = row.Owner.Loc; + if (row.IsAdded && value is null && kind == BsonKind.Missing && Path == "_id") + { + return ("", loc["Cw_AutoId"]); + } + switch (kind) + { + case BsonKind.Object: + { + BsonDocument doc = value!.AsBsonDocument; + return doc.ElementCount == 0 ? ("{ }", loc["Cw_EmptyObject"]) : ($"{{{doc.ElementCount}}}", BsonText.Summary(doc)); + } + case BsonKind.Array: + { + BsonArray array = value!.AsBsonArray; + return array.Count == 0 ? ("[ ]", loc.Format("Cw_ItemCount", 0)) : ($"[{array.Count}]", BsonText.ArraySummary(array)); + } + default: + return ("", BsonText.Cell(value)); + } + } + + private static bool IsAscii(string text) + { + foreach (char c in text) + { + if (c > 0x7F) + { + return false; + } + } + return true; + } +} + +/// +/// 单元格 / 字段的内联编辑器:文本 + 目标类型 + 可选的候选值(布尔、本页里取值不多的字符串列); +/// 日期不给候选值,编辑框右边是日历按钮()。 +/// +internal sealed class InlineValueEditor : ObservableObject +{ + private string _text; + private string? _error; + + /// 构造。 + /// 初始文本()。 + /// 目标类型。 + /// 候选值;没有为空。 + public InlineValueEditor(string text, BsonKind kind, IReadOnlyList choices) + { + _text = text; + Kind = kind; + Choices = choices; + } + + /// 编辑中的文本。 + public string Text + { + get => _text; + set + { + if (SetProperty(ref _text, value)) + { + Error = null; + } + } + } + + /// 目标类型。 + public BsonKind Kind { get; } + + /// 候选值(下拉箭头里的那些)。 + public IReadOnlyList Choices { get; } + + /// 有没有候选值。 + public bool HasChoices => Choices.Count > 0; + + /// 编辑的是日期(编辑框右边给日历按钮)。 + public bool IsDate => Kind == BsonKind.Date; + + /// 编辑框右边有没有按钮(候选值下拉,或日期的日历)。 + public bool HasDropDown => HasChoices || IsDate; + + /// 那个按钮的图标。 + public string DropDownIcon => IsDate ? "Mongo.calendar" : "Mongo.chevron-down"; + + /// 解析失败的原因;没有为 。 + public string? Error + { + get => _error; + set + { + if (SetProperty(ref _error, value)) + { + RaisePropertyChanged(nameof(HasError)); + } + } + } + + /// 有错。 + public bool HasError => _error is not null; +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Collection/GridPaneView.axaml b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/GridPaneView.axaml new file mode 100644 index 0000000..b4de839 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/GridPaneView.axaml @@ -0,0 +1,171 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Collection/GridPaneView.axaml.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/GridPaneView.axaml.cs new file mode 100644 index 0000000..1de708c --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/GridPaneView.axaml.cs @@ -0,0 +1,404 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.VisualTree; +using MongoDB.Bson; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 网格视图:列头与行的水平滚动同步、拖列宽 / 双击分隔线自适应、点列头排序、当前格与内联编辑的键盘手势、 +/// 双击对象 / 数组钻入与 Backspace 回退、右键菜单。 +/// +public sealed partial class GridPaneView : UserControl +{ + private CollectionTabViewModel? _viewModel; + private ScrollViewer? _rowsScroll; + + /// 构造(由集合工作台视图在 XAML 里建,之后 )。 + public GridPaneView() + { + InitializeComponent(); + GridList.TemplateApplied += (_, e) => + { + _rowsScroll = e.NameScope.Find("PART_ScrollViewer"); + if (_rowsScroll is not null) + { + _rowsScroll.ScrollChanged += (_, _) => HeaderScroll.Offset = new Vector(_rowsScroll.Offset.X, 0); + } + }; + GridList.AddHandler(PointerPressedEvent, OnPointerPressed, RoutingStrategies.Tunnel, handledEventsToo: true); + GridList.AddHandler(KeyDownEvent, OnKeyDown, RoutingStrategies.Tunnel); + GridList.AddHandler(Button.ClickEvent, OnChoicesClick); + GridList.DoubleTapped += OnDoubleTapped; + GridList.ContextRequested += OnContextRequested; + GridList.SelectionChanged += (_, _) => + { + if (_viewModel is not null) + { + _viewModel.GridSelectedRows = [.. GridList.SelectedItems?.OfType() ?? []]; + } + }; + // 分隔线上双击:按内容自动调列宽。 + ColumnFit.OnGripDoubleClick(HeaderItems, column => + { + if (column is CollectionColumn target) + { + AutoFit(target); + } + }); + } + + /// 行列表(测试用)。 + internal ListBox List => GridList; + + /// 最近一次从日期格弹出的日历(测试用)。 + internal Flyout? DatePicker { get; private set; } + + /// 接上视图模型。 + internal void Attach(CollectionTabViewModel viewModel) + { + _viewModel = viewModel; + viewModel.RowAdded += row => GridList.ScrollIntoView(row); + } + + private void OnHeaderClick(object? sender, RoutedEventArgs e) + { + // 钻入后列是嵌套字段,服务器端的排序管不到它们;子表就按数组原本的顺序看。 + if (_viewModel is { IsDrilled: false } vm && (sender as Control)?.DataContext is CollectionColumn column) + { + vm.SortByColumnCommand.Execute(column); + } + } + + private void OnResizeDelta(object? sender, VectorEventArgs e) + { + if ((sender as Control)?.DataContext is CollectionColumn column) + { + column.Width += e.Vector.X; + } + } + + /// + /// 按内容自动调列宽:列头与当前已实现的行里这一列的内容,按不受限宽度量一遍取最大值。 + /// 只看已实现的行 —— 5 万行的页里逐格排版不值当,眼睛看到的就是这几十行。 + /// + internal void AutoFit(CollectionColumn column) + { + double width = 0; + foreach (Border header in HeaderItems.GetVisualDescendants().OfType() + .Where(b => b.Classes.Contains("hcell") && ReferenceEquals(b.DataContext, column))) + { + if (header.GetVisualDescendants().OfType + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Collection/JsonCardsView.axaml.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/JsonCardsView.axaml.cs new file mode 100644 index 0000000..54f55ef --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Collection/JsonCardsView.axaml.cs @@ -0,0 +1,144 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.VisualTree; +using AvaloniaEdit.Document; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// JSON 视图:卡片编辑器里的 Ctrl+S 更新文档 / Esc 取消 / Alt+↑↓ 移动字段,点卡片换大纲,点大纲跳到那一行。 +/// +public sealed partial class JsonCardsView : UserControl +{ + private CollectionTabViewModel? _viewModel; + + /// 构造。 + public JsonCardsView() + { + InitializeComponent(); + CardList.AddHandler(KeyDownEvent, OnKeyDown, RoutingStrategies.Bubble); + CardList.AddHandler(PointerPressedEvent, OnCardPressed, RoutingStrategies.Tunnel, handledEventsToo: true); + OutlineList.AddHandler(PointerPressedEvent, OnOutlinePressed, RoutingStrategies.Tunnel); + } + + /// 卡片列表(测试用)。 + internal ListBox List => CardList; + + /// 接上视图模型。 + internal void Attach(CollectionTabViewModel viewModel) => _viewModel = viewModel; + + /// + /// 编辑器里的键:冒泡阶段接 —— 补全弹层开着时 Esc / Enter 先被编辑器自己的隧道处理器拿走(关弹层 / 接受补全), + /// 这里只接剩下的。 + /// + private void OnKeyDown(object? sender, KeyEventArgs e) + { + if (GridPaneView.Find(e.Source) is not { IsEditing: true } card || EditorOf(e.Source) is not { } editor) + { + return; + } + bool control = e.KeyModifiers.HasFlag(KeyModifiers.Control); + bool alt = e.KeyModifiers.HasFlag(KeyModifiers.Alt); + switch (e.Key) + { + case Key.S when control: + e.Handled = true; + if (_viewModel?.FormatOnSave == true) + { + card.Format(); + } + card.Update(); + break; + case Key.Escape: + e.Handled = true; + card.CancelEdit(); + break; + case Key.Up or Key.Down when alt: + e.Handled = true; + MoveLine(editor, e.Key == Key.Up ? -1 : 1); + break; + } + } + + private void OnCardPressed(object? sender, PointerPressedEventArgs e) + { + if (_viewModel is not null && GridPaneView.Find(e.Source) is { } card && !ReferenceEquals(_viewModel.FocusedCard, card) + && _viewModel.Cards.All(static c => !c.IsEditing)) + { + _viewModel.FocusedCard = card; + } + } + + /// 点大纲一项:编辑中的卡片把光标移到那一行;只读卡片滚到可见。 + private void OnOutlinePressed(object? sender, PointerPressedEventArgs e) + { + if (_viewModel?.FocusedCard is not { } card || GridPaneView.Find(e.Source) is not { } item) + { + return; + } + if (card.IsEditing && CardList.ContainerFromItem(card) is Control container + && container.GetVisualDescendants().OfType().FirstOrDefault(static c => c.Classes.Contains("edit")) is { } editor) + { + DocumentLine line = editor.Editor.Document.GetLineByNumber(Math.Clamp(item.Line, 1, editor.Editor.Document.LineCount)); + editor.Editor.CaretOffset = line.Offset + (editor.Editor.Document.GetText(line).Length - editor.Editor.Document.GetText(line).TrimStart().Length); + editor.ScrollToLine(item.Line); + editor.FocusEditor(); + } + else + { + CardList.ScrollIntoView(card); + } + e.Handled = true; + } + + private static CodeEditor? EditorOf(object? source) + { + for (var visual = source as Avalonia.Visual; visual is not null; visual = visual.GetVisualParent()) + { + if (visual is CodeEditor editor) + { + return editor.Classes.Contains("edit") ? editor : null; + } + } + return null; + } + + /// + /// Alt+↑ / ↓:把光标所在行与上 / 下一行对调(移动一个字段)。对调后补上缺的逗号 —— + /// 行尾多出来的逗号无所谓(解析器容忍行尾逗号),少了才是语法错误。 + /// + private static void MoveLine(CodeEditor editor, int direction) + { + TextDocument document = editor.Editor.Document; + DocumentLine current = document.GetLineByOffset(editor.Editor.CaretOffset); + int targetNumber = current.LineNumber + direction; + if (targetNumber < 1 || targetNumber > document.LineCount) + { + return; + } + DocumentLine target = document.GetLineByNumber(targetNumber); + string currentText = document.GetText(current); + string targetText = document.GetText(target); + if (IsBracketLine(currentText) || IsBracketLine(targetText)) + { + return; + } + int column = editor.Editor.CaretOffset - current.Offset; + (DocumentLine first, DocumentLine second) = direction < 0 ? (target, current) : (current, target); + string firstText = document.GetText(first); + string secondText = document.GetText(second); + string swapped = EnsureComma(secondText) + "\n" + EnsureComma(firstText); + document.Replace(first.Offset, second.EndOffset - first.Offset, swapped); + DocumentLine landed = document.GetLineByNumber(targetNumber); + editor.Editor.CaretOffset = Math.Min(landed.Offset + column, landed.EndOffset); + } + + private static bool IsBracketLine(string text) => text.Trim() is "{" or "}" or "[" or "]" or "}," or "],"; + + private static string EnsureComma(string text) + { + string trimmed = text.TrimEnd(); + return trimmed.Length == 0 || trimmed.EndsWith(',') || trimmed.EndsWith('{') || trimmed.EndsWith('[') ? text : trimmed + ","; + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/ColumnFit.cs b/plugins/VelaShell.Plugin.Mongo/Ui/ColumnFit.cs new file mode 100644 index 0000000..6672d75 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/ColumnFit.cs @@ -0,0 +1,57 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.VisualTree; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 列宽"双击分隔线按内容自适应"的公共零件:量控件不受限时的自然宽度、把列头拖把上的双击接出来。 +/// +internal static class ColumnFit +{ + /// 控件不受宽度限制时想要多宽;量完让它下一轮按真实约束重排。 + public static double Natural(Control control) + { + control.Measure(Size.Infinity); + double width = control.DesiredSize.Width; + control.InvalidateMeasure(); + return width; + } + + /// + /// 一组格子(,内容是它的 )里最宽的那格要多宽: + /// 内容自然宽度 + 内边距 + 边框。只量已经实现的格子 —— 虚拟化列表里看不见的行本来就没有控件。 + /// + public static double Widest(IEnumerable cells) + { + double width = 0; + foreach (Border cell in cells) + { + if (cell.Child is { } child) + { + double content = child is Panel panel + ? panel.Children.Where(static c => c.IsVisible && c is not Thumb).Select(Natural).DefaultIfEmpty(0).Max() + : Natural(child); + width = Math.Max(width, content + cell.Padding.Left + cell.Padding.Right + cell.BorderThickness.Left + cell.BorderThickness.Right); + } + } + return width; + } + + /// + /// 在 里的任何一个列头拖把()上双击时回调,参数是拖把的数据上下文(那一列)。 + /// 自己会吃掉按下事件,所以挂在隧道阶段先看点击次数。 + /// + public static void OnGripDoubleClick(Control host, Action fit) => + host.AddHandler(InputElement.PointerPressedEvent, (_, e) => + { + if (e.ClickCount == 2 && e.Source is Visual source && source.FindAncestorOfType(includeSelf: true) is { } grip) + { + e.Handled = true; + fit(grip.DataContext); + } + }, RoutingStrategies.Tunnel, handledEventsToo: true); +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Converters.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Converters.cs new file mode 100644 index 0000000..7825deb --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Converters.cs @@ -0,0 +1,82 @@ +using System.Globalization; +using Avalonia; +using Avalonia.Data.Converters; +using Avalonia.Media; +using VelaShell.Plugin.Mongo.Bson; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 视图里用到的转换器(一律 {x:Static ui:Converters.Xxx} 引用)。 +public static class Converters +{ + /// 令牌名 → 画刷(跟随换肤,见 )。 + public static IValueConverter Token { get; } = + new FuncValueConverter(static token => ThemeBrushes.Get(token ?? "VelaTextSecondary", Brushes.Gray)); + + /// BSON 类型 → 画刷。 + public static IValueConverter KindBrush { get; } = + new FuncValueConverter(static kind => ThemeBrushes.Get(BsonKinds.ColorToken(kind), Brushes.Gray)); + + /// BSON 类型 → 显示名。 + public static IValueConverter KindName { get; } = + new FuncValueConverter(static kind => BsonKinds.Name(kind)); + + /// 值等于参数(字符串比较)→ true。Classes.active 这类绑定用。 + public static IValueConverter IsEqual { get; } = new EqualsConverter(false); + + /// 值不等于参数 → true。 + public static IValueConverter IsNotEqual { get; } = new EqualsConverter(true); + + /// 非空(对象非 null、字符串非空)→ true。 + public static IValueConverter NotEmpty { get; } = + new FuncValueConverter(static v => v is string s ? s.Length > 0 : v is not null); + + /// 为空 → true。 + public static IValueConverter IsEmpty { get; } = + new FuncValueConverter(static v => v is string s ? s.Length == 0 : v is null); + + /// 布尔取反。 + public static IValueConverter Not { get; } = new FuncValueConverter(static v => !v); + + /// 数字 > 0 → true。 + public static IValueConverter Positive { get; } = + new FuncValueConverter(static v => v switch + { + int i => i > 0, + long l => l > 0, + double d => d > 0, + _ => false + }); + + /// 左缩进(double)→ Thickness(左, 0, 10, 0)(树行的内边距)。 + public static IValueConverter LeftPadding { get; } = + new FuncValueConverter(static left => new(left, 0, 10, 0)); + + /// 字节数 → 42.1 GB。 + public static IValueConverter Bytes { get; } = + new FuncValueConverter(static b => BsonText.Bytes(b)); + + /// 千分位整数。 + public static IValueConverter Grouped { get; } = + new FuncValueConverter(static n => BsonText.Grouped(n)); + + /// 0–1 的比例 → 百分比文字(62%)。 + public static IValueConverter Percent { get; } = + new FuncValueConverter(static p => (p * 100).ToString("0", CultureInfo.InvariantCulture) + "%"); + + /// 布尔 → 不透明度(true = 1,false = 0.4;禁用而不是隐藏的那种弱化)。 + public static IValueConverter Dim { get; } = + new FuncValueConverter(static on => on ? 1d : 0.4d); + + private sealed class EqualsConverter(bool negate) : IValueConverter + { + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + bool equal = string.Equals(value?.ToString(), parameter?.ToString(), StringComparison.Ordinal); + return negate ? !equal : equal; + } + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => + value is true ? parameter ?? "" : Avalonia.Data.BindingOperations.DoNothing; + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignBar.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignBar.cs new file mode 100644 index 0000000..825aeb6 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignBar.cs @@ -0,0 +1,104 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 一根分段比例条:轨道 + 按比例首尾相接的若干段(设计稿 08 的"类型与出现率"、取值比例条, +/// 16 的通过 / 不通过,统计页的索引大小)。 +/// +/// 自绘而不是在 XAML 里拼 Rectangle:段宽取决于控件自己的实际宽度,而这几处的宽度都随列宽伸缩; +/// 在视图模型里预先算像素宽就得知道布局结果,反过来了。颜色走 , +/// 那里给的是长期有效、换肤时就地改色的画刷。 +/// +/// +public sealed class DesignBar : Control +{ + /// 各段(比例之和不必为 1;剩下的露出轨道)。 + public static readonly StyledProperty?> SegmentsProperty = + AvaloniaProperty.Register?>(nameof(Segments)); + + /// 轨道颜色令牌;空 = 不画轨道。 + public static readonly StyledProperty TrackTokenProperty = + AvaloniaProperty.Register(nameof(TrackToken), "VelaBgActive"); + + /// 段与段之间的空隙(像素)。 + public static readonly StyledProperty GapProperty = + AvaloniaProperty.Register(nameof(Gap)); + + /// 圆角。 + public static readonly StyledProperty RadiusProperty = + AvaloniaProperty.Register(nameof(Radius), 2); + + static DesignBar() + { + AffectsRender(SegmentsProperty, TrackTokenProperty, GapProperty, RadiusProperty); + } + + /// 各段。 + public IEnumerable? Segments + { + get => GetValue(SegmentsProperty); + set => SetValue(SegmentsProperty, value); + } + + /// 轨道颜色令牌。 + public string? TrackToken + { + get => GetValue(TrackTokenProperty); + set => SetValue(TrackTokenProperty, value); + } + + /// 段间空隙。 + public double Gap + { + get => GetValue(GapProperty); + set => SetValue(GapProperty, value); + } + + /// 圆角。 + public double Radius + { + get => GetValue(RadiusProperty); + set => SetValue(RadiusProperty, value); + } + + /// + public override void Render(DrawingContext context) + { + Rect bounds = new(Bounds.Size); + if (bounds.Width <= 0 || bounds.Height <= 0) + { + return; + } + double radius = Math.Min(Radius, bounds.Height / 2); + if (TrackToken is { Length: > 0 } track) + { + context.DrawRectangle(ThemeBrushes.Get(track, Brushes.DimGray), null, bounds, radius, radius); + } + var pieces = (Segments ?? []).OfType().Where(static p => p.Ratio > 0).ToList(); + if (pieces.Count == 0) + { + return; + } + double gaps = Gap * (pieces.Count - 1); + double usable = Math.Max(0, bounds.Width - gaps); + double x = 0; + // 分段条整体是一个圆角形状:先按圆角裁一次,段与段之间就是直边 —— 与设计稿的类型条一致。 + using (context.PushClip(new RoundedRect(bounds, radius))) + { + foreach (BarPiece piece in pieces) + { + double width = Math.Max(1, usable * Math.Clamp(piece.Ratio, 0, 1)); + width = Math.Min(width, bounds.Width - x); + if (width <= 0) + { + break; + } + context.FillRectangle(ThemeBrushes.Get(piece.Token, Brushes.SteelBlue), new Rect(x, 0, width, bounds.Height)); + x += width + Gap; + } + } + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignIndexesView.axaml b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignIndexesView.axaml new file mode 100644 index 0000000..7ac8bf7 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignIndexesView.axaml @@ -0,0 +1,349 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignIndexesView.axaml.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignIndexesView.axaml.cs new file mode 100644 index 0000000..76a128d --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignIndexesView.axaml.cs @@ -0,0 +1,172 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Shapes; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.VisualTree; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 集合设计 · 索引页。代码里只做两件 AXAML 不好做的事: +/// 字段 / 方向下拉的菜单(弹出层在独立可视树里,菜单项要直达行模型,现建最省事), +/// 以及字段行按住左侧把手拖动排序。 +/// +public sealed partial class DesignIndexesView : UserControl +{ + /// 字段行的行距(26 高 + 6 间距)。 + private const double RowPitch = 32; + + private NewKeyRow? _dragging; + + /// 构造。 + public DesignIndexesView() + { + InitializeComponent(); + AddHandler(Button.ClickEvent, OnClick); + AddHandler(PointerPressedEvent, OnPointerPressed, RoutingStrategies.Tunnel); + AddHandler(PointerMovedEvent, OnPointerMoved, RoutingStrategies.Bubble, handledEventsToo: true); + AddHandler(PointerReleasedEvent, OnPointerReleased, RoutingStrategies.Bubble, handledEventsToo: true); + } + + private DesignTabViewModel? ViewModel => DataContext as DesignTabViewModel; + + private void OnClick(object? sender, RoutedEventArgs e) + { + if (e.Source is not Button button || ViewModel is not { } vm || button.DataContext is not NewKeyRow row) + { + return; + } + if (button.Name == "FieldPick") + { + ShowFieldMenu(button, vm, row); + e.Handled = true; + } + else if (button.Name == "DirectionPick") + { + ShowDirectionMenu(button, vm, row); + e.Handled = true; + } + } + + /// 字段下拉:抽样到的字段,带类型色块与类型名。 + private static void ShowFieldMenu(Control anchor, DesignTabViewModel vm, NewKeyRow row) + { + var items = new List(); + foreach (FieldOption option in vm.FieldOptions) + { + var header = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 }; + header.Children.Add(new Rectangle + { + Width = 7, + Height = 7, + RadiusX = 2, + RadiusY = 2, + VerticalAlignment = VerticalAlignment.Center, + Fill = ThemeBrushes.Get(option.Token, Brushes.Gray) + }); + header.Children.Add(Label(anchor, option.Path, "VelaUiMonoFont", "VelaTextPrimary", "VelaFontSize11")); + header.Children.Add(Label(anchor, option.KindName, "VelaUiFont", "VelaTextMuted", "VelaFontSize10")); + var item = new MenuItem { Header = header }; + item.Click += (_, _) => row.Field = option.Path; + items.Add(item); + } + if (items.Count == 0) + { + items.Add(new MenuItem + { + Header = vm.IsAnalyzing ? vm.Loc["Design_Analyzing"] : vm.Loc["Design_NoSampleFields"], + IsEnabled = false + }); + } + Open(anchor, items); + } + + /// 方向下拉:1 升序 / -1 降序。 + private static void ShowDirectionMenu(Control anchor, DesignTabViewModel vm, NewKeyRow row) + { + var items = new List(); + foreach (int direction in new[] { 1, -1 }) + { + var item = new MenuItem + { + Header = vm.DirectionLabel(direction), + Icon = new Glyph + { + Key = direction > 0 ? "Mongo.arrow-up" : "Mongo.arrow-down", + Size = 12, + Brush = ThemeBrushes.Get(direction > 0 ? "VelaStatusConnected" : "VelaWarning", Brushes.Gray) + } + }; + item.Click += (_, _) => row.Direction = direction; + items.Add(item); + } + Open(anchor, items); + } + + /// + /// 菜单里的一段文字。弹出层不在本视图的可视树里,本视图引入的文字类(mono 之类)够不着它, + /// 字体、字号、颜色只能直接按宿主令牌设。 + /// + private static TextBlock Label(Control anchor, string text, string font, string token, string size) => new() + { + Text = text, + VerticalAlignment = VerticalAlignment.Center, + FontFamily = anchor.TryFindResource(font, out object? f) && f is FontFamily family ? family : FontFamily.Default, + FontSize = anchor.TryFindResource(size, out object? s) && s is double d ? d : 11, + Foreground = ThemeBrushes.Get(token, Brushes.Gray) + }; + + private static void Open(Control anchor, List items) + { + var menu = new ContextMenu + { + ItemsSource = items, + Placement = PlacementMode.BottomEdgeAlignedLeft, + PlacementTarget = anchor + }; + menu.Open(anchor); + } + + private void OnPointerPressed(object? sender, PointerPressedEventArgs e) + { + if (e.Source is not Visual source || ViewModel is null || !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + { + return; + } + Border? grip = source.GetSelfAndVisualAncestors().OfType().FirstOrDefault(static b => b.Name == "Grip"); + if (grip?.DataContext is NewKeyRow row) + { + _dragging = row; + e.Pointer.Capture(grip); + e.Handled = true; + } + } + + private void OnPointerMoved(object? sender, PointerEventArgs e) + { + if (_dragging is not { } row || ViewModel is not { } vm) + { + return; + } + double y = e.GetPosition(KeyRows).Y; + int target = Math.Clamp((int)Math.Floor(y / RowPitch), 0, vm.NewKeys.Count - 1); + int current = vm.NewKeys.IndexOf(row); + if (current >= 0 && target != current) + { + vm.MoveKey(current, target); + } + } + + private void OnPointerReleased(object? sender, PointerReleasedEventArgs e) + { + if (_dragging is null) + { + return; + } + _dragging = null; + e.Pointer.Capture(null); + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignModels.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignModels.cs new file mode 100644 index 0000000..13c346d --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignModels.cs @@ -0,0 +1,526 @@ +using MongoDB.Bson; +using VelaShell.Plugin.Mongo.Analysis; +using VelaShell.Plugin.Mongo.Bson; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 比例条里的一段(类型分布、通过 / 不通过、使用率)。 +/// 占比(0–1)。 +/// 颜色令牌。 +internal sealed record BarPiece(double Ratio, string Token); + +/// 图例一项(色块 + 文字)。 +/// 文字(String 97%)。 +/// 色块令牌。 +internal sealed record LegendItem(string Text, string Token); + +/// 索引键里的一个字段芯片(status ↑、note text)。 +internal sealed class IndexKeyChip +{ + /// 构造。 + /// 字段。 + /// 键值(1 / -1 / "text" / "2dsphere" / "hashed")。 + public IndexKeyChip(string field, BsonValue value) + { + Field = field; + if (value.IsNumeric) + { + bool down = value.ToDouble() < 0; + ArrowKey = down ? "Mongo.arrow-down" : "Mongo.arrow-up"; + ArrowToken = down ? "VelaWarning" : "VelaStatusConnected"; + } + else + { + Special = value.ToString(); + } + } + + /// 字段名。 + public string Field { get; } + + /// 方向箭头图标;特殊索引为 。 + public string? ArrowKey { get; } + + /// 箭头颜色(升序绿、降序橙)。 + public string ArrowToken { get; } = "VelaTextTertiary"; + + /// 特殊类型(text / 2dsphere / hashed)。 + public string? Special { get; } + + /// 画箭头。 + public bool HasArrow => ArrowKey is not null; + + /// 画类型字样。 + public bool HasSpecial => Special is not null; +} + +/// 属性列里的一个小标签(唯一 / 稀疏 / 部分 / TTL / 隐藏 / 未使用 N 天)。 +/// 文字。 +/// 语气:accent / muted / info / warn。 +internal sealed record IndexAttrChip(string Text, string Tone) +{ + /// 灰底。 + public bool IsMuted => Tone == "muted"; + + /// 信息色底。 + public bool IsInfo => Tone == "info"; + + /// 警告色底。 + public bool IsWarn => Tone == "warn"; +} + +/// 索引表的一行(设计稿 07)。 +internal sealed class IndexRow : ObservableObject +{ + /// 使用列比例条的满宽。 + public const double BarWidth = 60; + + private bool _isBuilding; + private double _buildPercent; + private string _buildText = ""; + + /// 索引名。 + public required string Name { get; init; } + + /// listIndexes 的规格原文。 + public required BsonDocument Spec { get; init; } + + /// 键模式。 + public BsonDocument Key => Spec.GetValue("key", new BsonDocument()) as BsonDocument ?? []; + + /// 键芯片。 + public IReadOnlyList Keys { get; init; } = []; + + /// 形态。 + public IndexForm Form { get; init; } + + /// 类型列文字。 + public string TypeText { get; init; } = ""; + + /// 属性标签。 + public IReadOnlyList Attributes { get; init; } = []; + + /// 没有任何属性(属性列画一个灰色破折号)。 + public bool NoAttributes => Attributes.Count == 0; + + /// 大小(字节);未知为 0。 + public long Size { get; init; } + + /// 大小文字。 + public string SizeText { get; init; } = "—"; + + /// $indexStats 的 ops;拿不到为 。 + public long? Ops { get; init; } + + /// ops 文字。 + public string OpsText => Ops is { } ops ? BsonText.Grouped(ops) : "—"; + + /// 使用率(相对本集合使用最多的那个索引)。 + public double UsageRatio { get; init; } + + /// 使用条的像素宽。 + public double UsageWidth => Math.Clamp(UsageRatio, 0, 1) * BarWidth; + + /// 统计起点。 + public DateTime? Since { get; init; } + + /// 多键。 + public bool MultiKey { get; init; } + + /// 被标成"未使用"(名称与 ops 变橙)。 + public bool IsUnused { get; init; } + + /// 隐藏索引。 + public bool IsHidden => Spec.GetValue("hidden", false).ToBoolean(); + + /// _id_(不能隐藏、不能删除)。 + public bool IsId => Name == "_id_"; + + /// 正在构建。 + public bool IsBuilding + { + get => _isBuilding; + set + { + if (SetProperty(ref _isBuilding, value)) + { + RaisePropertiesChanged(nameof(IconKey), nameof(IconToken), nameof(IsReady)); + } + } + } + + /// 已建好(使用列画 ops 而不是进度)。 + public bool IsReady => !_isBuilding; + + /// 构建进度(0–100)。 + public double BuildPercent + { + get => _buildPercent; + set + { + if (SetProperty(ref _buildPercent, value)) + { + RaisePropertyChanged(nameof(BuildWidth)); + } + } + } + + /// 进度条像素宽。 + public double BuildWidth => Math.Clamp(_buildPercent, 0, 100) / 100 * BarWidth; + + /// 进度文字(构建中 64% · 约 2 分)。 + public string BuildText + { + get => _buildText; + set => SetProperty(ref _buildText, value); + } + + /// 名称列图标。 + public string IconKey => _isBuilding ? "Mongo.loader-circle" : IsHidden ? "Mongo.eye-off" : "Mongo.key-round"; + + /// 名称列图标颜色。 + public string IconToken => _isBuilding ? "VelaAccent" : IsUnused ? "VelaWarning" : "VelaTextTertiary"; + + /// 名称颜色。 + public string NameToken => IsUnused ? "VelaWarning" : IsHidden ? "VelaTextTertiary" : "VelaTextPrimary"; + + /// ops 颜色。 + public string OpsToken => IsUnused ? "VelaWarning" : "VelaTextSecondary"; +} + +/// 「索引建议」的一张卡片(设计稿 07 下半部分)。 +internal sealed class AdviceCard +{ + /// 依据的建议;纯提示卡(profiler 未开)为 。 + public AdvisorSuggestion? Suggestion { get; init; } + + /// 左侧大图标。 + public string IconKey { get; init; } = "Mongo.zap"; + + /// 图标颜色。 + public string IconToken { get; init; } = "VelaWarning"; + + /// 标题。 + public required string Title { get; init; } + + /// 说明。 + public string Detail { get; init; } = ""; + + /// 说明下面的等宽代码块(建议键);没有为 。 + public string? Code { get; init; } + + /// 有代码块。 + public bool HasCode => Code is not null; + + /// 按钮文字。 + public required string ActionLabel { get; init; } + + /// 主操作(半透明药丸)还是次操作(描边)。 + public bool IsPrimary { get; init; } + + /// 次操作。 + public bool IsSecondary => !IsPrimary; + + /// 按钮要做的事。 + public required Func Action { get; init; } +} + +/// 新建索引面板里的一行字段。 +internal sealed class NewKeyRow : ObservableObject +{ + private readonly Action _changed; + private readonly Func _tokenOf; + private readonly Func _label; + private string _field; + private int _direction; + private string _role; + private string _kind = "normal"; + private bool _isFirst; + + /// 构造。 + /// 字段。 + /// 方向(1 / -1)。 + /// ESR 角色文字(等值 / 排序 / 范围);未知为空。 + /// 字段 → 类型色令牌(来自抽样)。 + /// 任何改动后通知面板重算命令预览。 + /// 键值 → 方向下拉上的字(1 升序)。 + public NewKeyRow(string field, int direction, string role, Func tokenOf, Action changed, Func label) + { + _label = label; + _field = field; + _direction = direction; + _role = role; + _tokenOf = tokenOf; + _changed = changed; + } + + /// 字段路径。 + public string Field + { + get => _field; + set + { + if (SetProperty(ref _field, value ?? "")) + { + RaisePropertyChanged(nameof(SwatchToken)); + _changed(); + } + } + } + + /// 方向(1 / -1)。 + public int Direction + { + get => _direction; + set + { + if (SetProperty(ref _direction, value)) + { + RaisePropertiesChanged(nameof(DirectionValue), nameof(DirectionText)); + _changed(); + } + } + } + + /// ESR 角色文字。 + public string Role + { + get => _role; + set + { + if (SetProperty(ref _role, value)) + { + RaisePropertyChanged(nameof(HasRole)); + } + } + } + + /// 有角色小标签。 + public bool HasRole => _role.Length > 0; + + /// 字段类型色。 + public string SwatchToken => _tokenOf(_field); + + /// 抽样结果到了:类型色要重算。 + internal void RefreshSwatch() => RaisePropertyChanged(nameof(SwatchToken)); + + /// 当前索引类型(面板的分段:normal / text / 2dsphere / hashed / wildcard)。 + public string Kind + { + get => _kind; + set + { + if (SetProperty(ref _kind, value)) + { + RaisePropertiesChanged(nameof(DirectionValue), nameof(HasDirection), nameof(DirectionText)); + } + } + } + + /// 是第一行(哈希索引只把第一行设成 hashed)。 + public bool IsFirst + { + get => _isFirst; + set + { + if (SetProperty(ref _isFirst, value)) + { + RaisePropertiesChanged(nameof(DirectionValue), nameof(HasDirection), nameof(DirectionText)); + } + } + } + + /// 这一行的键值能不能选方向(普通索引、哈希索引的非首行)。 + public bool HasDirection => _kind == "normal" || (_kind == "hashed" && !_isFirst) || _kind == "wildcard"; + + /// 方向下拉上的字。 + public string DirectionText => _label(DirectionValue); + + /// 这一行在键模式里的值。 + public BsonValue DirectionValue => _kind switch + { + "text" => "text", + "2dsphere" => "2dsphere", + "hashed" when _isFirst => "hashed", + "wildcard" => 1, + _ => _direction + }; +} + +/// 抽样字段(新建索引的字段下拉、验证规则的补全)。 +/// 路径。 +/// 主类型。 +internal sealed record FieldOption(string Path, BsonKind Kind) +{ + /// 类型色。 + public string Token => BsonKinds.ColorToken(Kind); + + /// 类型名。 + public string KindName => BsonKinds.Name(Kind); +} + +/// 值分布里的一根横向比例条。 +/// 取值。 +/// 百分比文字。 +/// 占比。 +/// 颜色。 +internal sealed record ValueBar(string Label, string Percent, double Ratio, string Token) +{ + /// 条本身(给 )。 + public IReadOnlyList Pieces => [new(Ratio, Token)]; +} + +/// 数组长度分布的一根柱。 +/// 长度(1 … 5+)。 +/// 柱高(像素,满 36)。 +/// 次数。 +internal sealed record LengthBar(string Label, double Height, int Count); + +/// Schema 分析表的一行(设计稿 08)。 +internal sealed class SchemaFieldRow +{ + /// 路径。 + public required string Path { get; init; } + + /// 灰字提示。 + public string Hint { get; init; } = ""; + + /// 提示是警告(混合类型)。 + public bool HintWarn { get; init; } + + /// 类型与出现率的分段。 + public IReadOnlyList Pieces { get; init; } = []; + + /// 图例。 + public IReadOnlyList Legend { get; init; } = []; + + /// 画法。 + public AnalyzedViz Viz { get; init; } + + /// 文字分布。 + public string VizText { get; init; } = ""; + + /// 横向比例条。 + public IReadOnlyList Bars { get; init; } = []; + + /// 直方图数据。 + public IReadOnlyList? Histogram { get; init; } + + /// 直方图悬停标签。 + public IReadOnlyList? HistogramLabels { get; init; } + + /// 直方图左端标签。 + public string AxisMin { get; init; } = ""; + + /// 直方图右端标签。 + public string AxisMax { get; init; } = ""; + + /// 数组长度柱。 + public IReadOnlyList Lengths { get; init; } = []; + + /// 文字分布。 + public bool IsText => Viz == AnalyzedViz.Text; + + /// 比例条分布。 + public bool IsBars => Viz == AnalyzedViz.Bars; + + /// 直方图(数值或日期)。 + public bool IsHistogram => Viz is AnalyzedViz.Histogram or AnalyzedViz.Timeline; + + /// 数组长度。 + public bool IsLengths => Viz == AnalyzedViz.Lengths; +} + +/// 预检里的一类不通过原因(设计稿 16 右栏)。 +internal sealed class FailureReasonRow +{ + /// 字段路径。 + public required string Path { get; init; } + + /// 关键字摘要(required、minimum: 0)。 + public required string Keyword { get; init; } + + /// 关键字本身(minimum;悬停说明按它找这一组)。 + public string RawKeyword { get; init; } = ""; + + /// 份数。 + public int Count { get; init; } + + /// 份数文字(29 份)。 + public required string CountText { get; init; } + + /// 示例(例:_id 66f5c2a1…91c0)。 + public string Example { get; init; } = ""; + + /// 在网格中查看时用的筛选。 + public required string Filter { get; init; } +} + +/// 选项页里的一条 TTL 索引。 +internal sealed class TtlRow : ObservableObject +{ + private readonly Func _describe; + private string _seconds; + + /// 构造。 + /// 索引名。 + /// 日期字段。 + /// 当前生效的秒数。 + /// 秒数 → 人话。 + public TtlRow(string index, string field, long seconds, Func describe) + { + _describe = describe; + Index = index; + Field = field; + Original = seconds; + _seconds = seconds.ToString(System.Globalization.CultureInfo.InvariantCulture); + } + + /// 索引名。 + public string Index { get; } + + /// 日期字段。 + public string Field { get; } + + /// 当前生效的秒数。 + public long Original { get; } + + /// 编辑中的秒数。 + public string Seconds + { + get => _seconds; + set + { + if (SetProperty(ref _seconds, value)) + { + RaisePropertiesChanged(nameof(IsDirty), nameof(Human)); + } + } + } + + /// 改过了。 + public bool IsDirty => long.TryParse(_seconds, out long s) && s != Original; + + /// 换算成人话(= 1 天)。 + public string Human => long.TryParse(_seconds, out long s) ? _describe(s) : ""; +} + +/// 统计页的一张指标卡。 +/// 名称。 +/// 值。 +/// 小字说明。 +/// 值的颜色。 +internal sealed record StatCard(string Label, string Value, string Sub, string Token = "VelaTextPrimary"); + +/// 统计页的一根索引大小条。 +/// 索引名。 +/// 大小。 +/// 占索引总量的比例。 +internal sealed record IndexSizeBar(string Name, string SizeText, double Ratio) +{ + /// 条本身。 + public IReadOnlyList Pieces => [new(Ratio, "VelaInfo")]; + + /// 百分比文字。 + public string Percent => $"{Ratio * 100:0.#}%"; +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignOptionsView.axaml b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignOptionsView.axaml new file mode 100644 index 0000000..5df77ca --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignOptionsView.axaml @@ -0,0 +1,183 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignSchemaView.axaml.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignSchemaView.axaml.cs new file mode 100644 index 0000000..c92ceab --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignSchemaView.axaml.cs @@ -0,0 +1,38 @@ +using System.Globalization; +using Avalonia.Controls; +using Avalonia.Interactivity; +using VelaShell.Plugin.Mongo.Bson; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 集合设计 · Schema 分析页。代码里只建 $sample 数量的下拉菜单。 +public sealed partial class DesignSchemaView : UserControl +{ + /// 构造。 + public DesignSchemaView() + { + InitializeComponent(); + SamplePick.Click += OnSamplePick; + } + + private void OnSamplePick(object? sender, RoutedEventArgs e) + { + if (DataContext is not DesignTabViewModel vm) + { + return; + } + var items = new List(); + foreach (int size in DesignTabViewModel.SampleSizes) + { + var item = new MenuItem + { + Header = BsonText.Grouped(size), + Command = vm.SetSampleCommand, + CommandParameter = size.ToString(CultureInfo.InvariantCulture), + Icon = size == vm.SampleSize ? new Glyph { Key = "Mongo.check", Size = 12, Brush = ThemeBrushes.Get("VelaAccent", Avalonia.Media.Brushes.Gray) } : null + }; + items.Add(item); + } + new ContextMenu { ItemsSource = items, Placement = PlacementMode.BottomEdgeAlignedLeft, PlacementTarget = SamplePick }.Open(SamplePick); + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignStatsView.axaml b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignStatsView.axaml new file mode 100644 index 0000000..1e4b0a4 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignStatsView.axaml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignStatsView.axaml.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignStatsView.axaml.cs new file mode 100644 index 0000000..bbcc878 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignStatsView.axaml.cs @@ -0,0 +1,13 @@ +using Avalonia.Controls; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 集合设计 · 统计页(纯绑定,没有代码)。 +public sealed partial class DesignStatsView : UserControl +{ + /// 构造。 + public DesignStatsView() + { + InitializeComponent(); + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignStyles.axaml b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignStyles.axaml new file mode 100644 index 0000000..156ca0a --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignStyles.axaml @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignTabView.axaml b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignTabView.axaml new file mode 100644 index 0000000..7cf97db --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignTabView.axaml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignTabView.axaml.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignTabView.axaml.cs new file mode 100644 index 0000000..418cba4 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignTabView.axaml.cs @@ -0,0 +1,20 @@ +using Avalonia.Controls; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 视图(占位)。 +public sealed partial class DesignTabView : UserControl +{ + /// 用给定的视图模型初始化。 + internal DesignTabView(DesignTabViewModel viewModel) + { + InitializeComponent(); + DataContext = viewModel; + } + + /// 设计器 / headless 用的无参构造。 + public DesignTabView() + { + InitializeComponent(); + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignTabViewModel.Indexes.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignTabViewModel.Indexes.cs new file mode 100644 index 0000000..791cd61 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignTabViewModel.Indexes.cs @@ -0,0 +1,775 @@ +using System.Collections.ObjectModel; +using System.Globalization; +using MongoDB.Bson; +using MongoDB.Driver; +using VelaShell.Plugin.Mongo.Analysis; +using VelaShell.Plugin.Mongo.Bson; +using VelaShell.Plugin.Mongo.Core; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 集合设计 · 索引与建议(设计稿 07)。 +internal sealed partial class DesignTabViewModel +{ + private IndexRow? _selectedIndex; + private long _totalIndexSize; + private int _readyIndexCount; + private string _indexStatsSince = ""; + private Task? _indexesTask; + private bool _hasAdvice; + private string _adviceEmpty = ""; + private CancellationTokenSource? _buildPoll; + private CollectionStats? _indexStats; + + /// 索引表。 + public ObservableCollection Indexes { get; } = []; + + /// 索引建议卡片。 + public ObservableCollection Advice { get; } = []; + + /// 选中的索引(隐藏 / 删除作用的对象)。 + public IndexRow? SelectedIndex + { + get => _selectedIndex; + set + { + if (SetProperty(ref _selectedIndex, value)) + { + RaisePropertiesChanged(nameof(CanHideSelected), nameof(CanDropSelected), nameof(HideLabel), nameof(HideIcon)); + ToggleHiddenCommand.RaiseCanExecuteChanged(); + DropIndexCommand.RaiseCanExecuteChanged(); + } + } + } + + /// 已建好的索引个数(子页条上的计数)。 + public int ReadyIndexCount + { + get => _readyIndexCount; + private set => SetProperty(ref _readyIndexCount, value); + } + + /// 工具行右侧:$indexStats · 自 2026-09-12 起统计。 + public string IndexStatsSince + { + get => _indexStatsSince; + private set => SetProperty(ref _indexStatsSince, value); + } + + /// 有建议卡片。 + public bool HasAdvice + { + get => _hasAdvice; + private set => SetProperty(ref _hasAdvice, value); + } + + /// 没有建议时的那行说明。 + public string AdviceEmpty + { + get => _adviceEmpty; + private set + { + if (SetProperty(ref _adviceEmpty, value)) + { + RaisePropertyChanged(nameof(HasAdviceEmpty)); + } + } + } + + /// 显示空态说明。 + public bool HasAdviceEmpty => _adviceEmpty.Length > 0; + + /// 选中的索引能隐藏 / 取消隐藏(_id_ 与构建中的不行)。 + public bool CanHideSelected => _selectedIndex is { IsId: false, IsBuilding: false }; + + /// 选中的索引能删(_id_ 不行)。 + public bool CanDropSelected => _selectedIndex is { IsId: false }; + + /// 隐藏按钮的字:选中的已隐藏就是「取消隐藏」。 + public string HideLabel => _selectedIndex?.IsHidden == true ? Loc["Design_Unhide"] : Loc["Design_Hide"]; + + /// 隐藏按钮的图标。 + public string HideIcon => _selectedIndex?.IsHidden == true ? "Mongo.eye" : "Mongo.eye-off"; + + /// 隐藏 / 取消隐藏选中的索引。 + public AsyncCommand ToggleHiddenCommand { get; private set; } = null!; + + /// 删除选中的索引。 + public AsyncCommand DropIndexCommand { get; private set; } = null!; + + /// 刷新索引页。 + public AsyncCommand RefreshIndexesCommand { get; private set; } = null!; + + /// 建议卡片上的按钮。 + public AsyncCommand AdviceActionCommand { get; private set; } = null!; + + private void InitializeIndexes() + { + ToggleHiddenCommand = new(() => _selectedIndex is { } row ? SetHiddenAsync(row, !row.IsHidden) : Task.CompletedTask, () => CanHideSelected); + DropIndexCommand = new(() => _selectedIndex is { } row ? DropIndexAsync(row) : Task.CompletedTask, () => CanDropSelected); + RefreshIndexesCommand = new(LoadIndexesAsync); + AdviceActionCommand = new(static card => card.Action()); + } + + /// + /// 加载索引页:listIndexes(含构建中的)、$collStats 的 indexSizes、$indexStats、 + /// 多键探测(执行计划里的 isMultiKey)、currentOp 里的构建进度,最后是 profile 慢查询 → 建议。 + /// 每一步失败都只让那一列空着,不让整页失败 —— 权限不够看 $indexStats 的用户照样要能看索引表。 + /// + internal async Task LoadIndexesAsync() + { + // 已有一次在途:等它做完再重读一次 —— 调用方(建完索引、进选项页)要的是"此刻之后"的状态, + // 直接返回那次在途的结果可能早于它刚做的改动。 + while (_indexesTask is { IsCompleted: false } running) + { + await running.ConfigureAwait(true); + } + if (_disposed) + { + return; + } + _indexesTask = LoadIndexesCoreAsync(); + await _indexesTask.ConfigureAwait(true); + } + + /// 没加载过才加载;正在加载就等那一次(打开标签时 LoadAsync 与切页会同时要索引,不必读两遍)。 + internal async Task EnsureIndexesAsync() + { + if (_indexesTask is { IsCompleted: false } running) + { + await running.ConfigureAwait(true); + } + else if (!_indexesLoaded) + { + await LoadIndexesAsync().ConfigureAwait(true); + } + } + + private async Task LoadIndexesCoreAsync() + { + try + { + MongoConnection conn = Workspace.Connection; + IReadOnlyList<(BsonDocument Spec, bool Building)> specs = await ListIndexSpecsAsync().ConfigureAwait(true); + CollectionStats stats = await SafeAsync(() => conn.GetStatsAsync(Database, CollectionName, Lifetime), new CollectionStats()).ConfigureAwait(true); + _indexStats = stats; + Dictionary usage = await LoadIndexUsageAsync().ConfigureAwait(true); + Dictionary progress = await LoadBuildProgressAsync().ConfigureAwait(true); + var multiKey = new HashSet(StringComparer.Ordinal); + foreach ((BsonDocument spec, bool building) in specs) + { + if (!building && await IsMultiKeyAsync(spec).ConfigureAwait(true)) + { + multiKey.Add(spec.GetValue("name", "").ToString()!); + } + } + + DateTime now = Now(); + long maxOps = usage.Count == 0 ? 0 : usage.Values.Max(static u => u.Ops); + var rows = new List(); + foreach ((BsonDocument spec, bool building) in specs) + { + string name = spec.GetValue("name", "").ToString()!; + bool hasUsage = usage.TryGetValue(name, out var u); + bool unused = hasUsage && u.Ops == 0 && name != "_id_" && !building + && (now - u.Since).TotalDays >= IndexAdvisor.UnusedWindowDays; + IndexForm form = IndexAdvisor.ShapeOf(spec, multiKey.Contains(name)); + long size = stats.IndexSizes.GetValueOrDefault(name); + var row = 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, unused ? (int)(now - u.Since).TotalDays : 0), + Size = size, + SizeText = size > 0 ? BsonText.Bytes(size) : "—", + Ops = hasUsage ? u.Ops : null, + UsageRatio = hasUsage && maxOps > 0 ? (double)u.Ops / maxOps : 0, + Since = hasUsage ? u.Since : null, + MultiKey = multiKey.Contains(name), + IsUnused = unused, + IsBuilding = building || progress.ContainsKey(name) + }; + if (row.IsBuilding) + { + ApplyProgress(row, progress.GetValueOrDefault(name)); + } + rows.Add(row); + } + + string? selected = _selectedIndex?.Name; + Indexes.Clear(); + foreach (IndexRow row in rows) + { + Indexes.Add(row); + } + SelectedIndex = Indexes.FirstOrDefault(r => r.Name == selected); + ReadyIndexCount = rows.Count(static r => !r.IsBuilding); + _totalIndexSize = stats.TotalIndexSize > 0 ? stats.TotalIndexSize : rows.Sum(static r => r.Size); + IndexStatsSince = usage.Count == 0 + ? Loc["Design_IndexStatsUnavailable"] + : Loc.Format("Design_IndexStatsSince", usage.Values.Min(static v => v.Since).ToLocalTime().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); + _indexesLoaded = true; + UpdateStatus(); + RefreshTtlRows(); + if (rows.Any(static r => r.IsBuilding)) + { + StartBuildPoll(); + } + await LoadAdviceAsync(rows).ConfigureAwait(true); + } + catch (Exception ex) when (IsExpected(ex)) + { + Fail(ex); + } + } + + /// + /// 列索引。7.0 起 includeIndexBuildInfo 能把构建中的索引一起列出来(带 indexBuildInfo); + /// 老版本不认这个参数,退回普通的 listIndexes。 + /// + private async Task> ListIndexSpecsAsync() + { + if (IsView) + { + return []; + } + try + { + BsonDocument reply = await Workspace.Connection.RunCommandAsync(Database, new BsonDocument + { + { "listIndexes", CollectionName }, + { "includeIndexBuildInfo", true } + }, Lifetime).ConfigureAwait(true); + var list = new List<(BsonDocument, bool)>(); + foreach (BsonDocument item in reply["cursor"]["firstBatch"].AsBsonArray.OfType()) + { + if (item.GetValue("spec", BsonNull.Value) is BsonDocument spec) + { + list.Add((spec, item.Contains("indexBuildInfo"))); + } + else + { + list.Add((item, false)); + } + } + return list; + } + catch (MongoCommandException) + { + IReadOnlyList plain = await Workspace.Connection.ListIndexesAsync(Database, CollectionName, Lifetime).ConfigureAwait(true); + return [.. plain.Select(static s => (s, false))]; + } + } + + /// $indexStats:每个索引的 ops 与统计起点。没权限 / 老版本返回空。 + private async Task> LoadIndexUsageAsync() + { + var map = new Dictionary(StringComparer.Ordinal); + if (IsView) + { + return map; + } + try + { + using IAsyncCursor cursor = await Workspace.Connection.Collection(Database, CollectionName) + .AggregateAsync(new[] { new BsonDocument("$indexStats", new BsonDocument()) }, cancellationToken: Lifetime) + .ConfigureAwait(true); + foreach (BsonDocument stat in await cursor.ToListAsync(Lifetime).ConfigureAwait(true)) + { + if (stat.GetValue("accesses", BsonNull.Value) is BsonDocument accesses) + { + long ops = accesses.GetValue("ops", 0L).ToInt64(); + DateTime since = accesses.GetValue("since", BsonNull.Value) is BsonDateTime d ? d.ToUniversalTime() : Now(); + map[stat["name"].AsString] = (ops, since); + } + } + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Workspace.Log.Info($"$indexStats on {Namespace} failed: {ex.Message}"); + } + return map; + } + + /// + /// currentOp 里本集合正在进行的索引构建 → 索引名 → 进度(0–100)。 + /// 构建线程的那条操作带 progress.done / total;要索引名得看 command.indexes[].name。 + /// + private async Task> LoadBuildProgressAsync() + { + var map = new Dictionary(StringComparer.Ordinal); + if (IsView) + { + return map; + } + try + { + BsonDocument reply = await Workspace.Connection.RunCommandAsync("admin", new BsonDocument + { + { "currentOp", true }, + { "command.createIndexes", CollectionName } + }, Lifetime).ConfigureAwait(true); + foreach (BsonDocument op in reply.GetValue("inprog", new BsonArray()).AsBsonArray.OfType()) + { + // 同名集合可能在别的库里也在建索引:按 ns 或命令里的 $db 认库(查询条件里写不了 $ 开头的路径)。 + string ns = op.GetValue("ns", "").ToString() ?? ""; + string db = (op.GetValue("command", BsonNull.Value) as BsonDocument)?.GetValue("$db", "").ToString() ?? ""; + if (ns != Namespace && db != Database && !ns.StartsWith(Database + ".", StringComparison.Ordinal)) + { + continue; + } + double percent = 0; + if (op.GetValue("progress", BsonNull.Value) is BsonDocument p && p.GetValue("total", 0).ToDouble() > 0) + { + percent = p.GetValue("done", 0).ToDouble() * 100 / p["total"].ToDouble(); + } + if (op.GetValue("command", BsonNull.Value) is BsonDocument command + && command.GetValue("indexes", BsonNull.Value) is BsonArray indexes) + { + foreach (BsonDocument index in indexes.OfType()) + { + string name = index.GetValue("name", "").ToString()!; + map[name] = Math.Max(map.GetValueOrDefault(name), percent); + } + } + } + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Workspace.Log.Info($"currentOp for index builds failed: {ex.Message}"); + } + return map; + } + + /// + /// 多键:键模式看不出来,要看执行计划。用 hint 指定这个索引做一次 queryPlanner 级的 explain + /// (不执行查询),读 IXSCAN 阶段的 isMultiKey。文本 / 隐藏 / 通配符索引不能这样 hint,跳过。 + /// + private async Task IsMultiKeyAsync(BsonDocument spec) + { + IndexForm form = IndexAdvisor.ShapeOf(spec); + if (form is IndexForm.Text or IndexForm.Wildcard or IndexForm.Geo || spec.GetValue("hidden", false).ToBoolean()) + { + return false; + } + try + { + var find = new BsonDocument + { + { "find", CollectionName }, + { "filter", spec.GetValue("partialFilterExpression", new BsonDocument()) }, + { "hint", spec["name"] }, + { "limit", 1 } + }; + BsonDocument reply = await Workspace.Connection.RunCommandAsync(Database, new BsonDocument + { + { "explain", find }, + { "verbosity", "queryPlanner" } + }, Lifetime).ConfigureAwait(true); + return FindMultiKey(reply.GetValue("queryPlanner", new BsonDocument()).AsBsonDocument.GetValue("winningPlan", BsonNull.Value)); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + return false; + } + } + + private static bool FindMultiKey(BsonValue plan) + { + if (plan is not BsonDocument doc) + { + return false; + } + if (doc.GetValue("isMultiKey", false).ToBoolean()) + { + return true; + } + foreach (string child in new[] { "inputStage", "queryPlan" }) + { + if (FindMultiKey(doc.GetValue(child, BsonNull.Value))) + { + return true; + } + } + return doc.GetValue("inputStages", BsonNull.Value) is BsonArray stages && stages.Any(FindMultiKey); + } + + /// + /// 建议:读近 7 天的 system.profile(只取本集合),交给 。 + /// profiler 没开(或没权限读)时给一张"去开慢查询"的提示卡,而不是空着让人以为"没问题"。 + /// + private async Task LoadAdviceAsync(IReadOnlyList rows) + { + var profile = new List(); + int? level = null; + bool profileReadable = true; + if (!IsView) + { + try + { + BsonDocument status = await Workspace.Connection.RunCommandAsync(Database, new BsonDocument("profile", -1), Lifetime).ConfigureAwait(true); + level = status.GetValue("was", 0).ToInt32(); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Workspace.Log.Info($"profile status on {Database} failed: {ex.Message}"); + } + try + { + var filter = new BsonDocument + { + { "ns", Namespace }, + { "ts", new BsonDocument("$gte", Now().AddDays(-7)) } + }; + profile = await Workspace.Connection.Collection(Database, "system.profile") + .Find(filter) + .Sort(new BsonDocument("ts", -1)) + .Limit(2000) + .ToListAsync(Lifetime).ConfigureAwait(true); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + profileReadable = false; + Workspace.Log.Info($"Reading system.profile on {Database} failed: {ex.Message}"); + } + } + + var usages = rows.Where(static r => !r.IsBuilding) + .Select(static r => new AdvisorIndex(r.Name, r.Key, r.Spec, r.Ops, r.Since, r.Size)) + .ToList(); + IReadOnlyList groups = IndexAdvisor.GroupSlowQueries(profile); + IReadOnlyList suggestions = IndexAdvisor.Advise(groups, usages, Now()); + + Advice.Clear(); + foreach (AdvisorSuggestion suggestion in suggestions) + { + Advice.Add(Card(suggestion)); + } + if (!IsView && (level == 0 || !profileReadable) && profile.Count == 0) + { + Advice.Add(new AdviceCard + { + IconKey = "Mongo.info", + IconToken = "VelaInfo", + Title = Loc["Design_AdviceProfilerOffTitle"], + Detail = profileReadable ? Loc["Design_AdviceProfilerOffDetail"] : Loc["Design_AdviceProfilerDenied"], + ActionLabel = Loc["Design_AdviceOpenProfiler"], + Action = () => + { + Workspace.OpenProfiler(Database); + return Task.CompletedTask; + } + }); + } + HasAdvice = Advice.Count > 0; + AdviceEmpty = HasAdvice || IsView ? "" : Loc["Design_AdviceNone"]; + } + + /// 一条建议 → 卡片(标题、说明、代码块、按钮)。 + private AdviceCard Card(AdvisorSuggestion s) + { + switch (s.Kind) + { + case AdvisorKind.CreateIndex: + { + AdvisorQueryGroup q = s.Queries!; + return new AdviceCard + { + Suggestion = s, + IconKey = "Mongo.zap", + IconToken = "VelaWarning", + Title = Loc.Format("Design_AdviceCreateTitle", FieldSet(q.Shape.Fields), PlanLabel(q)), + Detail = Loc.Format("Design_AdviceCreateDetail", BsonText.Grouped(q.Count), Math.Round(q.AverageMillis), + BsonText.Count((long)q.AverageExamined), BsonText.Count((long)Math.Round(q.AverageReturned))), + Code = BsonText.Literal(s.Key!), + ActionLabel = Loc["Design_AdviceCreate"], + IsPrimary = true, + Action = () => + { + OpenCreatePanel(s.Key!, q.Shape); + return Task.CompletedTask; + } + }; + } + case AdvisorKind.ExtendIndex: + { + AdvisorQueryGroup q = s.Queries!; + return new AdviceCard + { + Suggestion = s, + IconKey = "Mongo.copy-minus", + IconToken = "VelaTextTertiary", + Title = Loc.Format("Design_AdviceExtendTitle", s.Index!.Name), + Detail = Loc.Format("Design_AdviceExtendDetail", BsonText.Grouped(q.Count), + q.InMemorySort ? Loc["Design_AdviceInMemorySort"] : Loc["Design_AdviceOverScan"], BsonText.Literal(s.Key!)), + ActionLabel = Loc["Design_AdviceViewQuery"], + Action = () => + { + Workspace.OpenQuery(Database, QueryText(q)); + return Task.CompletedTask; + } + }; + } + case AdvisorKind.RedundantIndex: + return new AdviceCard + { + Suggestion = s, + IconKey = "Mongo.copy-minus", + IconToken = "VelaTextTertiary", + Title = Loc.Format("Design_AdviceRedundantTitle", s.Index!.Name, s.Other!.Name), + Detail = Loc.Format("Design_AdviceRedundantDetail", s.Other.Name, s.Index.Name, BsonText.Bytes(s.Index.Size)), + ActionLabel = Loc["Design_AdviceHide"], + Action = () => Indexes.FirstOrDefault(r => r.Name == s.Index.Name) is { } row ? SetHiddenAsync(row, true) : Task.CompletedTask + }; + default: + { + double share = _totalIndexSize > 0 ? s.Index!.Size * 100.0 / _totalIndexSize : 0; + return new AdviceCard + { + Suggestion = s, + IconKey = "Mongo.archive-x", + IconToken = "VelaWarning", + Title = Loc.Format("Design_AdviceUnusedTitle", s.Index!.Name, s.UnusedDays), + Detail = Loc.Format("Design_AdviceUnusedDetail", BsonText.Bytes(s.Index.Size), share.ToString("0.#", CultureInfo.InvariantCulture)), + ActionLabel = Loc["Design_AdviceHide"], + Action = () => Indexes.FirstOrDefault(r => r.Name == s.Index.Name) is { } row ? SetHiddenAsync(row, true) : Task.CompletedTask + }; + } + } + } + + /// 慢查询组的代表查询(给"查看查询"打开到查询编辑器里)。 + private string QueryText(AdvisorQueryGroup q) + { + string text = $"{ShellRef}.find({BsonText.Literal(q.Filter)})"; + if (q.Sort is { ElementCount: > 0 } sort) + { + text += $".sort({BsonText.Literal(sort)})"; + } + return text + ".explain(\"executionStats\")"; + } + + /// { status, total }。 + private static string FieldSet(IReadOnlyList fields) => "{ " + string.Join(", ", fields) + " }"; + + /// 计划标签:全表扫描 / 内存排序 / 低效扫描。 + private string PlanLabel(AdvisorQueryGroup q) => + q.Collscan ? "COLLSCAN" : q.InMemorySort ? Loc["Design_AdviceInMemorySort"] : Loc["Design_AdviceOverScan"]; + + /// 类型列文字。 + private string FormText(IndexForm form) => Loc[form switch + { + IndexForm.Compound => "Design_FormCompound", + IndexForm.MultiKey => "Design_FormMultiKey", + IndexForm.Text => "Design_FormText", + IndexForm.Geo => "Design_FormGeo", + IndexForm.Hashed => "Design_FormHashed", + IndexForm.Wildcard => "Design_FormWildcard", + _ => "Design_FormSingle" + }]; + + /// 属性列的标签。 + private IReadOnlyList Attributes(BsonDocument spec, int unusedDays) + { + var list = new List(); + // _id_ 的规格里不写 unique,但它天然唯一 —— 设计稿 07 也给它标了「唯一」。 + if (spec.GetValue("unique", false).ToBoolean() || spec.GetValue("name", "").ToString() == "_id_") + { + list.Add(new(Loc["Design_AttrUnique"], "accent")); + } + if (spec.GetValue("sparse", false).ToBoolean()) + { + list.Add(new(Loc["Design_AttrSparse"], "muted")); + } + if (spec.Contains("partialFilterExpression")) + { + list.Add(new(Loc["Design_AttrPartial"], "info")); + } + if (spec.TryGetValue("expireAfterSeconds", out BsonValue ttl) && ttl.IsNumeric) + { + list.Add(new(Loc.Format("Design_AttrTtl", Duration(ttl.ToInt64())), "warn")); + } + if (spec.GetValue("hidden", false).ToBoolean()) + { + list.Add(new(Loc["Design_AttrHidden"], "muted")); + } + if (unusedDays > 0) + { + list.Add(new(Loc.Format("Design_AttrUnused", unusedDays), "warn")); + } + return list; + } + + /// 把进度写进一行(构建中 64% · 约 2 分)。 + private void ApplyProgress(IndexRow row, double percent) + { + row.IsBuilding = true; + row.BuildPercent = percent; + row.BuildText = Loc.Format("Design_Building", Math.Round(percent).ToString(CultureInfo.InvariantCulture)); + } + + /// 有构建中的索引时每秒刷一次进度,直到都建完(再整页重载一次)。 + private void StartBuildPoll() + { + if (_buildPoll is not null) + { + return; + } + var cts = CancellationTokenSource.CreateLinkedTokenSource(Lifetime); + _buildPoll = cts; + _ = PollBuildsAsync(cts); + } + + private async Task PollBuildsAsync(CancellationTokenSource cts) + { + try + { + while (!cts.IsCancellationRequested) + { + await Task.Delay(1000, cts.Token).ConfigureAwait(true); + Dictionary progress = await LoadBuildProgressAsync().ConfigureAwait(true); + foreach (IndexRow row in Indexes.Where(static r => r.IsBuilding)) + { + if (progress.TryGetValue(row.Name, out double p)) + { + ApplyProgress(row, p); + } + } + if (progress.Count == 0) + { + break; + } + } + } + catch (OperationCanceledException) + { + return; + } + finally + { + if (ReferenceEquals(_buildPoll, cts)) + { + _buildPoll = null; + } + cts.Dispose(); + } + if (!_disposed) + { + await LoadIndexesAsync().ConfigureAwait(true); + } + } + + /// + /// 隐藏 / 取消隐藏(collMod: { index: { name, hidden } },4.4+)。 + /// 隐藏是"删之前先观察"的安全步骤:优化器不再用它,但照常维护,随时能恢复 —— 所以提示里带一个「撤销」。 + /// + internal async Task SetHiddenAsync(IndexRow row, bool hidden) + { + if (row.IsId || !Workspace.EnsureWritable(Database)) + { + return; + } + if (Workspace.Guard.ConfirmWrites && !await Workspace.ConfirmAsync(new() + { + Title = hidden ? Loc["Design_HideTitle"] : Loc["Design_UnhideTitle"], + Message = Loc.Format(hidden ? "Design_HideBody" : "Design_UnhideBody", row.Name, Namespace), + ConfirmLabel = hidden ? Loc["Design_Hide"] : Loc["Design_Unhide"], + IconKey = hidden ? "Mongo.eye-off" : "Mongo.eye", + Danger = false + }).ConfigureAwait(true)) + { + return; + } + try + { + await Workspace.Connection.RunCommandAsync(Database, new BsonDocument + { + { "collMod", CollectionName }, + { "index", new BsonDocument { { "name", row.Name }, { "hidden", hidden } } } + }, Lifetime).ConfigureAwait(true); + string name = row.Name; + Workspace.Toast(new() + { + Title = Loc.Format(hidden ? "Design_HiddenDone" : "Design_UnhiddenDone", name), + Kind = ToastKind.Success, + ActionLabel = Loc["Design_Undo"], + Duration = TimeSpan.FromSeconds(8), + Action = async () => + { + if (Indexes.FirstOrDefault(r => r.Name == name) is { } again) + { + await SetHiddenAsync(again, !hidden).ConfigureAwait(true); + } + } + }); + await LoadIndexesAsync().ConfigureAwait(true); + } + catch (Exception ex) when (IsExpected(ex)) + { + Fail(ex); + } + } + + /// + /// 删除索引。确认框写清后果(查询会退回别的计划、重建要重新扫全表),并给一条"改为隐藏"的退路。 + /// + internal async Task DropIndexAsync(IndexRow row) + { + if (row.IsId || !Workspace.EnsureWritable(Database)) + { + return; + } + var facts = new List + { + new(Loc["Design_ColKey"], BsonText.Literal(row.Key)), + new(Loc["Design_ColSize"], row.SizeText), + new(Loc["Design_ColUsage"], row.OpsText) + }; + bool confirmed = await Workspace.ConfirmAsync(new() + { + Title = Loc["Design_DropTitle"], + Message = Loc.Format("Design_DropBody", row.Name, Namespace), + ConfirmLabel = Loc["Design_DropTitle"], + Facts = facts, + TypeToConfirm = Workspace.Guard.ConfirmWrites ? row.Name : null, + AsideLabel = row.IsHidden || row.IsBuilding ? null : Loc["Design_DropHideInstead"], + AsideAction = () => _ = SetHiddenAsync(row, true) + }).ConfigureAwait(true); + if (!confirmed) + { + return; + } + try + { + await Workspace.Connection.RunCommandAsync(Database, new BsonDocument + { + { "dropIndexes", CollectionName }, + { "index", row.Name } + }, Lifetime).ConfigureAwait(true); + Workspace.Toast(new() { Title = Loc.Format("Design_Dropped", row.Name), Kind = ToastKind.Success }); + SelectedIndex = null; + await LoadIndexesAsync().ConfigureAwait(true); + } + catch (Exception ex) when (IsExpected(ex)) + { + Fail(ex); + } + } + + /// 跑一个可能因权限 / 版本失败的读取;失败给兜底值。 + private async Task SafeAsync(Func> body, T fallback) + { + try + { + return await body().ConfigureAwait(true); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Workspace.Log.Info($"{Namespace}: {ex.Message}"); + return fallback; + } + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignTabViewModel.NewIndex.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignTabViewModel.NewIndex.cs new file mode 100644 index 0000000..4b18ad8 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignTabViewModel.NewIndex.cs @@ -0,0 +1,649 @@ +using System.Collections.ObjectModel; +using System.Globalization; +using MongoDB.Bson; +using VelaShell.Plugin.Mongo.Analysis; +using VelaShell.Plugin.Mongo.Bson; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 集合设计 · 右侧「新建索引」面板(设计稿 07)。 +internal sealed partial class DesignTabViewModel +{ + private bool _isCreateOpen; + private string _newKind = "normal"; + private bool _newUnique; + private bool _newSparse; + private bool _newHidden; + private bool _newTtl; + private string _newTtlSeconds = "86400"; + private bool _newPartial; + private string _newPartialText = "{ }"; + private string _newName = ""; + private bool _nameEdited; + private bool _settingName; + private string _newImpactTitle = ""; + private string _newImpactDetail = ""; + private string _newCommand = ""; + private string _newError = ""; + private bool _isCreating; + private IReadOnlyList _fieldOptions = []; + + /// 面板开着。 + public bool IsCreateOpen + { + get => _isCreateOpen; + set => SetProperty(ref _isCreateOpen, value); + } + + /// 键字段行。 + public ObservableCollection NewKeys { get; } = []; + + /// 索引类型分段:normal / text / 2dsphere / hashed / wildcard。 + public string NewKind + { + get => _newKind; + set + { + if (SetProperty(ref _newKind, value ?? "normal")) + { + foreach (NewKeyRow row in NewKeys) + { + row.Kind = _newKind; + } + RecomputeNewIndex(); + } + } + } + + /// 唯一。 + public bool NewUnique + { + get => _newUnique; + set + { + if (SetProperty(ref _newUnique, value)) + { + RecomputeNewIndex(); + } + } + } + + /// 稀疏。 + public bool NewSparse + { + get => _newSparse; + set + { + if (SetProperty(ref _newSparse, value)) + { + RecomputeNewIndex(); + } + } + } + + /// 创建后隐藏(灰度验证:先建好,观察没问题再放开给优化器)。 + public bool NewHidden + { + get => _newHidden; + set + { + if (SetProperty(ref _newHidden, value)) + { + RecomputeNewIndex(); + } + } + } + + /// TTL。 + public bool NewTtl + { + get => _newTtl; + set + { + if (SetProperty(ref _newTtl, value)) + { + RecomputeNewIndex(); + } + } + } + + /// TTL 秒数。 + public string NewTtlSeconds + { + get => _newTtlSeconds; + set + { + if (SetProperty(ref _newTtlSeconds, value ?? "")) + { + RaisePropertyChanged(nameof(NewTtlHuman)); + RecomputeNewIndex(); + } + } + } + + /// TTL 秒数换算成人话。 + public string NewTtlHuman => long.TryParse(_newTtlSeconds, out long s) ? "= " + Duration(s) : ""; + + /// 部分索引。 + public bool NewPartial + { + get => _newPartial; + set + { + if (SetProperty(ref _newPartial, value)) + { + RecomputeNewIndex(); + } + } + } + + /// partialFilterExpression 的文本(mongosh 写法)。 + public string NewPartialText + { + get => _newPartialText; + set + { + if (SetProperty(ref _newPartialText, value ?? "")) + { + RecomputeNewIndex(); + } + } + } + + /// 索引名(默认按键自动生成;手改过就不再跟着变)。 + public string NewName + { + get => _newName; + set + { + if (SetProperty(ref _newName, value ?? "") && !_settingName) + { + _nameEdited = _newName.Length > 0; + RaisePropertyChanged(nameof(NewNameHint)); + RecomputeNewIndex(); + } + } + } + + /// 名称右侧的小字:自动生成 / 已自定义。 + public string NewNameHint => _nameEdited ? Loc["Design_NameCustom"] : Loc["Design_NameAuto"]; + + /// 预估框第一行(预计大小 ≈ 140 MB · 构建约 3 分钟)。 + public string NewImpactTitle + { + get => _newImpactTitle; + private set => SetProperty(ref _newImpactTitle, value); + } + + /// 预估框说明。 + public string NewImpactDetail + { + get => _newImpactDetail; + private set => SetProperty(ref _newImpactDetail, value); + } + + /// 命令预览。 + public string NewCommand + { + get => _newCommand; + private set => SetProperty(ref _newCommand, value); + } + + /// 表单错误(创建按钮因此不可用);没有为空。 + public string NewError + { + get => _newError; + private set + { + if (SetProperty(ref _newError, value)) + { + RaisePropertyChanged(nameof(HasNewError)); + CreateIndexCommand.RaiseCanExecuteChanged(); + } + } + } + + /// 有表单错误。 + public bool HasNewError => _newError.Length > 0; + + /// 正在创建(按钮转圈)。 + public bool IsCreating + { + get => _isCreating; + private set + { + if (SetProperty(ref _isCreating, value)) + { + CreateIndexCommand.RaiseCanExecuteChanged(); + } + } + } + + /// 抽样到的字段(字段下拉的选项)。 + public IReadOnlyList FieldOptions + { + get => _fieldOptions; + private set + { + if (SetProperty(ref _fieldOptions, value)) + { + foreach (NewKeyRow row in NewKeys) + { + row.RefreshSwatch(); + } + } + } + } + + /// 打开面板(空白表单)。 + public RelayCommand OpenCreateCommand { get; private set; } = null!; + + /// 关闭面板。 + public RelayCommand CloseCreateCommand { get; private set; } = null!; + + /// 添加一个字段行。 + public RelayCommand AddKeyCommand { get; private set; } = null!; + + /// 删除一个字段行。 + public RelayCommand RemoveKeyCommand { get; private set; } = null!; + + /// 创建索引。 + public AsyncCommand CreateIndexCommand { get; private set; } = null!; + + private void InitializeNewIndex() + { + OpenCreateCommand = new(() => OpenCreatePanel(null, null)); + CloseCreateCommand = new(() => IsCreateOpen = false); + AddKeyCommand = new(() => AddKey("", 1, "")); + RemoveKeyCommand = new(row => + { + NewKeys.Remove(row); + RenumberKeys(); + RecomputeNewIndex(); + }); + CreateIndexCommand = new(CreateIndexAsync, () => !_isCreating && _newError.Length == 0 && NewKeys.Any(static k => k.Field.Length > 0)); + } + + /// + /// 打开面板。从建议卡片的「创建此索引」进来时带着建议键与查询形状(字段行的 等值 / 排序 / 范围 标签由形状给); + /// 从工具行进来是一行空字段。 + /// + internal void OpenCreatePanel(BsonDocument? key, EsrShape? shape) + { + NewKeys.Clear(); + _newKind = "normal"; + RaisePropertyChanged(nameof(NewKind)); + _newUnique = _newSparse = _newHidden = _newTtl = _newPartial = false; + RaisePropertiesChanged(nameof(NewUnique), nameof(NewSparse), nameof(NewHidden), nameof(NewTtl), nameof(NewPartial)); + _newPartialText = "{ }"; + RaisePropertyChanged(nameof(NewPartialText)); + _nameEdited = false; + RaisePropertyChanged(nameof(NewNameHint)); + if (key is { ElementCount: > 0 }) + { + foreach (BsonElement element in key) + { + int direction = element.Value.IsNumeric && element.Value.ToDouble() < 0 ? -1 : 1; + AddKey(element.Name, direction, RoleText(shape?.RoleOf(element.Name)), recompute: false); + } + } + else + { + AddKey("", 1, "", recompute: false); + } + IsCreateOpen = true; + RecomputeNewIndex(); + // 字段下拉、键长估算都靠抽样;还没分析过就在后台抽一次(不切页)。 + if (Schema is null && !IsAnalyzing) + { + _ = AnalyzeAsync(); + } + } + + /// 加一行字段。 + 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) + { + Kind = _newKind + }; + NewKeys.Add(row); + RenumberKeys(); + if (recompute) + { + RecomputeNewIndex(); + } + } + + /// 拖动排序:把一行从 挪到 。 + internal void MoveKey(int from, int to) + { + if (from < 0 || from >= NewKeys.Count || to < 0 || to >= NewKeys.Count || from == to) + { + return; + } + NewKeys.Move(from, to); + RenumberKeys(); + RecomputeNewIndex(); + } + + private void RenumberKeys() + { + for (int i = 0; i < NewKeys.Count; i++) + { + NewKeys[i].IsFirst = i == 0; + } + } + + /// 键值 → 方向下拉上的字(1 升序 / -1 降序 / text)。 + internal string DirectionLabel(BsonValue value) => + value.IsNumeric + ? Loc[value.ToDouble() < 0 ? "Design_DirDesc" : "Design_DirAsc"] + : value.ToString() ?? ""; + + /// 字段 → 类型色(抽样里没有就是灰的)。 + private string TokenOf(string field) => + _fieldOptions.FirstOrDefault(f => f.Path == field)?.Token ?? "VelaTextMuted"; + + /// ESR 角色 → 小标签文字。 + private string RoleText(string? role) => role switch + { + "E" => Loc["Design_RoleEquality"], + "S" => Loc["Design_RoleSort"], + "R" => Loc["Design_RoleRange"], + _ => "" + }; + + /// + /// 手加的字段没有查询形状可依,就按抽样猜一个角色:少量取值的字符串 / 布尔像等值条件, + /// 数值与日期像范围条件。猜不出就不标 —— 标错比不标更误导。 + /// + private string GuessRole(string field) + { + if (Schema?[field] is not { } profile) + { + return ""; + } + BsonKind kind = profile.DominantKind; + if (kind is BsonKind.Boolean || (kind is BsonKind.String or BsonKind.ObjectId && SchemaAnalyzer.IsLowCardinality(profile))) + { + return Loc["Design_RoleEquality"]; + } + return BsonKinds.IsNumeric(kind) || kind == BsonKind.Date ? Loc["Design_RoleRange"] : ""; + } + + /// 当前表单 → 键模式。 + internal BsonDocument NewKeyDocument() + { + var key = new BsonDocument(); + foreach (NewKeyRow row in NewKeys) + { + string field = row.Field.Trim(); + if (field.Length == 0) + { + continue; + } + if (_newKind == "wildcard" && !field.EndsWith("$**", StringComparison.Ordinal)) + { + field = field == "*" ? "$**" : field + ".$**"; + } + key.Set(field, row.DirectionValue); + } + if (key.ElementCount == 0 && _newKind == "wildcard") + { + key["$**"] = 1; + } + return key; + } + + /// 当前表单 → 选项(不含 key / name)。表单不合法时抛 ,消息即提示。 + internal BsonDocument NewOptionsDocument(BsonDocument key) + { + var options = new BsonDocument(); + if (_newUnique) + { + if (_newKind is "text" or "hashed" or "wildcard" or "2dsphere") + { + throw new FormatException(Loc["Design_ErrUniqueKind"]); + } + options["unique"] = true; + } + if (_newSparse) + { + options["sparse"] = true; + } + if (_newTtl) + { + if (key.ElementCount != 1 || _newKind != "normal") + { + throw new FormatException(Loc["Design_ErrTtlSingle"]); + } + if (!long.TryParse(_newTtlSeconds, NumberStyles.Integer, CultureInfo.InvariantCulture, out long seconds) || seconds < 0) + { + throw new FormatException(Loc["Design_ErrTtlSeconds"]); + } + options["expireAfterSeconds"] = seconds <= int.MaxValue ? (BsonValue)(int)seconds : seconds; + } + if (_newPartial) + { + if (_newSparse) + { + throw new FormatException(Loc["Design_ErrSparsePartial"]); + } + BsonDocument partial; + try + { + partial = ShellJson.ParseDocument(_newPartialText); + } + catch (ShellJsonException ex) + { + throw new FormatException(Loc.Format("Design_ErrPartial", ex.Message)); + } + if (partial.ElementCount == 0) + { + throw new FormatException(Loc["Design_ErrPartialEmpty"]); + } + options["partialFilterExpression"] = partial; + } + if (_newHidden) + { + options["hidden"] = true; + } + return options; + } + + /// 表单任何改动后:重算名称、命令预览、预估与错误。 + private void RecomputeNewIndex() + { + BsonDocument key = NewKeyDocument(); + string auto = key.ElementCount == 0 ? "" : IndexAdvisor.DefaultName(key); + if (!_nameEdited) + { + _settingName = true; + NewName = auto; + _settingName = false; + } + string error = ""; + BsonDocument options = []; + if (key.ElementCount == 0) + { + error = Loc["Design_ErrNoFields"]; + } + else + { + try + { + options = NewOptionsDocument(key); + } + catch (FormatException ex) + { + error = ex.Message; + } + } + if (error.Length == 0 && Indexes.FirstOrDefault(i => i.Name == _newName.Trim()) is { } clash) + { + error = Loc.Format("Design_ErrNameExists", clash.Name); + } + if (_nameEdited && _newName.Trim() != auto && _newName.Trim().Length > 0) + { + options["name"] = _newName.Trim(); + } + NewError = error; + NewCommand = key.ElementCount == 0 + ? "" + : $"{ShellRef}.createIndex(\n {BsonText.Literal(key)}" + (options.ElementCount > 0 ? $",\n {BsonText.Literal(options)}" : "") + "\n)"; + UpdateImpact(key); + CreateIndexCommand?.RaiseCanExecuteChanged(); + } + + /// + /// 预估:大小 ≈ 文档数 ×(各键字段平均值长 + 每项开销),构建耗时按数据量粗估; + /// 再写一句当前版本的构建方式(4.4 起的优化构建只在首尾短暂持排他锁)。 + /// + private void UpdateImpact(BsonDocument key) + { + long count = _indexStats?.Count ?? _stats?.Count ?? 0; + long data = _indexStats?.Size ?? _stats?.Size ?? 0; + double keyBytes = 0; + foreach (BsonElement element in key) + { + string field = element.Name.Replace(".$**", "", StringComparison.Ordinal); + keyBytes += Schema?[field] is { ScalarCount: > 0 } profile ? profile.AverageValueBytes : 12; + } + long size = IndexAdvisor.EstimateSize(count, keyBytes); + double seconds = IndexAdvisor.EstimateBuildSeconds(count, data); + string time = seconds < 60 + ? Loc.Format("Design_Seconds", Math.Max(1, (int)Math.Ceiling(seconds))) + : Loc.Format("Design_Minutes", Math.Ceiling(seconds / 60).ToString("0", CultureInfo.InvariantCulture)); + NewImpactTitle = Loc.Format("Design_ImpactTitle", BsonText.Bytes(size), time); + int major = Workspace.Connection.Server.Major; + string detail = major is 0 or >= 5 || Workspace.Connection.Server.Version.StartsWith("4.4", StringComparison.Ordinal) + ? Loc["Design_ImpactOptimized"] + : Loc["Design_ImpactLegacy"]; + if (_newPartial) + { + detail += " " + Loc["Design_ImpactPartial"]; + } + NewImpactDetail = detail; + } + + /// + /// 创建索引:过写护栏 → 生产连接确认 → createIndexes。构建期间表里先挂一行"构建中", + /// 每秒从 currentOp 刷进度;命令返回(建好或失败)后整页重载。 + /// + private async Task CreateIndexAsync() + { + if (!Workspace.EnsureWritable(Database)) + { + return; + } + BsonDocument key = NewKeyDocument(); + BsonDocument options; + try + { + options = NewOptionsDocument(key); + } + catch (FormatException ex) + { + NewError = ex.Message; + return; + } + string name = _newName.Trim().Length > 0 ? _newName.Trim() : IndexAdvisor.DefaultName(key); + if (Workspace.Guard.ConfirmWrites || Workspace.Guard.IsProduction) + { + bool ok = await Workspace.ConfirmAsync(new() + { + Title = Loc["Design_CreateTitle"], + Message = Loc.Format("Design_CreateBody", name, Namespace), + ConfirmLabel = Loc["Design_CreateIndex"], + IconKey = "Mongo.key-round", + Danger = false, + Facts = [new(Loc["Design_ColKey"], BsonText.Literal(key)), new(Loc["Design_Estimate"], NewImpactTitle)], + TypeToConfirm = Workspace.Guard.ConfirmWrites ? CollectionName : null + }).ConfigureAwait(true); + if (!ok) + { + return; + } + } + var spec = new BsonDocument { { "key", key }, { "name", name } }; + spec.Merge(options, overwriteExistingElements: false); + IsCreating = true; + var placeholder = new IndexRow + { + Name = name, + Spec = spec, + Keys = [.. IndexAdvisor.DisplayKeys(spec).Select(static k => new IndexKeyChip(k.Field, k.Value))], + Form = IndexAdvisor.ShapeOf(spec), + TypeText = FormText(IndexAdvisor.ShapeOf(spec)), + Attributes = Attributes(spec, 0) + }; + ApplyProgress(placeholder, 0); + Indexes.Add(placeholder); + using var poll = CancellationTokenSource.CreateLinkedTokenSource(Lifetime); + Task progress = TrackCreateAsync(placeholder, poll.Token); + try + { + await Workspace.Connection.RunCommandAsync(Database, new BsonDocument + { + { "createIndexes", CollectionName }, + { "indexes", new BsonArray { spec } } + }, Lifetime).ConfigureAwait(true); + Workspace.Toast(new() + { + Title = Loc.Format("Design_Created", name), + Detail = _newHidden ? Loc["Design_CreatedHidden"] : null, + Kind = ToastKind.Success + }); + IsCreateOpen = false; + } + catch (Exception ex) when (IsExpected(ex)) + { + Fail(ex); + } + finally + { + poll.Cancel(); + try + { + await progress.ConfigureAwait(true); + } + catch (OperationCanceledException) + { + } + IsCreating = false; + Indexes.Remove(placeholder); + } + await LoadIndexesAsync().ConfigureAwait(true); + } + + /// 构建期间刷占位行的进度。 + private async Task TrackCreateAsync(IndexRow placeholder, CancellationToken token) + { + while (!token.IsCancellationRequested) + { + await Task.Delay(700, token).ConfigureAwait(true); + Dictionary progress = await LoadBuildProgressAsync().ConfigureAwait(true); + if (progress.TryGetValue(placeholder.Name, out double p)) + { + ApplyProgress(placeholder, p); + } + } + } + + /// 抽样分析完成后:刷新字段下拉、重算预估与字段行的角色。 + private void OnSchemaForNewIndex(AnalyzedSchema schema) + { + FieldOptions = + [ + .. schema.Fields + .Where(static f => f.DominantKind is not BsonKind.Missing) + .Select(static f => new FieldOption(f.Path, f.DominantKind)) + ]; + foreach (NewKeyRow row in NewKeys.Where(static r => !r.HasRole)) + { + row.Role = GuessRole(row.Field); + } + RecomputeNewIndex(); + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignTabViewModel.Options.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignTabViewModel.Options.cs new file mode 100644 index 0000000..7103f4f --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignTabViewModel.Options.cs @@ -0,0 +1,356 @@ +using System.Collections.ObjectModel; +using System.Globalization; +using MongoDB.Bson; +using VelaShell.Plugin.Mongo.Analysis; +using VelaShell.Plugin.Mongo.Bson; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 集合设计 · 选项:TTL、排序规则、固定集合上限、时序参数、视图定义、变更流前后镜像。 +/// +/// 能用 collMod 改的都给改(TTL 秒数、固定集合上限 6.0+、时序粒度只能往粗改、视图的源与管道、前后镜像), +/// 改不了的(排序规则、timeField / metaField)只读展示并写明原因 —— 用户最常问的就是"这个能不能改"。 +/// +/// +internal sealed partial class DesignTabViewModel +{ + /// 时序粒度从细到粗(只能往粗改)。 + public static IReadOnlyList Granularities { get; } = ["seconds", "minutes", "hours"]; + + private string _collationText = ""; + private string _cappedSize = ""; + private string _cappedMax = ""; + private string _tsTimeField = ""; + private string _tsMetaField = ""; + private string _tsGranularity = ""; + private string _tsExpire = ""; + private string _viewOn = ""; + private string _viewPipeline = ""; + private bool _prePost; + private bool _prePostOriginal; + private string _optionsJson = ""; + + /// TTL 索引。 + public ObservableCollection TtlRows { get; } = []; + + /// 有 TTL 索引。 + public bool HasTtl => TtlRows.Count > 0; + + /// 排序规则(只读);没有为空。 + public string CollationText + { + get => _collationText; + private set + { + if (SetProperty(ref _collationText, value)) + { + RaisePropertyChanged(nameof(HasCollation)); + } + } + } + + /// 设了排序规则。 + public bool HasCollation => _collationText.Length > 0; + + /// 固定集合字节上限(可改,6.0+)。 + public string CappedSize + { + get => _cappedSize; + set => SetProperty(ref _cappedSize, value ?? ""); + } + + /// 固定集合文档数上限(可改,6.0+;空 = 不限)。 + public string CappedMax + { + get => _cappedMax; + set => SetProperty(ref _cappedMax, value ?? ""); + } + + /// 固定集合字节上限换算(= 1.0 GB)。 + public string CappedSizeHuman => long.TryParse(_cappedSize, out long b) ? "= " + BsonText.Bytes(b) : ""; + + /// 时序:时间字段(只读)。 + public string TsTimeField + { + get => _tsTimeField; + private set => SetProperty(ref _tsTimeField, value); + } + + /// 时序:元数据字段(只读)。 + public string TsMetaField + { + get => _tsMetaField; + private set => SetProperty(ref _tsMetaField, value); + } + + /// 时序:粒度(只能往粗改)。 + public string TsGranularity + { + get => _tsGranularity; + set => SetProperty(ref _tsGranularity, value ?? ""); + } + + /// 时序:过期秒数(空 = 不过期)。 + public string TsExpire + { + get => _tsExpire; + set + { + if (SetProperty(ref _tsExpire, value ?? "")) + { + RaisePropertyChanged(nameof(TsExpireHuman)); + } + } + } + + /// 时序过期换算。 + public string TsExpireHuman => long.TryParse(_tsExpire, out long s) ? "= " + Duration(s) : Loc["Design_NeverExpire"]; + + /// 视图:源集合。 + public string ViewOn + { + get => _viewOn; + set => SetProperty(ref _viewOn, value ?? ""); + } + + /// 视图:管道(mongosh 写法)。 + public string ViewPipeline + { + get => _viewPipeline; + set => SetProperty(ref _viewPipeline, value ?? ""); + } + + /// 变更流前后镜像(changeStreamPreAndPostImages,6.0+)。 + public bool PrePostImages + { + get => _prePost; + set + { + if (SetProperty(ref _prePost, value)) + { + RaisePropertyChanged(nameof(PrePostDirty)); + } + } + } + + /// 前后镜像开关改过了。 + public bool PrePostDirty => _prePost != _prePostOriginal; + + /// listCollections 的 options 原文(只读,页底)。 + public string OptionsJson + { + get => _optionsJson; + private set => SetProperty(ref _optionsJson, value); + } + + /// 应用一条 TTL 的新秒数。 + public AsyncCommand ApplyTtlCommand { get; private set; } = null!; + + /// 应用固定集合上限。 + public AsyncCommand ApplyCappedCommand { get; private set; } = null!; + + /// 应用时序参数。 + public AsyncCommand ApplyTimeSeriesCommand { get; private set; } = null!; + + /// 应用视图定义。 + public AsyncCommand ApplyViewCommand { get; private set; } = null!; + + /// 应用前后镜像开关。 + public AsyncCommand ApplyPrePostCommand { get; private set; } = null!; + + /// 选时序粒度。 + public RelayCommand SetGranularityCommand { get; private set; } = null!; + + private void InitializeOptions() + { + TtlRows.CollectionChanged += (_, _) => RaisePropertyChanged(nameof(HasTtl)); + ApplyTtlCommand = new(ApplyTtlAsync); + ApplyCappedCommand = new(ApplyCappedAsync); + ApplyTimeSeriesCommand = new(ApplyTimeSeriesAsync); + ApplyViewCommand = new(ApplyViewAsync); + ApplyPrePostCommand = new(async () => + { + if (await CollModAsync(new BsonDocument("changeStreamPreAndPostImages", new BsonDocument("enabled", _prePost)), Loc["Design_OptPrePost"]) + .ConfigureAwait(true)) + { + await LoadOptionsAsync().ConfigureAwait(true); + } + }); + SetGranularityCommand = new(g => TsGranularity = g); + } + + /// 加载选项页(重探集合信息;TTL 来自索引列表)。 + private async Task LoadOptionsAsync() + { + await RefreshInfoAsync().ConfigureAwait(true); + // TTL 行来自索引列表:别处(shell、别的客户端)可能刚建过 TTL 索引,每次进选项页都重读一遍。 + await LoadIndexesAsync().ConfigureAwait(true); + BsonDocument options = _live.Options; + CollationText = options.GetValue("collation", BsonNull.Value) is BsonDocument collation ? BsonText.Literal(collation) : ""; + CappedSize = options.GetValue("size", BsonNull.Value) is { IsNumeric: true } size ? size.ToInt64().ToString(CultureInfo.InvariantCulture) : ""; + CappedMax = options.GetValue("max", BsonNull.Value) is { IsNumeric: true } max && max.ToInt64() > 0 ? max.ToInt64().ToString(CultureInfo.InvariantCulture) : ""; + RaisePropertyChanged(nameof(CappedSizeHuman)); + BsonDocument ts = options.GetValue("timeseries", BsonNull.Value) as BsonDocument ?? []; + TsTimeField = ts.GetValue("timeField", "").ToString() ?? ""; + TsMetaField = ts.GetValue("metaField", BsonNull.Value) is BsonString meta ? meta.Value : "—"; + TsGranularity = ts.GetValue("granularity", BsonNull.Value) is BsonString g ? g.Value : "seconds"; + TsExpire = options.GetValue("expireAfterSeconds", BsonNull.Value) is { IsNumeric: true } e ? e.ToInt64().ToString(CultureInfo.InvariantCulture) : ""; + ViewOn = _live.ViewOn ?? ""; + ViewPipeline = _live.Pipeline is { } pipeline ? JsonSchemaGenerator.Format(pipeline, RuleWidth) : "[]"; + _prePostOriginal = options.GetValue("changeStreamPreAndPostImages", BsonNull.Value) is BsonDocument pp && pp.GetValue("enabled", false).ToBoolean(); + PrePostImages = _prePostOriginal; + RaisePropertyChanged(nameof(PrePostDirty)); + OptionsJson = options.ElementCount == 0 ? "{}" : BsonText.Pretty(options); + RefreshTtlRows(); + _optionsLoaded = true; + UpdateStatus(); + } + + /// TTL 行 ← 索引列表里带 expireAfterSeconds 的。 + private void RefreshTtlRows() + { + TtlRows.Clear(); + foreach (IndexRow row in Indexes) + { + if (row.Spec.TryGetValue("expireAfterSeconds", out BsonValue seconds) && seconds.IsNumeric) + { + TtlRows.Add(new TtlRow(row.Name, row.Key.Names.FirstOrDefault() ?? "", seconds.ToInt64(), Duration)); + } + } + } + + /// 改 TTL 秒数(collMod: { index: { name, expireAfterSeconds } })。 + internal async Task ApplyTtlAsync(TtlRow row) + { + if (!long.TryParse(row.Seconds, NumberStyles.Integer, CultureInfo.InvariantCulture, out long seconds) || seconds < 0) + { + Workspace.Toast(new() { Title = Loc["Design_ErrTtlSeconds"], Kind = ToastKind.Warning }); + return; + } + var index = new BsonDocument { { "name", row.Index }, { "expireAfterSeconds", seconds } }; + if (await CollModAsync(new BsonDocument("index", index), Loc.Format("Design_OptTtlChange", row.Index, Duration(seconds))).ConfigureAwait(true)) + { + await LoadIndexesAsync().ConfigureAwait(true); + } + } + + /// 改固定集合上限(6.0+ 的 cappedSize / cappedMax)。 + private async Task ApplyCappedAsync() + { + var changes = new BsonDocument(); + if (long.TryParse(_cappedSize, NumberStyles.Integer, CultureInfo.InvariantCulture, out long size) && size > 0) + { + changes["cappedSize"] = size; + } + if (_cappedMax.Trim().Length == 0) + { + changes["cappedMax"] = 0; + } + else if (long.TryParse(_cappedMax, NumberStyles.Integer, CultureInfo.InvariantCulture, out long max) && max >= 0) + { + changes["cappedMax"] = max; + } + if (changes.ElementCount == 0) + { + return; + } + await CollModAsync(changes, Loc["Design_OptCapped"]).ConfigureAwait(true); + await LoadOptionsAsync().ConfigureAwait(true); + } + + /// 改时序参数:粒度(只能往粗)与过期秒数(空 = 关闭过期)。 + private async Task ApplyTimeSeriesAsync() + { + var changes = new BsonDocument(); + BsonDocument ts = _live.Options.GetValue("timeseries", BsonNull.Value) as BsonDocument ?? []; + string current = ts.GetValue("granularity", BsonNull.Value) is BsonString g ? g.Value : ""; + if (_tsGranularity.Length > 0 && _tsGranularity != current) + { + if (Granularities.ToList().IndexOf(_tsGranularity) < Granularities.ToList().IndexOf(current)) + { + Workspace.Toast(new() { Title = Loc["Design_ErrGranularity"], Kind = ToastKind.Warning }); + return; + } + changes["timeseries"] = new BsonDocument("granularity", _tsGranularity); + } + if (_tsExpire.Trim().Length == 0) + { + if (_live.Options.Contains("expireAfterSeconds")) + { + changes["expireAfterSeconds"] = "off"; + } + } + else if (long.TryParse(_tsExpire, NumberStyles.Integer, CultureInfo.InvariantCulture, out long seconds) && seconds >= 0) + { + changes["expireAfterSeconds"] = seconds; + } + if (changes.ElementCount == 0) + { + return; + } + await CollModAsync(changes, Loc["Design_OptTimeSeries"]).ConfigureAwait(true); + await LoadOptionsAsync().ConfigureAwait(true); + } + + /// 改视图定义(collMod: { viewOn, pipeline })。 + private async Task ApplyViewAsync() + { + BsonArray pipeline; + try + { + pipeline = ShellJson.ParseArray(_viewPipeline); + } + catch (ShellJsonException ex) + { + Workspace.Toast(new() { Title = Loc.Format("Design_SyntaxError", ex.Message), Kind = ToastKind.Warning }); + return; + } + if (_viewOn.Trim().Length == 0) + { + return; + } + await CollModAsync(new BsonDocument { { "viewOn", _viewOn.Trim() }, { "pipeline", pipeline } }, Loc["Design_OptView"]).ConfigureAwait(true); + await LoadOptionsAsync().ConfigureAwait(true); + } + + /// + /// 一次 collMod:写护栏 → 确认(列出要改的东西)→ 执行 → 重探集合信息。 + /// + private async Task CollModAsync(BsonDocument changes, string what) + { + if (!Workspace.EnsureWritable(Database)) + { + return false; + } + bool confirmed = await Workspace.ConfirmAsync(new() + { + Title = Loc["Design_CollModTitle"], + Message = Loc.Format("Design_CollModBody", Namespace, what), + ConfirmLabel = Loc["Common_Apply"], + IconKey = "Mongo.settings-2", + Danger = false, + Facts = [.. changes.Select(static e => new ConfirmFact(e.Name, BsonText.Literal(e.Value)))], + TypeToConfirm = Workspace.Guard.ConfirmWrites ? CollectionName : null + }).ConfigureAwait(true); + if (!confirmed) + { + return false; + } + try + { + var command = new BsonDocument("collMod", CollectionName); + command.Merge(changes); + await Workspace.Connection.RunCommandAsync(Database, command, Lifetime).ConfigureAwait(true); + Workspace.Toast(new() { Title = Loc.Format("Design_CollModDone", what), Kind = ToastKind.Success }); + await RefreshInfoAsync().ConfigureAwait(true); + await Workspace.RefreshTreeAsync(Database).ConfigureAwait(true); + return true; + } + catch (Exception ex) when (IsExpected(ex)) + { + Fail(ex); + return false; + } + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignTabViewModel.Schema.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignTabViewModel.Schema.cs new file mode 100644 index 0000000..2573a08 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignTabViewModel.Schema.cs @@ -0,0 +1,511 @@ +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Globalization; +using MongoDB.Bson; +using MongoDB.Driver; +using VelaShell.Plugin.Mongo.Analysis; +using VelaShell.Plugin.Mongo.Bson; +using VelaShell.Plugin.Mongo.Core; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 集合设计 · Schema 分析与"由抽样生成验证规则"(设计稿 08)。 +internal sealed partial class DesignTabViewModel +{ + /// 抽样数的三档。 + public static IReadOnlyList SampleSizes { get; } = [100, 1000, 5000]; + + private int _sampleSize; + private string _schemaFilter = ""; + private bool _filterSeeded; + private bool _isAnalyzing; + private string _schemaTiming = ""; + private string _sampledText = ""; + private AnalyzedSchema? _schema; + private IReadOnlyList _sample = []; + private BsonDocument? _generated; + private string _generatedText = ""; + private string _genLevel = "moderate"; + private string _genAction = "warn"; + private bool _isGenChecking; + private long _genFailCount = -1; + private string _genCheckTitle = ""; + private string _genCheckReasons = ""; + + /// 抽样文档数。 + public int SampleSize + { + get => _sampleSize; + set + { + if (SetProperty(ref _sampleSize, value)) + { + RaisePropertyChanged(nameof(SampleSizeText)); + } + } + } + + /// 抽样数文字(1,000)。 + public string SampleSizeText => BsonText.Grouped(_sampleSize); + + /// 沿用的筛选条件(mongosh 写法;空 = 全集合抽样)。 + public string SchemaFilter + { + get => _schemaFilter; + set => SetProperty(ref _schemaFilter, value ?? ""); + } + + /// 字段行。 + public ObservableCollection SchemaFields { get; } = []; + + /// 正在抽样分析。 + public bool IsAnalyzing + { + get => _isAnalyzing; + private set + { + if (SetProperty(ref _isAnalyzing, value)) + { + RaisePropertyChanged(nameof(ShowSchemaEmpty)); + UpdateStatus(); + } + } + } + + /// 抽样结果为空(空集合或筛选不到)。 + public bool ShowSchemaEmpty => !_isAnalyzing && _schema is { Sampled: 0 }; + + /// 工具行右侧:分析用时 0.4 s · 2026-09-26 21:08。 + public string SchemaTiming + { + get => _schemaTiming; + private set => SetProperty(ref _schemaTiming, value); + } + + /// 抽到了多少(抽样 1,000 / 1,284,902)。 + public string SampledText + { + get => _sampledText; + private set => SetProperty(ref _sampledText, value); + } + + /// 最近一次的分析结果。 + public AnalyzedSchema? Schema + { + get => _schema; + private set + { + if (SetProperty(ref _schema, value)) + { + RaisePropertyChanged(nameof(ShowSchemaEmpty)); + } + } + } + + /// 抽样到的文档(验证规则页的试写文档以其中一份起头)。 + internal IReadOnlyList Sample => _sample; + + /// 由抽样生成的 validator。 + public BsonDocument? Generated + { + get => _generated; + private set + { + if (SetProperty(ref _generated, value)) + { + RaisePropertyChanged(nameof(HasGenerated)); + } + } + } + + /// 有生成结果。 + public bool HasGenerated => _generated is not null; + + /// 生成规则的预览文本。 + public string GeneratedText + { + get => _generatedText; + private set => SetProperty(ref _generatedText, value); + } + + /// 生成规则用的 validationLevel(默认 moderate:只校验新写入与本来就合规的文档的更新)。 + public string GenLevel + { + get => _genLevel; + set + { + if (SetProperty(ref _genLevel, value ?? "moderate")) + { + RaisePropertyChanged(nameof(GenCheckReasons)); + } + } + } + + /// 生成规则用的 validationAction(默认 warn:先观察,不拦写入)。 + public string GenAction + { + get => _genAction; + set => SetProperty(ref _genAction, value ?? "warn"); + } + + /// 正在做现有数据预检。 + public bool IsGenChecking + { + get => _isGenChecking; + private set => SetProperty(ref _isGenChecking, value); + } + + /// 预检不通过的份数;未检为 -1。 + public long GenFailCount + { + get => _genFailCount; + private set + { + if (SetProperty(ref _genFailCount, value)) + { + RaisePropertiesChanged(nameof(GenHasFailures), nameof(GenAllPass), nameof(GenFailLink)); + } + } + } + + /// 有不通过的文档(橙色预检框)。 + public bool GenHasFailures => _genFailCount > 0; + + /// 全部通过(绿色预检框)。 + public bool GenAllPass => _genFailCount == 0; + + /// 预检标题。 + public string GenCheckTitle + { + get => _genCheckTitle; + private set => SetProperty(ref _genCheckTitle, value); + } + + /// 主要原因 + 建议。 + public string GenCheckReasons + { + get => _genCheckReasons; + private set => SetProperty(ref _genCheckReasons, value); + } + + /// 「在查询编辑器中查看这 37 份文档 →」。 + public string GenFailLink => Loc.Format("Design_GenViewFailures", BsonText.Grouped(Math.Max(0, _genFailCount))); + + /// 重新分析。 + public AsyncCommand AnalyzeCommand { get; private set; } = null!; + + /// 换抽样数(并重新分析)。 + public AsyncCommand SetSampleCommand { get; private set; } = null!; + + /// 复制生成的规则。 + public AsyncCommand CopyGeneratedCommand { get; private set; } = null!; + + /// 在查询编辑器里查看不符合的文档。 + public RelayCommand ViewGeneratedFailuresCommand { get; private set; } = null!; + + /// 在验证规则页里调整。 + public RelayCommand EditGeneratedCommand { get; private set; } = null!; + + /// 直接应用为验证规则。 + public AsyncCommand ApplyGeneratedCommand { get; private set; } = null!; + + private void InitializeSchema() + { + AnalyzeCommand = new(AnalyzeAsync); + SetSampleCommand = new(async text => + { + if (int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out int size)) + { + SampleSize = size; + await AnalyzeAsync().ConfigureAwait(true); + } + }); + CopyGeneratedCommand = new(async () => + { + if (_generated is not null) + { + await Workspace.CopyAsync(JsonSchemaGenerator.Format(_generated)).ConfigureAwait(true); + Workspace.Toast(new() { Title = Loc["Common_Copied"], Kind = ToastKind.Success }); + } + }); + ViewGeneratedFailuresCommand = new(() => + { + if (_generated is not null) + { + Workspace.OpenQuery(Database, FailingQuery(_generated), run: true); + } + }); + EditGeneratedCommand = new(() => + { + if (_generated is not null) + { + LoadRuleIntoEditor(_generated, _genLevel, _genAction); + Page = DesignPage.Validation; + } + }); + ApplyGeneratedCommand = new(() => _generated is null + ? Task.CompletedTask + : ApplyValidatorAsync(_generated, _genLevel, _genAction, _genFailCount)); + } + + /// 不符合某条 validator 的文档的查询(db.orders.find({ $nor: [ … ] }))。 + internal string FailingQuery(BsonDocument validator) => + $"{ShellRef}.find({BsonText.Literal(new BsonDocument("$nor", new BsonArray { validator }))})"; + + /// + /// 抽样并分析:$match(沿用的筛选)→ $sample。统计在后台线程算,算完一次性换掉整张表。 + /// 第一次分析时把这个集合最近一次用过的筛选条件带进来(「筛选条件沿用」)。 + /// + internal async Task AnalyzeAsync() + { + if (_isAnalyzing || _disposed) + { + return; + } + IsAnalyzing = true; + try + { + if (!_filterSeeded) + { + _filterSeeded = true; + IReadOnlyList history = await Workspace.Store.LoadFilterHistoryAsync(Workspace.ConnectionKey, Namespace).ConfigureAwait(true); + if (_schemaFilter.Length == 0 && history.Count > 0) + { + SchemaFilter = history[0]; + } + } + BsonDocument filter = ShellJson.ParseDocument(_schemaFilter); + var pipeline = new List(); + if (filter.ElementCount > 0) + { + pipeline.Add(new BsonDocument("$match", filter)); + } + pipeline.Add(new BsonDocument("$sample", new BsonDocument("size", _sampleSize))); + var watch = Stopwatch.StartNew(); + var options = new AggregateOptions + { + MaxTime = TimeSpan.FromMilliseconds(Math.Max(1000, Workspace.Connection.Settings.MaxTimeMs)), + Comment = "velashell:design-sample" + }; + using IAsyncCursor cursor = await Workspace.Connection.Collection(Database, CollectionName) + .AggregateAsync(pipeline, options, Lifetime).ConfigureAwait(true); + List docs = await cursor.ToListAsync(Lifetime).ConfigureAwait(true); + AnalyzedSchema schema = await Task.Run(() => SchemaAnalyzer.Analyze(docs), Lifetime).ConfigureAwait(true); + List rows = await Task.Run(() => schema.Fields.Select(BuildSchemaRow).ToList(), Lifetime).ConfigureAwait(true); + watch.Stop(); + long? total = await Workspace.Connection.EstimatedCountAsync(Database, CollectionName, Lifetime).ConfigureAwait(true); + + _sample = docs; + Schema = schema; + SchemaFields.Clear(); + foreach (SchemaFieldRow row in rows) + { + SchemaFields.Add(row); + } + SchemaTiming = Loc.Format("Design_SchemaTiming", + watch.Elapsed.TotalSeconds.ToString("0.0", CultureInfo.InvariantCulture), + DateTime.Now.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)); + SampledText = Loc.Format("Design_Sampled", BsonText.Grouped(docs.Count), total is { } t ? BsonText.Grouped(t) : "?"); + if (docs.Count > 0) + { + Generated = JsonSchemaGenerator.Generate(schema); + GeneratedText = JsonSchemaGenerator.Format(Generated, width: 48, compactRoot: true); + } + else + { + Generated = null; + GeneratedText = ""; + } + OnSchemaForNewIndex(schema); + OnSchemaForValidation(); + UpdateStatus(); + if (Generated is not null) + { + _ = PrecheckGeneratedAsync(Generated); + } + } + catch (Exception ex) when (IsExpected(ex)) + { + Fail(ex); + } + finally + { + IsAnalyzing = false; + } + } + + /// 一个字段画像 → 表格一行。 + private SchemaFieldRow BuildSchemaRow(AnalyzedField field) + { + (string hint, bool warn) = SchemaAnalyzer.Describe(field, Loc); + var pieces = new List(); + var legend = new List(); + foreach (BsonKind kind in field.PresentKinds) + { + double ratio = field.Total == 0 ? 0 : (double)field.Kinds[kind] / field.Total; + string token = BsonKinds.ColorToken(kind); + pieces.Add(new(ratio, token)); + legend.Add(new($"{BsonKinds.Name(kind)} {Percent(ratio)}", token)); + } + if (field.Missing > 0) + { + double ratio = (double)field.Missing / field.Total; + pieces.Add(new(ratio, "VelaTextMuted")); + legend.Add(new(Loc.Format("Design_LegendMissing", Percent(ratio)), "VelaTextMuted")); + } + + AnalyzedViz viz = SchemaAnalyzer.VizOf(field); + IReadOnlyList bars = []; + IReadOnlyList? histogram = null; + IReadOnlyList? labels = null; + string axisMin = "", axisMax = ""; + IReadOnlyList lengths = []; + switch (viz) + { + case AnalyzedViz.Bars: + { + string token = BsonKinds.ColorToken(field.DominantKind); + bars = + [ + .. field.Values + .OrderByDescending(static v => v.Value) + .Take(SchemaAnalyzer.MaxBars) + .Select(v => + { + double ratio = (double)v.Value / field.ScalarCount; + string label = v.Key.IsString ? BsonText.OneLine(v.Key.AsString) : SchemaAnalyzer.Label(v.Key); + return new ValueBar(label, Percent(ratio), ratio, token); + }) + ]; + break; + } + case AnalyzedViz.Histogram: + { + (double[] bins, double min, double max, bool clipped) = SchemaAnalyzer.Histogram(field); + histogram = [new ChartSeries(field.Path, "VelaShellGreen", bins)]; + double width = (max - min) / Math.Max(1, bins.Length); + labels = [.. bins.Select((_, i) => $"{SchemaAnalyzer.Number(min + (width * i))} – {SchemaAnalyzer.Number(min + (width * (i + 1)))}")]; + axisMin = SchemaAnalyzer.Number(min); + axisMax = SchemaAnalyzer.Number(max) + (clipped ? "+" : ""); + break; + } + case AnalyzedViz.Timeline: + { + (double[] bins, DateTime start, DateTime end, int perBin) = SchemaAnalyzer.Timeline(field); + histogram = [new ChartSeries(field.Path, "VelaShellMagenta", bins)]; + labels = [.. bins.Select((_, i) => start.AddDays(i * perBin).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture))]; + axisMin = start.ToString("MM-dd", CultureInfo.InvariantCulture); + axisMax = end.ToString("MM-dd", CultureInfo.InvariantCulture); + break; + } + case AnalyzedViz.Lengths: + { + IReadOnlyList buckets = SchemaAnalyzer.Lengths(field); + double peak = buckets.Count == 0 ? 1 : Math.Max(0.0001, buckets.Max(static b => b.Ratio)); + lengths = [.. buckets.Select(b => new LengthBar(b.Label, Math.Round(36 * b.Ratio / peak), b.Count))]; + break; + } + } + return new SchemaFieldRow + { + Path = field.Path, + Hint = hint, + HintWarn = warn, + Pieces = pieces, + Legend = legend, + Viz = viz, + VizText = viz == AnalyzedViz.Text ? SchemaAnalyzer.VizText(field, Loc) : "", + Bars = bars, + Histogram = histogram, + HistogramLabels = labels, + AxisMin = axisMin, + AxisMax = axisMax, + Lengths = lengths + }; + } + + /// 百分比文字:不足 1% 但非零的写成 <1%,别让一段可见的比例条配一个 0%。 + private static string Percent(double ratio) => + ratio is > 0 and < 0.005 ? "<1%" : Math.Round(ratio * 100).ToString(CultureInfo.InvariantCulture) + "%"; + + /// + /// 现有数据预检:服务器端 countDocuments({ $nor: [ validator ] }) 数全集合不符合的份数 + /// (只读,带超时 —— 大集合上可能要扫一阵);再取最多 200 份不符合的文档,用客户端校验器归纳主要原因。 + /// + private async Task PrecheckGeneratedAsync(BsonDocument validator) + { + IsGenChecking = true; + try + { + (long count, IReadOnlyList<(string Label, int Count)> reasons) = await CountFailuresAsync(validator).ConfigureAwait(true); + if (!ReferenceEquals(validator, _generated)) + { + return; + } + GenFailCount = count; + GenCheckTitle = count == 0 + ? Loc["Design_GenAllPass"] + : Loc.Format("Design_GenFailTitle", BsonText.Grouped(count)); + GenCheckReasons = count == 0 + ? Loc["Design_GenAllPassDetail"] + : (reasons.Count > 0 + ? Loc.Format("Design_GenReasons", string.Join(" · ", reasons.Take(3).Select(static r => $"{r.Label} ({r.Count})"))) + : "") + + Loc["Design_GenAdvice"]; + } + catch (Exception ex) when (IsExpected(ex)) + { + GenFailCount = -1; + GenCheckTitle = Loc.Format("Common_Failed", MongoConnector.Describe(ex)); + GenCheckReasons = ""; + } + finally + { + IsGenChecking = false; + } + } + + /// + /// 全集合不符合份数 + 主要原因。计数超时(大集合)就退回抽样口径。 + /// + internal async Task<(long Count, IReadOnlyList<(string Label, int Count)> Reasons)> CountFailuresAsync(BsonDocument validator) + { + IMongoCollection collection = Workspace.Connection.Collection(Database, CollectionName); + var nor = new BsonDocument("$nor", new BsonArray { validator }); + long count; + try + { + count = await collection.CountDocumentsAsync(nor, new CountOptions { MaxTime = TimeSpan.FromSeconds(15), Comment = "velashell:design-precheck" }, Lifetime) + .ConfigureAwait(true); + } + catch (MongoExecutionTimeoutException) + { + count = _sample.Count(doc => !SampleMatches(validator, doc)); + } + List failing = count == 0 + ? [] + : await collection.Find(nor).Limit(200).ToListAsync(Lifetime).ConfigureAwait(true); + var reasons = new Dictionary(StringComparer.Ordinal); + foreach (BsonDocument doc in failing) + { + foreach (string label in JsonSchemaValidator.Validate(validator, doc, Loc).Select(ReasonLabel).Distinct(StringComparer.Ordinal)) + { + reasons[label] = reasons.GetValueOrDefault(label) + 1; + } + } + return (count, [.. reasons.OrderByDescending(static r => r.Value).Select(static r => (r.Key, r.Value))]); + } + + /// 客户端粗判(只在服务器计数超时时兜底用)。 + private bool SampleMatches(BsonDocument validator, BsonDocument doc) => JsonSchemaValidator.Validate(validator, doc, Loc).Count == 0; + + /// 一条违规 → 原因短语(customer.level 缺失、total 须 ≥ 0)。 + private string ReasonLabel(SchemaViolation violation) => + violation.Keyword == "required" + ? Loc.Format("Design_ReasonMissing", NormalizePath(violation.Path)) + : violation.Message; + + /// 去掉数组下标(items.0.qty → items.qty):同一类原因不该按下标拆成几十条。 + internal static string NormalizePath(string path) => + string.Join(".", path.Split('.').Where(static s => s.Length == 0 || !s.All(char.IsAsciiDigit))); +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignTabViewModel.Stats.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignTabViewModel.Stats.cs new file mode 100644 index 0000000..f06d9f4 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignTabViewModel.Stats.cs @@ -0,0 +1,75 @@ +using System.Collections.ObjectModel; +using System.Globalization; +using VelaShell.Plugin.Mongo.Bson; +using VelaShell.Plugin.Mongo.Core; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 集合设计 · 统计($collStats 的要点 + 各索引大小 + 原文)。 +internal sealed partial class DesignTabViewModel +{ + private CollectionStats? _stats; + private string _statsRaw = ""; + + /// 指标卡。 + public ObservableCollection StatCards { get; } = []; + + /// 各索引大小条。 + public ObservableCollection IndexSizeBars { get; } = []; + + /// 原始 JSON。 + public string StatsRaw + { + get => _statsRaw; + private set => SetProperty(ref _statsRaw, value); + } + + /// 刷新统计。 + public AsyncCommand RefreshStatsCommand { get; private set; } = null!; + + /// 复制原始 JSON。 + public AsyncCommand CopyStatsCommand { get; private set; } = null!; + + private void InitializeStats() + { + RefreshStatsCommand = new(LoadStatsAsync); + CopyStatsCommand = new(async () => + { + await Workspace.CopyAsync(_statsRaw).ConfigureAwait(true); + Workspace.Toast(new() { Title = Loc["Common_Copied"], Kind = ToastKind.Success }); + }); + } + + /// 加载统计页。 + private async Task LoadStatsAsync() + { + try + { + CollectionStats stats = await Workspace.Connection.GetStatsAsync(Database, CollectionName, Lifetime).ConfigureAwait(true); + _stats = stats; + double ratio = stats.StorageSize > 0 ? (double)stats.Size / stats.StorageSize : 0; + bool reclaimable = stats.StorageSize > 0 && stats.FreeStorageSize > stats.StorageSize / 5; + StatCards.Clear(); + StatCards.Add(new(Loc["Design_StatDocs"], BsonText.Grouped(stats.Count), Loc.Format("Design_StatAvg", BsonText.Bytes(stats.AvgObjSize)))); + StatCards.Add(new(Loc["Design_StatData"], BsonText.Bytes(stats.Size), Loc["Design_StatUncompressed"])); + StatCards.Add(new(Loc["Design_StatStorage"], BsonText.Bytes(stats.StorageSize), stats.Engine)); + StatCards.Add(new(Loc["Design_StatRatio"], ratio > 0 ? ratio.ToString("0.0", CultureInfo.InvariantCulture) + "×" : "—", Loc["Design_StatRatioSub"], "VelaShellGreen")); + StatCards.Add(new(Loc["Design_StatIndexes"], BsonText.Bytes(stats.TotalIndexSize), Loc.Format("Design_StatIndexCount", stats.IndexCount))); + StatCards.Add(new(Loc["Design_StatFree"], BsonText.Bytes(stats.FreeStorageSize), + reclaimable ? Loc["Design_StatFreeCompact"] : Loc["Design_StatFreeOk"], reclaimable ? "VelaWarning" : "VelaTextPrimary")); + IndexSizeBars.Clear(); + long total = Math.Max(1, stats.IndexSizes.Values.Sum()); + foreach ((string name, long size) in stats.IndexSizes.OrderByDescending(static p => p.Value)) + { + IndexSizeBars.Add(new(name, BsonText.Bytes(size), (double)size / total)); + } + StatsRaw = BsonText.Pretty(stats.Raw); + _statsLoaded = true; + UpdateStatus(); + } + catch (Exception ex) when (IsExpected(ex)) + { + Fail(ex); + } + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignTabViewModel.Validation.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignTabViewModel.Validation.cs new file mode 100644 index 0000000..710d773 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignTabViewModel.Validation.cs @@ -0,0 +1,1074 @@ +using System.Collections.ObjectModel; +using System.Globalization; +using System.Text.RegularExpressions; +using Avalonia.Threading; +using MongoDB.Bson; +using MongoDB.Driver; +using VelaShell.Plugin.Mongo.Analysis; +using VelaShell.Plugin.Mongo.Bson; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 验证规则的一个历史版本(每次应用前自动存下旧规则)。 +/// 保存时间。 +/// validationLevel。 +/// validationAction。 +/// 规则文本(mongosh 写法)。 +internal sealed record RuleVersion(DateTimeOffset At, string Level, string Action, string Text) +{ + /// 菜单里的一行:2026-09-26 21:08 · moderate · warn。 + public string Label => $"{At.ToLocalTime():yyyy-MM-dd HH:mm} · {Level} · {Action}"; +} + +/// 集合设计 · 验证规则(设计稿 16)。 +internal sealed partial class DesignTabViewModel +{ + /// 编辑器里规则行的最大宽度(超过才折行)。 + private const int RuleWidth = 88; + + /// 规则测试的抽样数。 + public const int PrecheckSampleSize = 1000; + + private static readonly string[] BsonTypeAliases = + [ + "string", "int", "long", "double", "decimal", "number", "bool", "date", "objectId", "object", "array", "null", + "binData", "regex", "timestamp" + ]; + + private static readonly string[] SchemaKeywords = + [ + "bsonType", "required", "properties", "enum", "minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf", + "minLength", "maxLength", "pattern", "items", "minItems", "maxItems", "uniqueItems", "additionalProperties", + "patternProperties", "minProperties", "maxProperties", "description", "title", "anyOf", "allOf", "oneOf", "not" + ]; + + private bool _editorReady; + private bool _validationLoading; + private string _ruleText = ""; + private string _appliedText = ""; + private string _ruleLevel = "strict"; + private string _ruleAction = "error"; + private string _appliedLevel = "strict"; + private string _appliedAction = "error"; + private BsonDocument? _ruleValidator; + private IReadOnlyList _ruleDiagnostics = []; + private bool _ruleSyntaxOk = true; + private string _ruleSyntaxText = ""; + private bool _isPrechecking; + private bool _precheckRan; + private bool _precheckAgain; + private bool _precheckResample; + private List _precheckIds = []; + private long _passCount; + private long _failCount = -1; + private IReadOnlyList _passPieces = []; + private string _tryDocText = ""; + private IReadOnlyList _tryDiagnostics = []; + private IReadOnlyList _tryMarks = []; + private bool? _tryPassed; + private string _tryTitle = ""; + private string _tryDetail = ""; + private DispatcherTimer? _tryTimer; + private DispatcherTimer? _ruleTimer; + private int _tryVersion; + + /// 编辑器里的规则文本(mongosh 写法)。 + public string RuleText + { + get => _ruleText; + set + { + if (SetProperty(ref _ruleText, value ?? "")) + { + ParseRule(); + RecomputeRuleState(); + Restart(ref _ruleTimer, TimeSpan.FromMilliseconds(1200), () => _ = RunPrecheckAsync(resample: false)); + Restart(ref _tryTimer, TimeSpan.FromMilliseconds(350), () => _ = EvaluateTryDocAsync()); + } + } + } + + /// validationLevel(off / moderate / strict)。 + public string RuleLevel + { + get => _ruleLevel; + set + { + if (SetProperty(ref _ruleLevel, value ?? "strict")) + { + RecomputeRuleState(); + } + } + } + + /// validationAction(warn / error)。 + public string RuleAction + { + get => _ruleAction; + set + { + if (SetProperty(ref _ruleAction, value ?? "error")) + { + RecomputeRuleState(); + Restart(ref _tryTimer, TimeSpan.FromMilliseconds(50), () => _ = EvaluateTryDocAsync()); + } + } + } + + /// 编辑器诊断(语法错误)。 + public IReadOnlyList RuleDiagnostics + { + get => _ruleDiagnostics; + private set => SetProperty(ref _ruleDiagnostics, value); + } + + /// 语法正确。 + public bool RuleSyntaxOk + { + get => _ruleSyntaxOk; + private set + { + if (SetProperty(ref _ruleSyntaxOk, value)) + { + RaisePropertyChanged(nameof(RuleSyntaxBad)); + ApplyRuleCommand.RaiseCanExecuteChanged(); + } + } + } + + /// 语法有误。 + public bool RuleSyntaxBad => !_ruleSyntaxOk; + + /// 工具行右侧的语法状态文字。 + public string RuleSyntaxText + { + get => _ruleSyntaxText; + private set => SetProperty(ref _ruleSyntaxText, value); + } + + /// 底部命令预览。 + public string RuleCommand => + $"db.runCommand({{ collMod: {QuotedCollection}, validator: {{…}}, validationLevel: {BsonText.Quote(_ruleLevel)}, validationAction: {BsonText.Quote(_ruleAction)} }})"; + + /// 子页条上验证规则旁的小字。 + public string ValidationTabHint => _live.Validator switch + { + null => Loc["Design_NoRule"], + { } v when v.Contains("$jsonSchema") => "$jsonSchema", + _ => Loc["Design_QueryRule"] + }; + + /// 能设验证规则(视图与时序集合不行)。 + public bool CanValidate => !IsView && !IsTimeSeries; + + /// 规则测试:正在预检。 + public bool IsPrechecking + { + get => _isPrechecking; + private set => SetProperty(ref _isPrechecking, value); + } + + /// 预检抽了多少份(抽样 1,000)。 + public string PrecheckSampleText => Loc.Format("Design_PrecheckSample", BsonText.Grouped(_precheckIds.Count > 0 ? _precheckIds.Count : PrecheckSampleSize)); + + /// 通过份数文字。 + public string PassText => Loc.Format("Design_Pass", BsonText.Grouped(_passCount)); + + /// 不通过份数文字。 + public string FailText => Loc.Format("Design_Fail", BsonText.Grouped(Math.Max(0, _failCount))); + + /// 通过 / 不通过比例条。 + public IReadOnlyList PassPieces + { + get => _passPieces; + private set => SetProperty(ref _passPieces, value); + } + + /// 不通过原因(按 关键字 + 路径 分组)。 + public ObservableCollection FailureReasons { get; } = []; + + /// 有不通过原因。 + public bool HasFailureReasons => FailureReasons.Count > 0; + + /// 试写文档(mongosh 写法)。 + public string TryDocText + { + get => _tryDocText; + set + { + if (SetProperty(ref _tryDocText, value ?? "")) + { + Restart(ref _tryTimer, TimeSpan.FromMilliseconds(350), () => _ = EvaluateTryDocAsync()); + } + } + } + + /// 试写文档的诊断(违规的值下面画波浪线)。 + public IReadOnlyList TryDiagnostics + { + get => _tryDiagnostics; + private set => SetProperty(ref _tryDiagnostics, value); + } + + /// 试写文档的整行标记(违规的行红底)。 + public IReadOnlyList TryMarks + { + get => _tryMarks; + private set => SetProperty(ref _tryMarks, value); + } + + /// 试写结果:通过 / 不通过 / 未知(语法错误)。 + public bool? TryPassed + { + get => _tryPassed; + private set + { + if (SetProperty(ref _tryPassed, value)) + { + RaisePropertiesChanged(nameof(TryOk), nameof(TryFailed), nameof(TryUnknown)); + } + } + } + + /// 通过(绿框)。 + public bool TryOk => _tryPassed == true; + + /// 不通过(红框)。 + public bool TryFailed => _tryPassed == false; + + /// 没法判断(灰框)。 + public bool TryUnknown => _tryPassed is null && _tryTitle.Length > 0; + + /// 结果框标题。 + public string TryTitle + { + get => _tryTitle; + private set + { + if (SetProperty(ref _tryTitle, value)) + { + RaisePropertyChanged(nameof(TryUnknown)); + } + } + } + + /// 结果框明细(每条违规一行)。 + public string TryDetail + { + get => _tryDetail; + private set + { + if (SetProperty(ref _tryDetail, value)) + { + RaisePropertyChanged(nameof(HasTryDetail)); + } + } + } + + /// 有明细。 + public bool HasTryDetail => _tryDetail.Length > 0; + + /// 历史版本(新的在前)。 + public ObservableCollection RuleHistory { get; } = []; + + /// 规则编辑器的补全($jsonSchema 关键字、bsonType 别名、抽样字段名)。 + public Func> RuleCompletion => CompleteRuleAsync; + + /// 选 validationLevel。 + public RelayCommand SetLevelCommand { get; private set; } = null!; + + /// 选 validationAction。 + public RelayCommand SetActionCommand { get; private set; } = null!; + + /// 从 Schema 分析生成。 + public AsyncCommand GenerateRuleCommand { get; private set; } = null!; + + /// 格式化。 + public RelayCommand FormatRuleCommand { get; private set; } = null!; + + /// 恢复一个历史版本(进编辑器,不直接应用)。 + public RelayCommand RestoreVersionCommand { get; private set; } = null!; + + /// 放弃修改。 + public RelayCommand DiscardRuleCommand { get; private set; } = null!; + + /// 应用规则。 + public AsyncCommand ApplyRuleCommand { get; private set; } = null!; + + /// 重新抽样预检。 + public AsyncCommand RefreshPrecheckCommand { get; private set; } = null!; + + /// 在网格中查看某类不通过的文档。 + public RelayCommand OpenFailureCommand { get; private set; } = null!; + + private void InitializeValidation() + { + SetLevelCommand = new(level => RuleLevel = level); + SetActionCommand = new(action => RuleAction = action); + GenerateRuleCommand = new(async () => + { + if (_generated is null) + { + await AnalyzeAsync().ConfigureAwait(true); + } + if (_generated is not null) + { + LoadRuleIntoEditor(_generated, _genLevel, _genAction); + } + }); + FormatRuleCommand = new(() => + { + if (_ruleValidator is not null) + { + RuleText = JsonSchemaGenerator.Format(_ruleValidator, RuleWidth); + } + }); + RestoreVersionCommand = new(version => + { + RuleText = version.Text; + RuleLevel = version.Level; + RuleAction = version.Action; + }); + DiscardRuleCommand = new(() => + { + RuleText = _appliedText; + RuleLevel = _appliedLevel; + RuleAction = _appliedAction; + }); + ApplyRuleCommand = new(() => _ruleValidator is null + ? Task.CompletedTask + : ApplyValidatorAsync(_ruleValidator, _ruleLevel, _ruleAction, _failCount), () => _ruleSyntaxOk); + RefreshPrecheckCommand = new(() => RunPrecheckAsync(resample: true)); + OpenFailureCommand = new(row => Workspace.OpenCollection(Database, CollectionName, row.Filter)); + FailureReasons.CollectionChanged += (_, _) => RaisePropertyChanged(nameof(HasFailureReasons)); + } + + /// 没有验证规则时编辑器里的起手式。 + /// 不写 required: []:服务器不接受空的 required 数组,起手式本身就得是一条能用的规则。 + private static string RuleTemplate => + "{\n $jsonSchema: {\n bsonType: \"object\",\n properties: {}\n }\n}"; + + /// + /// 加载验证规则页:编辑器换成当前生效的规则,读历史版本,跑一次抽样预检,备一份试写文档。 + /// + private async Task LoadValidationAsync(bool resetEditor) + { + if (_validationLoading) + { + return; + } + _validationLoading = true; + try + { + if (resetEditor) + { + InitializeEditorFromLive(); + } + await LoadHistoryAsync().ConfigureAwait(true); + await RunPrecheckAsync(resample: !_precheckRan).ConfigureAwait(true); + if (_tryDocText.Length == 0) + { + await SeedTryDocAsync().ConfigureAwait(true); + } + _validationLoaded = true; + } + finally + { + _validationLoading = false; + } + } + + /// 编辑器 ← 当前生效的规则(重探过的集合信息)。 + private void InitializeEditorFromLive() + { + BsonDocument? validator = _live.Validator; + _appliedLevel = _live.ValidationLevel; + _appliedAction = _live.ValidationAction; + _appliedText = validator is null ? RuleTemplate : JsonSchemaGenerator.Format(validator, RuleWidth); + _editorReady = true; + RuleText = _appliedText; + RuleLevel = _appliedLevel; + RuleAction = _appliedAction; + RecomputeRuleState(); + RaisePropertyChanged(nameof(ValidationTabHint)); + // 载入不算改动:不必为它再排一次预检(调用方会自己跑)。 + _ruleTimer?.Stop(); + } + + /// 把一条规则放进编辑器(从 Schema 分析生成 / 在编辑器中调整 / 恢复历史版本)。不应用。 + internal void LoadRuleIntoEditor(BsonDocument validator, string level, string action) + { + if (!_editorReady) + { + InitializeEditorFromLive(); + } + RuleText = JsonSchemaGenerator.Format(validator, RuleWidth); + RuleLevel = level; + RuleAction = action; + } + + /// 解析编辑器文本 → validator + 诊断。 + private void ParseRule() + { + var diagnostics = new List(); + foreach (ShellDiagnostic d in ShellJson.Diagnose(_ruleText)) + { + diagnostics.Add(new(d.Offset, Math.Max(1, d.Length), Loc.Format(d.MessageKey, d.Argument), DiagnosticSeverity.Error, d.Fix)); + } + BsonDocument? parsed = null; + string error = ""; + if (diagnostics.Count == 0) + { + try + { + parsed = ShellJson.ParseDocument(_ruleText); + } + catch (ShellJsonException ex) + { + error = ex.Message; + int offset = ex.Offset >= 0 ? Math.Min(ex.Offset, Math.Max(0, _ruleText.Length - 1)) : 0; + diagnostics.Add(new(offset, 1, ex.Message)); + } + } + else + { + error = diagnostics[0].Message; + } + _ruleValidator = parsed; + RuleDiagnostics = diagnostics; + RuleSyntaxOk = parsed is not null; + RuleSyntaxText = parsed is not null ? Loc["Design_SyntaxOk"] : Loc.Format("Design_SyntaxError", error); + } + + /// 改动标记、命令预览、状态栏。 + private void RecomputeRuleState() + { + IsModified = _editorReady + && (!string.Equals(_ruleText.Trim(), _appliedText.Trim(), StringComparison.Ordinal) + || _ruleLevel != _appliedLevel || _ruleAction != _appliedAction); + RaisePropertyChanged(nameof(RuleCommand)); + UpdateStatus(); + } + + /// + /// 规则测试 · 对现有文档预检。 + /// + /// 抽样只抽一次 _id($sample + 只投影 _id),之后每次改规则都用 + /// { _id: { $in: 抽样 }, $nor: [ 规则 ] } 在服务器上判 —— 同一批文档反复判, + /// 改一个关键字前后的计数才可比;判定以服务器为准,客户端校验器只负责归纳"为什么"。 + /// + /// + internal async Task RunPrecheckAsync(bool resample) + { + if (!_editorReady || _disposed || !CanValidate) + { + return; + } + if (_isPrechecking) + { + // 正在判上一版规则:记一笔,判完马上用最新的规则再判一次(而不是丢掉这次改动)。 + _precheckAgain = true; + _precheckResample |= resample; + return; + } + BsonDocument? validator = _ruleValidator; + if (validator is null) + { + return; + } + IsPrechecking = true; + try + { + IMongoCollection collection = Workspace.Connection.Collection(Database, CollectionName); + if (resample || _precheckIds.Count == 0) + { + var pipeline = new[] + { + new BsonDocument("$sample", new BsonDocument("size", PrecheckSampleSize)), + new BsonDocument("$project", new BsonDocument("_id", 1)) + }; + using IAsyncCursor cursor = await collection.AggregateAsync(pipeline, cancellationToken: Lifetime).ConfigureAwait(true); + _precheckIds = [.. (await cursor.ToListAsync(Lifetime).ConfigureAwait(true)).Select(static d => d["_id"])]; + RaisePropertyChanged(nameof(PrecheckSampleText)); + } + _precheckRan = true; + if (_precheckIds.Count == 0) + { + SetPrecheck(0, 0, []); + return; + } + List failing = []; + if (validator.ElementCount > 0) + { + var filter = new BsonDocument + { + { "_id", new BsonDocument("$in", new BsonArray(_precheckIds)) }, + { "$nor", new BsonArray { validator } } + }; + failing = await collection.Find(filter).Limit(PrecheckSampleSize).ToListAsync(Lifetime).ConfigureAwait(true); + } + if (!ReferenceEquals(validator, _ruleValidator)) + { + // 判的途中规则又改了:这份结果作废,按最新规则再判一次。 + _precheckAgain = true; + return; + } + SetPrecheck(_precheckIds.Count - failing.Count, failing.Count, GroupFailures(validator, failing)); + } + catch (Exception ex) when (IsExpected(ex)) + { + Fail(ex); + } + finally + { + IsPrechecking = false; + if (_precheckAgain && !_disposed) + { + bool again = _precheckResample; + _precheckAgain = false; + _precheckResample = false; + _ = RunPrecheckAsync(again); + } + } + } + + private void SetPrecheck(long pass, long fail, IReadOnlyList reasons) + { + _passCount = pass; + _failCount = fail; + long total = pass + fail; + PassPieces = total == 0 + ? [] + : [new BarPiece((double)pass / total, "VelaStatusConnected"), new BarPiece((double)fail / total, "VelaError")]; + FailureReasons.Clear(); + foreach (FailureReasonRow row in reasons) + { + FailureReasons.Add(row); + } + RaisePropertiesChanged(nameof(PassText), nameof(FailText)); + } + + /// + /// 不通过的文档 → 按(路径, 关键字)分组,每组记份数、一个示例 _id、在网格中查看用的筛选。 + /// 客户端校验器说不出原因的(服务器支持而它不认的关键字)单独归一组。 + /// + private IReadOnlyList GroupFailures(BsonDocument validator, IReadOnlyList failing) + { + var groups = new Dictionary<(string Path, string Keyword), List>(); + var order = new List<(string, string)>(); + foreach (BsonDocument doc in failing) + { + BsonValue id = doc.GetValue("_id", BsonNull.Value); + var keys = JsonSchemaValidator.Validate(validator, doc, Loc) + .Select(static v => (NormalizePath(v.Path), v.Keyword)) + .Distinct() + .ToList(); + if (keys.Count == 0) + { + keys.Add(("", "")); + } + foreach ((string, string) key in keys) + { + if (!groups.TryGetValue(key, out List? ids)) + { + ids = []; + groups[key] = ids; + order.Add(key); + } + ids.Add(id); + } + } + return + [ + .. order + .OrderByDescending(k => groups[k].Count) + .Select(k => + { + List ids = groups[k]; + BsonValue first = ids[0]; + string example = first.IsObjectId ? BsonText.Shorten(first.AsObjectId.ToString()) : BsonText.Inline(first); + BsonDocument filter = ids.Count <= 200 + ? new BsonDocument("_id", new BsonDocument("$in", new BsonArray(ids))) + : new BsonDocument("$nor", new BsonArray { validator }); + return new FailureReasonRow + { + Path = k.Item1.Length == 0 ? Loc["Design_ReasonServer"] : k.Item1, + Keyword = KeywordSummary(validator, k.Item1, k.Item2), + RawKeyword = k.Item2, + Count = ids.Count, + CountText = Loc.Format("Design_Copies", BsonText.Grouped(ids.Count)), + Example = Loc.Format("Design_Example", example), + Filter = BsonText.Literal(filter) + }; + }) + ]; + } + + /// + /// 关键字摘要:required;带取值的关键字把规则里的值也写上(minimum: 0)。 + /// + private static string KeywordSummary(BsonDocument validator, string path, string keyword) + { + if (keyword.Length == 0) + { + return "$jsonSchema"; + } + if (keyword == "required" || SchemaAt(validator, path) is not { } schema || !schema.TryGetValue(keyword, out BsonValue value)) + { + return keyword; + } + string text = BsonText.Literal(value); + return text.Length <= 24 ? $"{keyword}: {text}" : keyword; + } + + /// 沿 properties(数组则经 items)走到某个路径的子 schema。 + internal static BsonDocument? SchemaAt(BsonDocument validator, string path) + { + BsonDocument? schema = validator.GetValue("$jsonSchema", BsonNull.Value) as BsonDocument ?? validator; + foreach (string segment in path.Split('.', StringSplitOptions.RemoveEmptyEntries)) + { + if (schema?.GetValue("items", BsonNull.Value) is BsonDocument items && !schema.Contains("properties")) + { + schema = items; + } + schema = (schema?.GetValue("properties", BsonNull.Value) as BsonDocument)?.GetValue(segment, BsonNull.Value) as BsonDocument; + if (schema is null) + { + return null; + } + } + return schema; + } + + /// 试写文档起手:抽样里的一份文档(去掉 _id,试写的是"新插入")。 + private async Task SeedTryDocAsync() + { + BsonDocument? doc = _sample.FirstOrDefault(); + if (doc is null) + { + try + { + doc = await Workspace.Connection.Collection(Database, CollectionName) + .Find(FilterDefinition.Empty).Limit(1).FirstOrDefaultAsync(Lifetime).ConfigureAwait(true); + } + catch (Exception ex) when (IsExpected(ex)) + { + doc = null; + } + } + doc = doc?.DeepClone().AsBsonDocument ?? []; + doc.Remove("_id"); + TryDocText = BsonText.Pretty(doc); + } + + /// + /// 试写文档的实时校验:客户端校验器给出逐条原因(并在编辑器里标出那一行); + /// 若服务器支持 $documents(5.1+),再让服务器判一次 —— 结论以服务器为准,不写入任何东西。 + /// + internal async Task EvaluateTryDocAsync() + { + if (_disposed) + { + return; + } + int version = ++_tryVersion; + if (_tryDocText.Trim().Length == 0) + { + TryPassed = null; + TryTitle = ""; + TryDetail = ""; + TryDiagnostics = []; + TryMarks = []; + return; + } + if (!ShellJson.TryParseDocument(_tryDocText, out BsonDocument doc, out string? error)) + { + TryPassed = null; + TryTitle = Loc.Format("Design_SyntaxError", error); + TryDetail = ""; + TryDiagnostics = [.. ShellJson.Diagnose(_tryDocText).Select(d => new EditorDiagnostic(d.Offset, Math.Max(1, d.Length), Loc.Format(d.MessageKey, d.Argument)))]; + TryMarks = []; + return; + } + if (_ruleValidator is not { } validator) + { + TryPassed = null; + TryTitle = Loc["Design_TryRuleInvalid"]; + TryDetail = ""; + return; + } + IReadOnlyList violations = JsonSchemaValidator.Validate(validator, doc, Loc); + bool? serverFailed = await ServerRejectsAsync(validator, doc).ConfigureAwait(true); + if (version != _tryVersion) + { + return; + } + bool failed = serverFailed ?? violations.Count > 0; + TryPassed = !failed; + TryTitle = failed + ? Loc.Format("Design_TryFail", _ruleAction, Loc[_ruleAction == "warn" ? "Design_TryFailWarn" : "Design_TryFailError"]) + : Loc["Design_TryPass"]; + TryDetail = !failed + ? "" + : violations.Count > 0 + ? string.Join("\n", violations.Take(6).Select(v => $"{ViolationPath(v)}:{v.Message}")) + : Loc["Design_TryServerOnly"]; + (TryMarks, TryDiagnostics) = failed ? MarkViolations(_tryDocText, violations) : ([], []); + } + + /// properties.total.minimum(设计稿 16 结果框里的写法)。 + private static string ViolationPath(SchemaViolation v) + { + string path = NormalizePath(v.Path); + if (v.Keyword == "required") + { + int dot = path.LastIndexOf('.'); + string parent = dot < 0 ? "" : string.Concat(path[..dot].Split('.').Select(static s => $"properties.{s}.")); + return $"{parent}required"; + } + return path.Length == 0 + ? v.Keyword + : string.Concat(path.Split('.').Select(static s => $"properties.{s}.")) + v.Keyword; + } + + /// 让服务器判一次($documents + $match: { $nor: [规则] });不支持返回 。 + private async Task ServerRejectsAsync(BsonDocument validator, BsonDocument doc) + { + if (validator.ElementCount == 0) + { + return false; + } + try + { + BsonDocument reply = await Workspace.Connection.RunCommandAsync(Database, new BsonDocument + { + { "aggregate", 1 }, + { + "pipeline", new BsonArray + { + new BsonDocument("$documents", new BsonArray { doc }), + new BsonDocument("$match", new BsonDocument("$nor", new BsonArray { validator })), + new BsonDocument("$count", "n") + } + }, + { "cursor", new BsonDocument() } + }, Lifetime).ConfigureAwait(true); + return reply["cursor"]["firstBatch"].AsBsonArray.Count > 0; + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + return null; + } + } + + /// + /// 违规 → 编辑器标记:按路径最后一段找到 key: 所在的行,整行红底,值下面画波浪线。 + /// 缺失字段(required)在文本里找不到对应的行,不标。 + /// + internal static (IReadOnlyList, IReadOnlyList) MarkViolations(string text, IReadOnlyList violations) + { + var marks = new List(); + var diagnostics = new List(); + foreach (SchemaViolation v in violations) + { + if (v.Keyword == "required") + { + continue; + } + string[] segments = NormalizePath(v.Path).Split('.', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length == 0) + { + continue; + } + int from = 0; + Match match = Match.Empty; + foreach (string segment in segments) + { + match = Regex.Match(text[from..], $"(? valueStart && (char.IsWhiteSpace(text[valueEnd - 1]) || text[valueEnd - 1] == ',')) + { + valueEnd--; + } + int line = text[..valueStart].Count(static c => c == '\n') + 1; + if (marks.All(m => m.Line != line)) + { + marks.Add(new LineMark(line, LineMarkKind.Removed)); + } + diagnostics.Add(new EditorDiagnostic(valueStart, Math.Max(1, valueEnd - valueStart), v.Message)); + } + return (marks, diagnostics); + } + + /// 读历史版本。 + private async Task LoadHistoryAsync() + { + SavedItem[] items = await Workspace.Store.GetAsync(HistoryKey).ConfigureAwait(true) ?? []; + RuleHistory.Clear(); + foreach (SavedItem item in items) + { + string[] parts = item.Name.Split('|'); + RuleHistory.Add(new RuleVersion(item.SavedAt, parts.ElementAtOrDefault(0) ?? "strict", parts.ElementAtOrDefault(1) ?? "error", item.Content)); + } + } + + private string HistoryKey => $"design-validator-history:{Workspace.ConnectionKey}:{Namespace}"; + + /// + /// 应用一条验证规则(collMod)。写护栏 → 确认(写清 level / action 与已知的不符合份数)→ + /// 先把旧规则存进历史 → collMod → 重探集合信息、重置编辑器、重新预检。 + /// + internal async Task ApplyValidatorAsync(BsonDocument validator, string level, string action, long knownFailures) + { + if (!CanValidate) + { + Workspace.Toast(new() { Title = Loc["Design_RuleUnsupported"], Kind = ToastKind.Warning }); + return; + } + if (!Workspace.EnsureWritable(Database)) + { + return; + } + var facts = new List + { + new("validationLevel", level), + new("validationAction", action) + }; + if (knownFailures > 0) + { + facts.Add(new(Loc["Design_PrecheckFailures"], BsonText.Grouped(knownFailures))); + } + bool confirmed = await Workspace.ConfirmAsync(new() + { + Title = Loc["Design_ApplyTitle"], + Message = Loc.Format("Design_ApplyBody", Namespace, level, action) + + (knownFailures > 0 ? " " + Loc.Format(action == "error" ? "Design_ApplyRejects" : "Design_ApplyWarns", BsonText.Grouped(knownFailures)) : ""), + ConfirmLabel = Loc["Design_ApplyRule"], + IconKey = "Mongo.shield-check", + Danger = action == "error" && level != "off" && knownFailures > 0, + Facts = facts, + TypeToConfirm = Workspace.Guard.ConfirmWrites ? CollectionName : null + }).ConfigureAwait(true); + if (!confirmed) + { + return; + } + try + { + await SaveHistoryAsync().ConfigureAwait(true); + await Workspace.Connection.RunCommandAsync(Database, new BsonDocument + { + { "collMod", CollectionName }, + { "validator", validator }, + { "validationLevel", level }, + { "validationAction", action } + }, Lifetime).ConfigureAwait(true); + await RefreshInfoAsync().ConfigureAwait(true); + InitializeEditorFromLive(); + await LoadHistoryAsync().ConfigureAwait(true); + Workspace.Toast(new() { Title = Loc["Design_Applied"], Detail = $"validationLevel {level} · validationAction {action}", Kind = ToastKind.Success }); + await RunPrecheckAsync(resample: false).ConfigureAwait(true); + } + catch (Exception ex) when (IsExpected(ex)) + { + Fail(ex); + } + } + + /// 把当前生效的规则存进历史(最多 20 版)。 + private async Task SaveHistoryAsync() + { + BsonDocument? current = _live.Validator; + if (current is null) + { + return; + } + SavedItem[] existing = await Workspace.Store.GetAsync(HistoryKey).ConfigureAwait(true) ?? []; + var item = new SavedItem($"{_live.ValidationLevel}|{_live.ValidationAction}", JsonSchemaGenerator.Format(current, RuleWidth), DateTimeOffset.Now); + SavedItem[] next = [item, .. existing.Take(19)]; + await Workspace.Store.SetAsync(HistoryKey, next).ConfigureAwait(true); + } + + /// Schema 分析刚完成:试写文档还空着就用抽样起头。 + private void OnSchemaForValidation() + { + if (_validationLoaded && _tryDocText.Length == 0) + { + _ = SeedTryDocAsync(); + } + } + + /// + /// 规则编辑器的补全:bsonType 后面给类型别名,$ 开头给 $jsonSchema 等, + /// 其余给 $jsonSchema 关键字与抽样到的字段名。 + /// + private Task CompleteRuleAsync(CompletionRequest request) + { + string text = request.Text; + int caret = Math.Clamp(request.CaretOffset, 0, text.Length); + int start = caret; + while (start > 0 && (char.IsLetterOrDigit(text[start - 1]) || text[start - 1] is '_' or '$' or '.')) + { + start--; + } + string prefix = text[start..caret]; + if (!request.Explicit && prefix.Length == 0) + { + return Task.FromResult(null); + } + var items = new List(); + string before = text[..start]; + bool quoted = start > 0 && text[start - 1] is '"' or '\''; + if (Regex.IsMatch(before, "bsonType\\s*:\\s*(\\[[^\\]]*)?[\"']?$")) + { + items.AddRange(BsonTypeAliases.Select(alias => new CompletionItem + { + Label = alias, + InsertText = quoted ? alias : $"\"{alias}\"", + IconKey = "Mongo.type", + IconToken = "VelaShellCyan", + Category = "bsonType" + })); + } + else if (prefix.StartsWith('$')) + { + items.Add(new CompletionItem + { + Label = "$jsonSchema", + InsertText = "$jsonSchema: {\n bsonType: \"object\",\n |\n}", + IconKey = "Mongo.shield-check", + IconToken = "VelaShellMagenta", + Category = Loc["Design_CompletionOperator"], + Description = Loc["Design_Kw_jsonSchema"], + DocsUrl = "https://www.mongodb.com/docs/manual/reference/operator/query/jsonSchema/" + }); + foreach (string op in new[] { "$and", "$or", "$nor", "$expr" }) + { + items.Add(new CompletionItem { Label = op, InsertText = $"{op}: [ | ]", IconKey = "Mongo.braces", Category = Loc["Design_CompletionOperator"] }); + } + } + else + { + items.AddRange(SchemaKeywords.Select(keyword => new CompletionItem + { + Label = keyword, + InsertText = keyword switch + { + "properties" or "patternProperties" => $"{keyword}: {{\n |\n}}", + "required" or "enum" or "anyOf" or "allOf" or "oneOf" => $"{keyword}: [ | ]", + "items" or "not" => $"{keyword}: {{ | }}", + "bsonType" or "description" or "title" or "pattern" => $"{keyword}: \"|\"", + _ => $"{keyword}: |" + }, + IconKey = "Mongo.shield-check", + IconToken = "VelaShellMagenta", + Category = Loc["Design_CompletionKeyword"], + Badge = "$jsonSchema", + Description = Loc["Design_Kw_" + keyword], + DocsUrl = "https://www.mongodb.com/docs/manual/reference/operator/query/jsonSchema/#available-keywords" + })); + var names = new HashSet(StringComparer.Ordinal); + foreach (FieldOption field in _fieldOptions) + { + string name = field.Path[(field.Path.LastIndexOf('.') + 1)..]; + if (names.Add(name)) + { + items.Add(new CompletionItem + { + Label = name, + InsertText = $"{BsonText.FieldName(name)}: {{ bsonType: \"{JsonSchemaGenerator.BsonTypeName(field.Kind) ?? "string"}\"| }}", + IconKey = "Mongo.variable", + IconToken = field.Token, + Category = Loc.Format("Design_CompletionField", field.KindName) + }); + } + } + } + List matched = prefix.Length == 0 + ? items + : [.. items.Where(i => i.Label.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + .Concat(items.Where(i => !i.Label.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + && i.Label.Contains(prefix, StringComparison.OrdinalIgnoreCase)))]; + if (matched.Count == 0) + { + return Task.FromResult(null); + } + return Task.FromResult(new CompletionSet + { + Items = matched, + ReplaceOffset = start, + ReplaceLength = prefix.Length + }); + } + + /// + /// 规则编辑器里悬停在一个 $jsonSchema 关键字上时的说明(设计稿 16 的悬停卡): + /// 关键字分类 + 一句释义;抽样预检里正好有文档违反这个关键字时,再补一句"有几份、在当前 + /// validationLevel / validationAction 下会怎样"。不是关键字返回 。 + /// + internal (string Title, string Badge, string Body)? KeywordHover(string word) + { + if (word != "$jsonSchema" && !SchemaKeywords.Contains(word, StringComparer.Ordinal)) + { + return null; + } + string group = word switch + { + "minimum" or "maximum" or "exclusiveMinimum" or "exclusiveMaximum" or "multipleOf" => "numeric", + "minLength" or "maxLength" or "pattern" => "string", + "items" or "minItems" or "maxItems" or "uniqueItems" => "array", + "required" or "properties" or "additionalProperties" or "patternProperties" or "minProperties" or "maxProperties" => "object", + "bsonType" or "enum" => "type", + "anyOf" or "allOf" or "oneOf" or "not" => "logic", + "$jsonSchema" => "operator", + _ => "meta" + }; + string body = Loc["Design_Kw_" + word.TrimStart('$')]; + var failing = FailureReasons.Where(r => r.RawKeyword == word).ToList(); + if (failing.Count > 0) + { + string consequence = _ruleLevel == "off" + ? Loc["Design_HoverOff"] + : _ruleAction == "warn" + ? Loc.Format("Design_HoverWarn", _ruleLevel) + : Loc[_ruleLevel == "moderate" ? "Design_HoverErrorModerate" : "Design_HoverErrorStrict"]; + body += Loc.Format("Design_HoverFailures", BsonText.Grouped(failing.Sum(static r => r.Count)), + string.Join(", ", failing.Select(static r => r.Path)), consequence); + } + return (word, Loc["Design_KwGroup_" + group], body); + } + + /// 防抖:重启一个一次性计时器。 + private static void Restart(ref DispatcherTimer? timer, TimeSpan delay, Action action) + { + timer?.Stop(); + var t = new DispatcherTimer { Interval = delay }; + t.Tick += (_, _) => + { + t.Stop(); + action(); + }; + timer = t; + t.Start(); + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignTabViewModel.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignTabViewModel.cs new file mode 100644 index 0000000..45b3b5b --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignTabViewModel.cs @@ -0,0 +1,291 @@ +using System.Globalization; +using MongoDB.Bson; +using MongoDB.Driver; +using VelaShell.Plugin.Mongo.Bson; +using VelaShell.Plugin.Mongo.Core; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 集合设计(设计稿 07 索引与建议 / 08 Schema 分析 / 16 验证规则,外加选项与统计两页)。 +/// +/// 五个子页共用一个标签、一份视图模型:它们说的是同一个集合,彼此要递东西 —— +/// Schema 分析生成的规则要送进验证规则页,抽样到的字段要进新建索引的字段下拉与规则编辑器的补全, +/// 索引统计要进状态栏。拆成五个视图模型就得在它们之间再架一层消息,不值得。 +/// 各子页的成员按页拆在 DesignTabViewModel.*.cs 分部文件里。 +/// +/// +/// 子页**按需加载**:打开时只加载当前页(以及子页条上要显示计数的索引列表), +/// 其余页第一次切过去时再加载 —— Schema 抽样、全集合预检都不便宜,用户可能根本不看。 +/// +/// +internal sealed partial class DesignTabViewModel : WorkspaceTab +{ + private readonly CancellationTokenSource _life = new(); + private DesignPage _page; + private CollectionInfo _live; + private bool _indexesLoaded; + private bool _validationLoaded; + private bool _optionsLoaded; + private bool _statsLoaded; + private bool _disposed; + + /// 构造。 + /// 外壳服务。 + /// 集合信息(对象树里那份;可能是占位,加载时会重新探)。 + /// 初始子页。 + public DesignTabViewModel(IMongoWorkspace workspace, CollectionInfo info, DesignPage page) + : base(workspace) + { + Info = info; + _live = info; + _page = page; + Title = Loc.Format("Design_Title", info.Name); + Scope = "@" + info.Database; + _sampleSize = workspace.Connection.Settings.SampleSize is 100 or 1000 or 5000 ? workspace.Connection.Settings.SampleSize : 1000; + InitializeIndexes(); + InitializeNewIndex(); + InitializeSchema(); + InitializeValidation(); + InitializeOptions(); + InitializeStats(); + UpdateStatus(); + } + + /// 打开时的集合信息。 + public CollectionInfo Info { get; } + + /// 重新探过的集合信息(验证规则、选项以它为准)。 + public CollectionInfo Live + { + get => _live; + private set + { + if (SetProperty(ref _live, value)) + { + RaisePropertiesChanged(nameof(IsView), nameof(IsTimeSeries), nameof(IsCapped), nameof(ValidationTabHint), nameof(CanValidate)); + } + } + } + + /// 库名。 + public string Database => Info.Database; + + /// 集合名。 + public string CollectionName => Info.Name; + + /// 命名空间。 + public string Namespace => Info.Namespace; + + /// 当前子页。 + public DesignPage Page + { + get => _page; + set + { + if (SetProperty(ref _page, value)) + { + (Workspace as MongoSession)?.Shell.OnTabToolChanged(); + } + RaisePropertyChanged(nameof(PageName)); + UpdateStatus(); + _ = EnsurePageAsync(force: false); + } + } + + /// 子页名(子页条的单选绑它:IsEqual 转换器两头都是字符串)。 + public string PageName + { + get => _page.ToString(); + set + { + if (Enum.TryParse(value, out DesignPage page)) + { + Page = page; + } + } + } + + /// 视图(没有索引、不能设验证规则)。 + public bool IsView => _live.Kind == CollectionKind.View; + + /// 时序集合。 + public bool IsTimeSeries => _live.Kind == CollectionKind.TimeSeries; + + /// 固定集合。 + public bool IsCapped => _live.Kind == CollectionKind.Capped; + + /// + public override TabKind Kind => TabKind.Design; + + /// + public override string Key => $"design:{Info.Namespace}"; + + /// + public override string IconKey => "Mongo.pencil-ruler"; + + /// + public override string IconToken => "VelaAccent"; + + /// 集合的 mongosh 引用(db.orders / db.getCollection("a-b"))。 + internal string ShellRef => "db." + MongoWorkspaceViewModel.ShellCollectionRef(CollectionName); + + /// 生命周期取消令牌(标签关掉就取消在途的请求)。 + internal CancellationToken Lifetime => _life.Token; + + /// + public override async Task LoadAsync() + { + await RefreshInfoAsync().ConfigureAwait(true); + // 子页条上的「索引 N」要计数,所以索引总是先加载;其余页按需。 + if (_page != DesignPage.Indexes) + { + await EnsureIndexesAsync().ConfigureAwait(true); + } + await EnsurePageAsync(force: false).ConfigureAwait(true); + } + + /// + public override Task RefreshAsync() => EnsurePageAsync(force: true); + + /// + protected override void OnActivated() => UpdateStatus(); + + /// 确保某个子页的数据已加载( = 重新加载)。 + internal async Task EnsurePageAsync(bool force) + { + if (_disposed) + { + return; + } + switch (_page) + { + case DesignPage.Indexes when force: + await LoadIndexesAsync().ConfigureAwait(true); + break; + case DesignPage.Indexes: + await EnsureIndexesAsync().ConfigureAwait(true); + break; + case DesignPage.Schema when force || Schema is null: + await AnalyzeAsync().ConfigureAwait(true); + break; + case DesignPage.Validation when force || !_validationLoaded: + if (force) + { + await RefreshInfoAsync().ConfigureAwait(true); + } + // 刷新不吞掉未应用的改动:编辑器里有改动时只重跑预检与历史,不覆盖文本。 + await LoadValidationAsync(resetEditor: !_editorReady || (force && !IsModified)).ConfigureAwait(true); + break; + case DesignPage.Options when force || !_optionsLoaded: + await LoadOptionsAsync().ConfigureAwait(true); + break; + case DesignPage.Stats when force || !_statsLoaded: + await LoadStatsAsync().ConfigureAwait(true); + break; + } + } + + /// 重新探一次集合信息(listCollections 的 options:验证规则、TTL、时序参数…)。 + internal async Task RefreshInfoAsync() + { + try + { + using IAsyncCursor cursor = await Workspace.Connection.Database(Database) + .ListCollectionsAsync(new ListCollectionsOptions { Filter = new BsonDocument("name", CollectionName) }, Lifetime) + .ConfigureAwait(true); + BsonDocument? doc = await cursor.FirstOrDefaultAsync(Lifetime).ConfigureAwait(true); + if (doc is not null) + { + Live = MongoConnection.ToCollectionInfo(Database, doc); + } + } + catch (Exception ex) when (IsExpected(ex)) + { + Workspace.Log.Info($"listCollections for {Namespace} failed: {ex.Message}"); + } + } + + /// + /// 状态栏那一行:设计 · shop.orders · 6 个索引 · 1.9 GB。随子页换摘要。 + /// + internal void UpdateStatus() + { + string summary = _page switch + { + DesignPage.Indexes => _indexesLoaded + ? Loc.Format("Design_StatusIndexes", ReadyIndexCount, BsonText.Bytes(_totalIndexSize)) + : Loc["Common_Loading"], + DesignPage.Schema => IsAnalyzing + ? Loc["Design_StatusAnalyzing"] + : Loc.Format("Design_StatusSchema", SchemaFields.Count), + DesignPage.Validation => Loc.Format("Design_StatusValidation", RuleLevel, RuleAction) + + (IsModified ? " · " + Loc["Design_RuleUnsaved"] : ""), + DesignPage.Options => Loc.Format("Design_StatusOptions", TtlRows.Count), + DesignPage.Stats => _statsLoaded + ? Loc.Format("Design_StatusStats", BsonText.Grouped(_stats?.Count ?? 0), BsonText.Bytes(_stats?.Size ?? 0)) + : Loc["Common_Loading"], + _ => "" + }; + StatusText = Loc.Format("Design_Status", Namespace, summary); + } + + /// 驱动 / 解析的"预期内"失败(弹提示,不崩)。 + internal static bool IsExpected(Exception ex) => + ex is MongoException or TimeoutException or ShellJsonException or FormatException or OperationCanceledException + or InvalidOperationException; + + /// 把失败弹成右下角的红色提示(取消不算失败)。 + internal void Fail(Exception ex) + { + if (ex is OperationCanceledException || _disposed) + { + return; + } + Workspace.Toast(new() { Title = Loc.Format("Common_Failed", MongoConnector.Describe(ex)), Kind = ToastKind.Error }); + } + + /// 秒数 → 人话(1 天、2 小时 30 分)。 + internal string Duration(long seconds) + { + Loc loc = Loc; + if (seconds < 0) + { + return ""; + } + if (seconds < 60) + { + return loc.Format("Design_Seconds", seconds); + } + if (seconds < 3600) + { + return loc.Format("Design_Minutes", Math.Round(seconds / 60.0, 1).ToString("0.#", CultureInfo.InvariantCulture)); + } + if (seconds < 86400) + { + return loc.Format("Design_Hours", Math.Round(seconds / 3600.0, 1).ToString("0.#", CultureInfo.InvariantCulture)); + } + return loc.Format("Design_Days", Math.Round(seconds / 86400.0, 1).ToString("0.#", CultureInfo.InvariantCulture)); + } + + /// 集合名在命令文本里的写法("orders")。 + internal string QuotedCollection => BsonText.Quote(CollectionName); + + /// 当前时间(UTC;单测可以替换,建议里的"未使用 N 天"按它算)。 + internal Func Now { get; set; } = static () => DateTime.UtcNow; + + /// + public override void Dispose() + { + if (_disposed) + { + return; + } + _disposed = true; + _life.Cancel(); + _buildPoll?.Cancel(); + _tryTimer?.Stop(); + _ruleTimer?.Stop(); + base.Dispose(); + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignValidationView.axaml b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignValidationView.axaml new file mode 100644 index 0000000..3dac003 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignValidationView.axaml @@ -0,0 +1,212 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignValidationView.axaml.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignValidationView.axaml.cs new file mode 100644 index 0000000..e486104 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Design/DesignValidationView.axaml.cs @@ -0,0 +1,128 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using AvaloniaEdit.Rendering; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 集合设计 · 验证规则页。代码里做两件事: +/// 三个下拉菜单(validationLevel、validationAction、历史版本 —— 弹出层在独立可视树里,菜单项直接绑视图模型的命令最省事), +/// 以及规则编辑器里关键字的悬停说明卡。 +/// +public sealed partial class DesignValidationView : UserControl +{ + /// 构造。 + public DesignValidationView() + { + InitializeComponent(); + LevelPick.Click += (_, _) => ShowChoices(LevelPick, ["off", "moderate", "strict"], static vm => vm.RuleLevel, static vm => vm.SetLevelCommand, + static (vm, value) => vm.Loc["Design_Level_" + value]); + ActionPick.Click += (_, _) => ShowChoices(ActionPick, ["warn", "error"], static vm => vm.RuleAction, static vm => vm.SetActionCommand, + static (vm, value) => vm.Loc["Design_Action_" + value]); + HistoryPick.Click += OnHistory; + TextView view = RuleEditor.Editor.TextArea.TextView; + view.AddHandler(TextView.PointerHoverEvent, OnRuleHover); + view.AddHandler(TextView.PointerHoverStoppedEvent, (_, _) => HoverDoc.IsOpen = false); + RuleEditor.Editor.TextChanged += (_, _) => HoverDoc.IsOpen = false; + RuleEditor.PointerExited += (_, _) => HoverDoc.IsOpen = false; + } + + private DesignTabViewModel? ViewModel => DataContext as DesignTabViewModel; + + /// 悬停在一个词上:是 $jsonSchema 关键字就弹说明卡。 + private void OnRuleHover(object? sender, PointerEventArgs e) + { + if (ViewModel is not { } vm) + { + return; + } + TextView view = RuleEditor.Editor.TextArea.TextView; + if (view.GetPositionFloor(e.GetPosition(view) + view.ScrollOffset) is not { } position) + { + return; + } + string text = RuleEditor.Editor.Document.Text; + int offset = RuleEditor.Editor.Document.GetOffset(position.Location); + if (WordAt(text, offset) is not { Length: > 0 } word || vm.KeywordHover(word) is not { } hover) + { + HoverDoc.IsOpen = false; + return; + } + HoverTitle.Text = hover.Title; + HoverBadge.Text = hover.Badge; + HoverBody.Text = hover.Body; + HoverDoc.IsOpen = true; + e.Handled = true; + } + + /// 光标处的那个标识符(含 $)。 + private static string WordAt(string text, int offset) + { + static bool Part(char c) => char.IsLetterOrDigit(c) || c is '_' or '$'; + int start = Math.Clamp(offset, 0, text.Length); + int end = start; + while (start > 0 && Part(text[start - 1])) + { + start--; + } + while (end < text.Length && Part(text[end])) + { + end++; + } + return text[start..end]; + } + + /// 一组取值的下拉:当前值打勾,每项右侧一句说明。 + private void ShowChoices(Button anchor, string[] values, Func current, + Func> command, Func describe) + { + if (ViewModel is not { } vm) + { + return; + } + var items = new List(); + foreach (string value in values) + { + items.Add(new MenuItem + { + Header = $"{value} · {describe(vm, value)}", + Command = command(vm), + CommandParameter = value, + Icon = value == current(vm) ? Check() : null + }); + } + Open(anchor, items); + } + + /// 历史版本:每次应用规则前自动存的旧规则,点一项放回编辑器(不应用)。 + private void OnHistory(object? sender, RoutedEventArgs e) + { + if (ViewModel is not { } vm) + { + return; + } + var items = new List(); + foreach (RuleVersion version in vm.RuleHistory) + { + items.Add(new MenuItem + { + Header = version.Label, + Command = vm.RestoreVersionCommand, + CommandParameter = version, + Icon = new Glyph { Key = "Mongo.history", Size = 12, Brush = ThemeBrushes.Get("VelaTextTertiary", Brushes.Gray) } + }); + } + if (items.Count == 0) + { + items.Add(new MenuItem { Header = vm.Loc["Design_NoHistory"], IsEnabled = false }); + } + Open(HistoryPick, items); + } + + private static Glyph Check() => new() { Key = "Mongo.check", Size = 12, Brush = ThemeBrushes.Get("VelaAccent", Brushes.Gray) }; + + private static void Open(Control anchor, List items) => + new ContextMenu { ItemsSource = items, Placement = PlacementMode.BottomEdgeAlignedLeft, PlacementTarget = anchor }.Open(anchor); +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Dialogs/ConnectionDialogView.axaml b/plugins/VelaShell.Plugin.Mongo/Ui/Dialogs/ConnectionDialogView.axaml new file mode 100644 index 0000000..792a05f --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Dialogs/ConnectionDialogView.axaml @@ -0,0 +1,584 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Dialogs/ConnectionDialogView.axaml.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Dialogs/ConnectionDialogView.axaml.cs new file mode 100644 index 0000000..6a48b10 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Dialogs/ConnectionDialogView.axaml.cs @@ -0,0 +1,68 @@ +using System.Collections.Specialized; +using Avalonia.Controls; +using Avalonia.Controls.Documents; +using Avalonia.Media; +using Avalonia.Threading; +using VelaShell.Plugin.Mongo.Core; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 新建 / 编辑 MongoDB 连接(设计稿 10)。 +public sealed partial class ConnectionDialogView : UserControl +{ + private readonly ConnectionDialogViewModel? _viewModel; + + /// 用给定的视图模型初始化。 + /// 视图模型。 + internal ConnectionDialogView(ConnectionDialogViewModel viewModel) + { + _viewModel = viewModel; + InitializeComponent(); + DataContext = viewModel; + viewModel.PreviewSpans.CollectionChanged += OnPreviewChanged; + ActualThemeVariantChanged += (_, _) => RenderPreview(); + AttachedToVisualTree += (_, _) => Dispatcher.UIThread.Post(() => NameBox.Focus()); + DetachedFromVisualTree += (_, _) => viewModel.PreviewSpans.CollectionChanged -= OnPreviewChanged; + RenderPreview(); + } + + /// 设计器用的无参构造。 + public ConnectionDialogView() + { + InitializeComponent(); + } + + private void OnPreviewChanged(object? sender, NotifyCollectionChangedEventArgs e) => RenderPreview(); + + /// + /// 连接串按角色着色(设计稿 10:scheme 灰、用户名强调色、口令占位灰、主机绿、库与参数值品红 / 黄)。 + /// 行内着色绑不了集合,就在这里拼 ;颜色取宿主令牌,换肤时重拼。 + /// + private void RenderPreview() + { + if (_viewModel is null) + { + return; + } + InlineCollection inlines = PreviewBlock.Inlines ??= []; + inlines.Clear(); + foreach (PreviewSpanViewModel span in _viewModel.PreviewSpans) + { + inlines.Add(new Run(span.Text) { Foreground = Brush(span.Role) }); + } + } + + private IBrush? Brush(PreviewRole role) + { + string token = role switch + { + PreviewRole.Scheme or PreviewRole.Secret or PreviewRole.Key => "VelaTextTertiary", + PreviewRole.User => "VelaAccent", + PreviewRole.Host => "VelaShellGreen", + PreviewRole.Path => "VelaShellYellow", + PreviewRole.Value => "VelaShellMagenta", + _ => "VelaTextSecondary" + }; + return this.TryFindResource(token, ActualThemeVariant, out object? value) ? value as IBrush : null; + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Dialogs/ConnectionDialogViewModel.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Dialogs/ConnectionDialogViewModel.cs new file mode 100644 index 0000000..665f139 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Dialogs/ConnectionDialogViewModel.cs @@ -0,0 +1,1260 @@ +using System.Collections.ObjectModel; +using System.Globalization; +using Avalonia.Threading; +using VelaShell.Plugin.Mongo.Core; +using VelaShell.PluginSdk.Sessions; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 主机列表里的一行(设计稿 10:主机 + 端口 + 测试后发现的角色 + 删除)。 +internal sealed class HostRowViewModel : ObservableObject +{ + private string _host; + private string _port; + private string? _badge; + private Tone _badgeTone; + + /// 构造。 + public HostRowViewModel(string host, int port, bool isFirst, Action remove, Action changed) + { + _host = host; + _port = port.ToString(CultureInfo.InvariantCulture); + IsFirst = isFirst; + RemoveCommand = new RelayCommand(() => remove(this)); + Changed = changed; + } + + private Action Changed { get; } + + /// 主机。 + public string Host + { + get => _host; + set + { + if (SetProperty(ref _host, value)) + { + Changed(); + } + } + } + + /// 端口(文本:用户可能正敲到一半)。 + public string Port + { + get => _port; + set + { + if (SetProperty(ref _port, value)) + { + Changed(); + } + } + } + + /// 端口数值;写坏了按 27017。 + public int PortNumber => int.TryParse(_port, NumberStyles.Integer, CultureInfo.InvariantCulture, out int p) && p is > 0 and < 65536 ? p : 27017; + + /// 第一台(就是连接的主机,不能删)。 + public bool IsFirst { get; } + + /// 能删(除第一台外)。 + public bool CanRemove => !IsFirst; + + /// 测试后发现的角色。 + public string? Badge + { + get => _badge; + set + { + if (SetProperty(ref _badge, value)) + { + RaisePropertyChanged(nameof(HasBadge)); + } + } + } + + /// 有角色徽章。 + public bool HasBadge => !string.IsNullOrEmpty(_badge); + + /// 徽章语气色。 + public Tone BadgeTone + { + get => _badgeTone; + set + { + if (SetProperty(ref _badgeTone, value)) + { + RaisePropertiesChanged(nameof(IsBadgeOk), nameof(IsBadgeInfo), nameof(IsBadgeWarn), nameof(IsBadgeErr)); + } + } + } + + /// 主节点 / 单机(绿)。 + public bool IsBadgeOk => _badgeTone == Tone.Success; + + /// 从节点(蓝)。 + public bool IsBadgeInfo => _badgeTone == Tone.Info; + + /// 恢复中(橙)。 + public bool IsBadgeWarn => _badgeTone == Tone.Warning; + + /// 不可达(红)。 + public bool IsBadgeErr => _badgeTone == Tone.Danger; + + /// 删掉这一行。 + public RelayCommand RemoveCommand { get; } + + /// host:port(IPv6 加方括号)。 + public string Address => _host.Contains(':', StringComparison.Ordinal) && !_host.StartsWith('[') + ? $"[{_host.Trim()}]:{PortNumber}" + : $"{_host.Trim()}:{PortNumber}"; +} + +/// 连接串预览的一段(视图按角色着色)。 +/// 文字。 +/// 角色。 +internal sealed record PreviewSpanViewModel(string Text, PreviewRole Role) +{ + /// scheme。 + public bool IsScheme => Role == PreviewRole.Scheme; + + /// 用户名。 + public bool IsUser => Role == PreviewRole.User; + + /// 口令。 + public bool IsSecret => Role == PreviewRole.Secret; + + /// 主机。 + public bool IsHost => Role == PreviewRole.Host; + + /// 库。 + public bool IsPath => Role == PreviewRole.Path; + + /// 参数名。 + public bool IsKey => Role == PreviewRole.Key; + + /// 参数值。 + public bool IsValue => Role == PreviewRole.Value; +} + +/// 测试结果的一行。 +internal sealed class ProbeStepViewModel(ProbeStep step) : ObservableObject +{ + private ProbeStep _step = step; + + /// 步骤键。 + public string Key => _step.Key; + + /// 标题。 + public string Title => _step.Title; + + /// 细节。 + public string Detail => _step.Detail ?? ""; + + /// 有细节。 + public bool HasDetail => !string.IsNullOrEmpty(_step.Detail); + + /// 耗时(9 ms)。 + public string Elapsed => _step.ElapsedMs is { } ms ? $"{ms} ms" : ""; + + /// 状态。 + public ProbeState State => _step.State; + + /// 通过。 + public bool IsPassed => _step.State == ProbeState.Passed; + + /// 有保留。 + public bool IsWarning => _step.State == ProbeState.Warning; + + /// 失败。 + public bool IsFailed => _step.State == ProbeState.Failed; + + /// 进行中。 + public bool IsRunning => _step.State == ProbeState.Running; + + /// 没轮到 / 没执行。 + public bool IsIdle => _step.State is ProbeState.Pending or ProbeState.Skipped; + + /// 原地更新。 + public void Update(ProbeStep step) + { + _step = step; + RaisePropertiesChanged(nameof(Title), nameof(Detail), nameof(HasDetail), nameof(Elapsed), nameof(State), + nameof(IsPassed), nameof(IsWarning), nameof(IsFailed), nameof(IsRunning), nameof(IsIdle)); + } +} + +/// 发现的一个成员。 +/// 端点。 +internal sealed record ProbeEndpointViewModel(ProbeEndpoint Endpoint) +{ + /// 地址。 + public string Address => Endpoint.Address; + + /// 角色。 + public string Role => Endpoint.Role ?? ""; + + /// 补充(延迟)。 + public string Detail => Endpoint.Detail ?? ""; + + /// 主节点 / 单机。 + public bool IsOk => Endpoint.Tone == Tone.Success; + + /// 从节点。 + public bool IsInfo => Endpoint.Tone == Tone.Info; + + /// 恢复中。 + public bool IsWarn => Endpoint.Tone == Tone.Warning; + + /// 不可达。 + public bool IsErr => Endpoint.Tone == Tone.Danger; +} + +/// 下拉里的一项:存的值与显示的字分开(DEFAULT 显示成「默认(协商 SCRAM-SHA-256)」)。 +/// 存的值。 +/// 显示的字。 +internal sealed record Choice(string Value, string Label) +{ + /// + public override string ToString() => Label; +} + +/// 可选的 SSH 跳板(宿主里已保存的 SSH 连接)。 +/// 已保存连接的 id。 +/// 显示(bastion-ops · 10.0.0.5)。 +internal sealed record SshChoice(string Id, string Display) +{ + /// + public override string ToString() => Display; +} + +/// +/// 新建 / 编辑 MongoDB 连接(设计稿 10,1020 × 760):左边 700 的表单(基本 / 服务器 / 认证 / 安全通道, +/// 设计稿外的调优项收在末尾的「高级」里),右边 320 的侧栏(连接字符串 / 测试结果 / 发现的成员 / 安全策略), +/// 页脚「测试连接 · 取消 · 仅保存 · 保存并连接」。 +/// +/// 口令只在这里过手:存进宿主的加密密钥库,不写进连接串、不写进插件存储。编辑时口令框留空表示不改。 +/// +/// +internal sealed class ConnectionDialogViewModel : DialogViewModel, IDisposable +{ + private static readonly string[] ReadPreferenceNames = ["primary", "primaryPreferred", "secondary", "secondaryPreferred", "nearest"]; + + private readonly MongoWorkspaceViewModel _shell; + private readonly MongoProfile _original; + private readonly string? _passwordFrom; + private bool _hasStoredPassword; + private string _name; + private string _group; + private string _environment; + private string _topology; + private string _srvHost = ""; + private string _uri = ""; + private string _replicaSet; + private string _database; + private string _readPreference; + private string _mechanism; + private string _authSource; + private string _username; + private string _password = ""; + private bool _passwordVisible; + private bool _sshEnabled; + private SshChoice? _sshChoice; + private bool _tlsEnabled; + private string _tlsCaFile; + private string _tlsCertFile; + private bool _readOnly; + private bool _confirmWrites; + private bool _disableDropDatabase; + private bool _policyTouched; + private bool _showAdvanced; + private bool _directConnection; + private string _appName; + private string _connectTimeout; + private string _maxTimeMs; + private string _pageSize; + private string _sampleSize; + private string _ejson; + private bool _showSystemDatabases; + private string _previewText = ""; + private bool _isTesting; + private string _testSummary = ""; + private bool? _testSucceeded; + private string _error = ""; + private bool _isSaving; + private CancellationTokenSource? _probe; + + /// 构造。 + /// 工作台外壳。 + /// 要编辑的连接(副本;新建时是一条空的)。 + /// 新建还是编辑。 + /// 复制连接时,口令从哪条已存的连接带过来。 + public ConnectionDialogViewModel(MongoWorkspaceViewModel shell, MongoProfile profile, bool isNew, string? passwordFrom = null) + : base(shell) + { + _shell = shell; + _original = profile; + _passwordFrom = passwordFrom; + IsNew = isNew; + Title = isNew ? Loc["Conn_NewTitle"] : Loc["Conn_EditTitle"]; + Subtitle = isNew ? "" : profile.Name; + + MongoSettings s = profile.Parsed; + _name = profile.Name; + _group = profile.Group; + Groups = [.. shell.Connections.Select(static c => c.Profile.Group).Where(static g => g.Length > 0).Distinct(StringComparer.CurrentCultureIgnoreCase).Order(StringComparer.CurrentCultureIgnoreCase)]; + MechanismChoices = + [ + new("DEFAULT", Loc["Mongo_AuthDefault"]), + new("SCRAM-SHA-256", "SCRAM-SHA-256"), + new("SCRAM-SHA-1", "SCRAM-SHA-1"), + new("MONGODB-X509", "X.509"), + new("PLAIN", Loc["Conn_AuthPlain"]) + ]; + EjsonChoices = + [ + new("relaxed", "Relaxed EJSON"), + new("canonical", "Canonical EJSON"), + new("shell", Loc["Conn_EjsonShell"]) + ]; + _environment = profile.Get(MongoSettings.KeyEnvironment, "development"); + _topology = profile.Get(MongoSettings.KeyTopology, "hosts"); + switch (s.Topology) + { + case MongoTopology.Srv: + _srvHost = profile.Host; + break; + case MongoTopology.Uri: + _uri = profile.Host; + break; + } + Hosts.Add(new HostRowViewModel(s.Topology == MongoTopology.Hosts ? profile.Host : "", + s.Topology == MongoTopology.Hosts ? profile.Port : 27017, true, RemoveHost, OnChanged)); + foreach (string extra in s.AdditionalHosts) + { + MongoDB.Driver.MongoServerAddress address = MongoConnection.ParseAddress(extra); + Hosts.Add(new HostRowViewModel(address.Host, address.Port, false, RemoveHost, OnChanged)); + } + _replicaSet = s.ReplicaSet; + _database = s.Database; + _readPreference = s.ReadPreference; + _mechanism = s.AuthMechanism; + _authSource = s.AuthSource; + _username = profile.Username; + _sshEnabled = s.JumpSessionId.Length > 0; + _tlsEnabled = s.UseTls; + _tlsCaFile = s.TlsCaFile; + _tlsCertFile = s.TlsCertificateFile; + _readOnly = s.ReadOnly; + _confirmWrites = s.ConfirmWrites; + _disableDropDatabase = s.DisableDropDatabase; + _policyTouched = profile.Settings.ContainsKey(MongoSettings.KeyReadOnly) || profile.Settings.ContainsKey(MongoSettings.KeyConfirmWrites); + _directConnection = s.DirectConnection; + _appName = s.AppName; + _connectTimeout = s.ConnectTimeoutMs.ToString(CultureInfo.InvariantCulture); + _maxTimeMs = s.MaxTimeMs.ToString(CultureInfo.InvariantCulture); + _pageSize = s.PageSize.ToString(CultureInfo.InvariantCulture); + _sampleSize = s.SampleSize.ToString(CultureInfo.InvariantCulture); + _ejson = profile.Get(MongoSettings.KeyEjson, "relaxed"); + + AddHostCommand = new RelayCommand(AddHost); + TogglePasswordCommand = new RelayCommand(() => PasswordVisible = !PasswordVisible); + ToggleAdvancedCommand = new RelayCommand(() => ShowAdvanced = !ShowAdvanced); + CopyPreviewCommand = new AsyncCommand(() => _shell.CopyAsync(_previewText)); + BrowseCaCommand = new AsyncCommand(async () => TlsCaFile = await PickAsync(TlsCaFile, Loc["Mongo_TlsCaFile"], new FileKind("PEM", "*.pem", "*.crt", "*.cer")).ConfigureAwait(true)); + BrowseCertCommand = new AsyncCommand(async () => TlsCertFile = await PickAsync(TlsCertFile, Loc["Mongo_TlsCertFile"], new FileKind("PFX / PEM", "*.pfx", "*.p12", "*.pem")).ConfigureAwait(true)); + TestCommand = new AsyncCommand(TestAsync); + SaveCommand = new AsyncCommand(() => SaveAsync(connect: false)); + SaveAndConnectCommand = new AsyncCommand(() => SaveAsync(connect: true)); + RefreshPreview(); + _ = LoadAsync(profile.Id); + } + + /// + public override string IconKey => "Mongo.leaf"; + + /// + public override string IconToken => "VelaStatusConnected"; + + /// + public override double Width => 1020; + + /// + public override double Height => 760; + + /// + public override bool CanCloseWithEscape => true; + + /// 新建(否则是编辑)。 + public bool IsNew { get; } + + // ── 基本 ─────────────────────────────────────────────────────────────── + + /// 连接名。 + public string Name + { + get => _name; + set + { + // 改名不影响连不连得上:不作废测试结果。 + if (SetProperty(ref _name, value)) + { + OnChanged(); + } + } + } + + /// 分组(对象树按它分节;空 = 不分组)。 + public string Group + { + get => _group; + set + { + if (SetProperty(ref _group, value ?? "")) + { + OnChanged(); + } + } + } + + /// 已有的分组(下拉候选)。 + public IReadOnlyList Groups { get; } + + /// 开发。 + public bool IsDevelopment + { + get => _environment == "development"; + set => SetEnvironment(value, "development"); + } + + /// 测试。 + public bool IsTesting + { + get => _environment == "testing"; + set => SetEnvironment(value, "testing"); + } + + /// 生产(默认只读、写前确认)。 + public bool IsProduction + { + get => _environment == "production"; + set => SetEnvironment(value, "production"); + } + + private void SetEnvironment(bool on, string value) + { + if (!on || _environment == value) + { + return; + } + _environment = value; + // 安全策略没被用户动过时跟着环境走:换成生产就默认只读 + 写前确认。 + if (!_policyTouched) + { + _readOnly = _confirmWrites = value == "production"; + RaisePropertiesChanged(nameof(ReadOnly), nameof(ConfirmWrites)); + } + RaisePropertiesChanged(nameof(IsDevelopment), nameof(IsTesting), nameof(IsProduction)); + OnChanged(); + } + + // ── 服务器 ───────────────────────────────────────────────────────────── + + /// 主机列表形态。 + public bool IsHostsTopology + { + get => _topology == "hosts"; + set => SetTopology(value, "hosts"); + } + + /// SRV 形态。 + public bool IsSrvTopology + { + get => _topology == "srv"; + set => SetTopology(value, "srv"); + } + + /// 连接字符串形态。 + public bool IsUriTopology + { + get => _topology == "uri"; + set => SetTopology(value, "uri"); + } + + private void SetTopology(bool on, string value) + { + if (!on || _topology == value) + { + return; + } + _topology = value; + // SRV 默认走 TLS(Atlas 就是这样);另外两种保持用户的选择。 + if (value == "srv" && !_tlsEnabled) + { + _tlsEnabled = true; + RaisePropertyChanged(nameof(TlsEnabled)); + } + RaisePropertiesChanged(nameof(IsHostsTopology), nameof(IsSrvTopology), nameof(IsUriTopology), nameof(CanUseSsh), nameof(SshHint)); + ClearProbe(); + OnChanged(); + } + + /// 主机列表(第一行是连接的主机)。 + public ObservableCollection Hosts { get; } = []; + + /// SRV 域名。 + public string SrvHost + { + get => _srvHost; + set => Set(ref _srvHost, value); + } + + /// 连接字符串。 + public string Uri + { + get => _uri; + set => Set(ref _uri, value); + } + + /// 副本集名。 + public string ReplicaSet + { + get => _replicaSet; + set => Set(ref _replicaSet, value); + } + + /// 默认库。 + public string Database + { + get => _database; + set => Set(ref _database, value); + } + + /// 读偏好的候选。 + public IReadOnlyList ReadPreferences => ReadPreferenceNames; + + /// 读偏好。 + public string ReadPreference + { + get => _readPreference; + set => Set(ref _readPreference, value ?? "primary"); + } + + /// 加一台主机。 + public RelayCommand AddHostCommand { get; } + + private void AddHost() + { + Hosts.Add(new HostRowViewModel("", 27017, false, RemoveHost, OnChanged)); + OnChanged(); + } + + private void RemoveHost(HostRowViewModel row) + { + if (!row.IsFirst && Hosts.Remove(row)) + { + OnChanged(); + } + } + + // ── 认证 ─────────────────────────────────────────────────────────────── + + /// 认证机制的候选。 + public IReadOnlyList MechanismChoices { get; } + + /// 下拉里选中的认证机制。 + public Choice? SelectedMechanism + { + get => MechanismChoices.FirstOrDefault(c => c.Value == _mechanism) ?? MechanismChoices[0]; + set + { + if (value is not null) + { + Mechanism = value.Value; + } + } + } + + /// 认证机制。 + public string Mechanism + { + get => _mechanism; + set + { + Set(ref _mechanism, value ?? "DEFAULT"); + RaisePropertyChanged(nameof(SelectedMechanism)); + } + } + + /// authSource。 + public string AuthSource + { + get => _authSource; + set => Set(ref _authSource, value); + } + + /// 用户名(空 = 匿名)。 + public string Username + { + get => _username; + set => Set(ref _username, value); + } + + /// 口令(编辑时留空 = 不改)。 + public string Password + { + get => _password; + set + { + if (SetProperty(ref _password, value)) + { + ClearProbe(); + } + } + } + + /// 口令明文显示。 + public bool PasswordVisible + { + get => _passwordVisible; + set => SetProperty(ref _passwordVisible, value); + } + + /// 密钥库里已经存着这条连接的口令(口令框的占位字写「已保存」)。 + public bool HasStoredPassword + { + get => _hasStoredPassword; + private set + { + if (SetProperty(ref _hasStoredPassword, value)) + { + RaisePropertyChanged(nameof(PasswordPlaceholder)); + } + } + } + + /// 口令框的占位字。 + public string PasswordPlaceholder => _hasStoredPassword ? Loc["Conn_PasswordStored"] : ""; + + /// 切换明文显示。 + public RelayCommand TogglePasswordCommand { get; } + + // ── 安全通道 ─────────────────────────────────────────────────────────── + + /// 宿主里已保存的 SSH 连接(跳板的候选)。 + public ObservableCollection SshChoices { get; } = []; + + /// 走 SSH 跳板。 + public bool SshEnabled + { + get => _sshEnabled; + set + { + if (!SetProperty(ref _sshEnabled, value)) + { + return; + } + if (value) + { + _sshChoice ??= SshChoices.FirstOrDefault(); + RaisePropertyChanged(nameof(SshChoice)); + } + ClearProbe(); + OnChanged(); + } + } + + /// 选中的跳板。 + public SshChoice? SshChoice + { + get => _sshChoice; + set + { + if (SetProperty(ref _sshChoice, value)) + { + ClearProbe(); + OnChanged(); + } + } + } + + /// 跳板开关能不能开:SRV 不能走跳板;一条已保存的 SSH 连接都没有也开不了(已开着的仍能关)。 + public bool CanUseSsh => (_topology != "srv" && SshChoices.Count > 0) || _sshEnabled; + + /// 跳板卡片上的说明。 + public string SshHint => _topology == "srv" + ? Loc["Conn_SshNoSrv"] + : SshChoices.Count == 0 ? Loc["Conn_SshNone"] : Loc["Mongo_JumpSessionHint"]; + + /// TLS。 + public bool TlsEnabled + { + get => _tlsEnabled; + set => Set(ref _tlsEnabled, value); + } + + /// CA 证书文件。 + public string TlsCaFile + { + get => _tlsCaFile; + set => Set(ref _tlsCaFile, value); + } + + /// 客户端证书文件。 + public string TlsCertFile + { + get => _tlsCertFile; + set => Set(ref _tlsCertFile, value); + } + + /// 选 CA 证书。 + public AsyncCommand BrowseCaCommand { get; } + + /// 选客户端证书。 + public AsyncCommand BrowseCertCommand { get; } + + // ── 安全策略(右侧栏)────────────────────────────────────────────────── + + /// 默认以只读模式打开。 + public bool ReadOnly + { + get => _readOnly; + set + { + if (SetProperty(ref _readOnly, value)) + { + _policyTouched = true; + } + } + } + + /// 写操作前二次确认。 + public bool ConfirmWrites + { + get => _confirmWrites; + set + { + if (SetProperty(ref _confirmWrites, value)) + { + _policyTouched = true; + } + } + } + + /// 禁用 dropDatabase。 + public bool DisableDropDatabase + { + get => _disableDropDatabase; + set => SetProperty(ref _disableDropDatabase, value); + } + + // ── 高级(设计稿外的调优项)─────────────────────────────────────────── + + /// 「高级」展开着。 + public bool ShowAdvanced + { + get => _showAdvanced; + set => SetProperty(ref _showAdvanced, value); + } + + /// 展开 / 收起「高级」。 + public RelayCommand ToggleAdvancedCommand { get; } + + /// 直连(不做副本集发现)。 + public bool DirectConnection + { + get => _directConnection; + set => Set(ref _directConnection, value); + } + + /// appName。 + public string AppName + { + get => _appName; + set => Set(ref _appName, value); + } + + /// 连接超时(毫秒)。 + public string ConnectTimeout + { + get => _connectTimeout; + set => SetProperty(ref _connectTimeout, value); + } + + /// 默认 maxTimeMS。 + public string MaxTimeMs + { + get => _maxTimeMs; + set => SetProperty(ref _maxTimeMs, value); + } + + /// 每页行数。 + public string PageSize + { + get => _pageSize; + set => SetProperty(ref _pageSize, value); + } + + /// Schema 抽样数。 + public string SampleSize + { + get => _sampleSize; + set => SetProperty(ref _sampleSize, value); + } + + /// 扩展 JSON 模式的候选。 + public IReadOnlyList EjsonChoices { get; } + + /// 下拉里选中的扩展 JSON 模式。 + public Choice? SelectedEjson + { + get => EjsonChoices.FirstOrDefault(c => c.Value == _ejson) ?? EjsonChoices[0]; + set + { + if (value is not null && SetProperty(ref _ejson, value.Value, nameof(Ejson))) + { + RaisePropertyChanged(); + } + } + } + + /// 扩展 JSON 模式。 + public string Ejson + { + get => _ejson; + set => SetProperty(ref _ejson, value ?? "relaxed"); + } + + /// 对象树列出系统库。 + public bool ShowSystemDatabases + { + get => _showSystemDatabases; + set => SetProperty(ref _showSystemDatabases, value); + } + + // ── 右侧栏:连接字符串 ───────────────────────────────────────────────── + + /// 预览的各段(按角色着色)。 + public ObservableCollection PreviewSpans { get; } = []; + + /// 整串(复制用)。 + public string PreviewText + { + get => _previewText; + private set => SetProperty(ref _previewText, value); + } + + /// 复制连接串。 + public AsyncCommand CopyPreviewCommand { get; } + + // ── 右侧栏:测试结果与发现的成员 ─────────────────────────────────────── + + /// 测试结果。 + public ObservableCollection Steps { get; } = []; + + /// 还没测过(给一行「点测试连接逐步检查」)。 + public bool ShowStepsHint => Steps.Count == 0; + + /// 发现的成员。 + public ObservableCollection Members { get; } = []; + + /// 有成员。 + public bool HasMembers => Members.Count > 0; + + /// 正在测。 + public bool IsProbing + { + get => _isTesting; + private set => SetProperty(ref _isTesting, value); + } + + /// 页脚的结论(连接成功 · 38 ms)。 + public string TestSummary + { + get => _testSummary; + private set + { + if (SetProperty(ref _testSummary, value)) + { + RaisePropertyChanged(nameof(HasTestSummary)); + } + } + } + + /// 有结论。 + public bool HasTestSummary => _testSummary.Length > 0; + + /// 测试通过了。 + public bool TestPassed => _testSucceeded == true; + + /// 测试失败了。 + public bool TestFailed => _testSucceeded == false; + + /// 测试连接。 + public AsyncCommand TestCommand { get; } + + // ── 页脚 ─────────────────────────────────────────────────────────────── + + /// 页脚的错误(名字没填、主机没填、存不下)。 + public string Error + { + get => _error; + private set + { + if (SetProperty(ref _error, value)) + { + RaisePropertyChanged(nameof(HasError)); + } + } + } + + /// 有错误。 + public bool HasError => _error.Length > 0; + + /// 正在存(按钮灰掉,免得点两次)。 + public bool IsSaving + { + get => _isSaving; + private set => SetProperty(ref _isSaving, value); + } + + /// 仅保存。 + public AsyncCommand SaveCommand { get; } + + /// 保存并连接。 + public AsyncCommand SaveAndConnectCommand { get; } + + // ── 行为 ─────────────────────────────────────────────────────────────── + + /// 编辑时问一下密钥库里有没有口令;顺带列出宿主里已保存的 SSH 连接。 + private async Task LoadAsync(string id) + { + try + { + string stored = await _shell.Profiles.GetPasswordAsync(_passwordFrom ?? id).ConfigureAwait(true); + HasStoredPassword = stored.Length > 0; + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + _shell.Log.Info($"Reading the stored password failed: {ex.Message}"); + } + try + { + IReadOnlyList saved = await _shell.Context.Sessions.ListSavedAsync().ConfigureAwait(true); + foreach (SavedSessionInfo session in saved) + { + SshChoices.Add(new SshChoice(session.SavedSessionId, $"{session.Name} · {session.Host}")); + } + string jump = _original.Parsed.JumpSessionId; + _sshChoice = SshChoices.FirstOrDefault(c => c.Id == jump); + if (_sshEnabled && _sshChoice is null && jump.Length > 0) + { + // 跳板那条 SSH 连接在宿主里被删了:列表里补一条占位,开关照旧开着,让用户看得到、换得掉。 + _sshChoice = new SshChoice(jump, Loc["Conn_SshMissing"]); + SshChoices.Add(_sshChoice); + } + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + _shell.Log.Info($"Listing saved SSH sessions failed: {ex.Message}"); + } + RaisePropertiesChanged(nameof(SshChoice), nameof(CanUseSsh), nameof(SshHint)); + } + + private async Task PickAsync(string current, string title, FileKind kind) + { + IReadOnlyList files = await _shell.PickOpenFilesAsync(title, [kind, FileKind.Any]).ConfigureAwait(true); + return files.Count > 0 ? files[0] : current; + } + + private void Set(ref T field, T value, [System.Runtime.CompilerServices.CallerMemberName] string? name = null) + { + if (SetProperty(ref field, value, name)) + { + ClearProbe(); + OnChanged(); + } + } + + private void OnChanged() + { + Error = ""; + RefreshPreview(); + } + + private void RefreshPreview() + { + ConnectionPreview? preview = ConnectionProbe.Preview(ToProfile().ToRequest(""), Loc); + PreviewSpans.Clear(); + foreach (PreviewSpan span in preview?.Spans ?? []) + { + PreviewSpans.Add(new PreviewSpanViewModel(span.Text, span.Role)); + } + PreviewText = preview?.Text ?? ""; + } + + /// 参数一改,上一次的测试结果就不再代表它了。 + private void ClearProbe() + { + _probe?.Cancel(); + if (Steps.Count == 0 && Members.Count == 0 && _testSummary.Length == 0) + { + return; + } + Steps.Clear(); + Members.Clear(); + foreach (HostRowViewModel row in Hosts) + { + row.Badge = null; + } + _testSucceeded = null; + TestSummary = ""; + RaisePropertiesChanged(nameof(ShowStepsHint), nameof(HasMembers), nameof(TestPassed), nameof(TestFailed)); + } + + /// 表单 → 连接(id、上次连的时间与信任过的证书指纹照原样带着)。 + /// 连接。 + internal MongoProfile ToProfile() + { + MongoProfile p = _original.Clone(); + p.Name = _name.Trim(); + p.Group = _group.Trim(); + p.Username = _username.Trim(); + p.Set(MongoSettings.KeyTopology, _topology == "hosts" ? null : _topology); + switch (_topology) + { + case "srv": + p.Host = _srvHost.Trim(); + p.Set(MongoSettings.KeyHosts, null); + break; + case "uri": + p.Host = _uri.Trim(); + p.Set(MongoSettings.KeyHosts, null); + break; + default: + HostRowViewModel first = Hosts[0]; + p.Host = first.Host.Trim(); + p.Port = first.PortNumber; + p.Set(MongoSettings.KeyHosts, string.Join(",", Hosts.Skip(1).Where(static h => h.Host.Trim().Length > 0).Select(static h => h.Address))); + break; + } + p.Set(MongoSettings.KeyEnvironment, _environment); + p.Set(MongoSettings.KeyReplicaSet, _replicaSet.Trim()); + p.Set(MongoSettings.KeyDatabase, _database.Trim()); + p.Set(MongoSettings.KeyReadPreference, _readPreference == "primary" ? null : _readPreference); + p.Set(MongoSettings.KeyAuthMechanism, _mechanism == "DEFAULT" ? null : _mechanism); + p.Set(MongoSettings.KeyAuthSource, _authSource.Trim() is "admin" or "" ? null : _authSource.Trim()); + p.Set(MongoSettings.KeyJumpSession, _sshEnabled && _topology != "srv" ? _sshChoice?.Id : null); + p.Set(MongoSettings.KeyTls, _tlsEnabled ? "true" : "false"); + p.Set(MongoSettings.KeyTlsCaFile, _tlsEnabled ? _tlsCaFile.Trim() : null); + p.Set(MongoSettings.KeyTlsCertFile, _tlsEnabled ? _tlsCertFile.Trim() : null); + p.Set(MongoSettings.KeyReadOnly, _policyTouched ? Bool(_readOnly) : null); + p.Set(MongoSettings.KeyConfirmWrites, _policyTouched ? Bool(_confirmWrites) : null); + p.Set(MongoSettings.KeyDisableDropDatabase, _disableDropDatabase ? null : "false"); + p.Set(MongoSettings.KeyDirect, _directConnection ? "true" : null); + p.Set(MongoSettings.KeyAppName, _appName.Trim() is "velashell" or "" ? null : _appName.Trim()); + p.Set(MongoSettings.KeyConnectTimeout, NonDefault(_connectTimeout, 10_000)); + p.Set(MongoSettings.KeyMaxTimeMs, NonDefault(_maxTimeMs, 30_000)); + p.Set(MongoSettings.KeyPageSize, NonDefault(_pageSize, 50)); + p.Set(MongoSettings.KeySampleSize, NonDefault(_sampleSize, 1000)); + p.Set(MongoSettings.KeyEjson, _ejson == "relaxed" ? null : _ejson); + p.Set(MongoSettings.KeyShowSystemDatabases, _showSystemDatabases ? "true" : null); + return p; + + static string Bool(bool value) => value ? "true" : "false"; + + static string? NonDefault(string text, int fallback) => + int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out int n) && n != fallback + ? n.ToString(CultureInfo.InvariantCulture) + : null; + } + + /// 名字与主机必填;名字不能与别的连接重复(树上分不清)。 + private string? Validate(MongoProfile profile) + { + if (profile.Name.Length == 0) + { + return Loc["Conn_NeedName"]; + } + if (_shell.Connections.Any(c => c.Profile.Id != profile.Id && string.Equals(c.Name, profile.Name, StringComparison.OrdinalIgnoreCase))) + { + return Loc.Format("Conn_DuplicateName", profile.Name); + } + if (profile.Host.Length == 0) + { + return Loc[_topology == "uri" ? "Conn_NeedUri" : "Conn_NeedHost"]; + } + return null; + } + + /// 口令框为空时,测试与连接用密钥库里存着的那个。 + private async Task EffectivePasswordAsync() + { + if (_password.Length > 0 || _username.Trim().Length == 0) + { + return _password; + } + return await _shell.Profiles.GetPasswordAsync(_passwordFrom ?? _original.Id).ConfigureAwait(true); + } + + private async Task TestAsync() + { + MongoProfile profile = ToProfile(); + if (profile.Host.Length == 0) + { + Error = Loc[_topology == "uri" ? "Conn_NeedUri" : "Conn_NeedHost"]; + return; + } + _probe?.Cancel(); + var cts = new CancellationTokenSource(); + _probe = cts; + Steps.Clear(); + Members.Clear(); + foreach (HostRowViewModel row in Hosts) + { + row.Badge = null; + } + _testSucceeded = null; + TestSummary = ""; + IsProbing = true; + RaisePropertiesChanged(nameof(ShowStepsHint), nameof(HasMembers), nameof(TestPassed), nameof(TestFailed)); + try + { + string password = await EffectivePasswordAsync().ConfigureAwait(true); + ProbeReport report = await _shell.Connector.ProbeAsync(profile, password, new UiProgress(Apply), cts.Token).ConfigureAwait(true); + if (cts.IsCancellationRequested) + { + return; + } + foreach (ProbeStep step in report.Steps) + { + Apply(step); + } + foreach (ProbeEndpoint endpoint in report.Endpoints) + { + Members.Add(new ProbeEndpointViewModel(endpoint)); + } + ApplyBadges(report.Endpoints); + _testSucceeded = report.Succeeded; + TestSummary = report.Succeeded + ? report.Summary ?? "" + : report.FirstFailure is { } failed ? Loc.Format("Conn_TestFailed", failed.Title) : report.Summary ?? ""; + } + catch (OperationCanceledException) + { + // 参数改了或对话框关了:结果作废,什么都不显示。 + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + _testSucceeded = false; + TestSummary = MongoConnector.Describe(ex); + } + finally + { + if (ReferenceEquals(_probe, cts)) + { + _probe = null; + IsProbing = false; + } + cts.Dispose(); + RaisePropertiesChanged(nameof(ShowStepsHint), nameof(HasMembers), nameof(TestPassed), nameof(TestFailed)); + } + } + + private void Apply(ProbeStep step) + { + ProbeStepViewModel? row = Steps.FirstOrDefault(s => s.Key == step.Key); + if (row is null) + { + Steps.Add(new ProbeStepViewModel(step)); + RaisePropertyChanged(nameof(ShowStepsHint)); + } + else + { + row.Update(step); + } + } + + /// 测试后给主机列表各行标上发现的角色。 + private void ApplyBadges(IReadOnlyList endpoints) + { + foreach (HostRowViewModel row in Hosts) + { + ProbeEndpoint? match = endpoints.FirstOrDefault(e => string.Equals(e.Address, row.Address, StringComparison.OrdinalIgnoreCase)); + row.Badge = match?.Role; + row.BadgeTone = match?.Tone ?? Tone.Neutral; + } + } + + private async Task SaveAsync(bool connect) + { + MongoProfile profile = ToProfile(); + if (Validate(profile) is { } problem) + { + Error = problem; + return; + } + // 口令:填了就存;匿名就把存着的删掉;复制来的连接带上原来那条的;其余(编辑时留空)不动。 + string? password; + if (_password.Length > 0) + { + password = _password; + } + else if (profile.Username.Length == 0) + { + password = ""; + } + else if (_passwordFrom is not null) + { + password = await _shell.Profiles.GetPasswordAsync(_passwordFrom).ConfigureAwait(true); + } + else + { + password = null; + } + IsSaving = true; + try + { + _probe?.Cancel(); + Close(); + await _shell.SaveProfileAsync(profile, password, connect).ConfigureAwait(true); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + // 存不下:把对话框放回去,填的东西不丢。 + Error = Loc.Format("Conn_SaveFailed", MongoConnector.Describe(ex)); + _shell.ShowDialog(this); + } + finally + { + IsSaving = false; + } + } + + /// + internal override void OnClosed() + { + _probe?.Cancel(); + base.OnClosed(); + } + + /// 对话框关了还在测就取消( 会调它)。 + public void Dispose() => _probe?.Cancel(); + + /// 进度回到界面线程。 + private sealed class UiProgress(Action apply) : IProgress + { + public void Report(ProbeStep value) + { + if (Dispatcher.UIThread.CheckAccess()) + { + apply(value); + } + else + { + Dispatcher.UIThread.Post(() => apply(value)); + } + } + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Dialogs/NewCollectionDialogView.axaml b/plugins/VelaShell.Plugin.Mongo/Ui/Dialogs/NewCollectionDialogView.axaml new file mode 100644 index 0000000..7f9bb92 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Dialogs/NewCollectionDialogView.axaml @@ -0,0 +1,381 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Dialogs/NewCollectionDialogView.axaml.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Dialogs/NewCollectionDialogView.axaml.cs new file mode 100644 index 0000000..d8bf15c --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Dialogs/NewCollectionDialogView.axaml.cs @@ -0,0 +1,59 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Threading; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 新建集合视图。打开即聚焦名称框;名称可用时 Enter 等同于「创建集合」。 +/// 右侧命令预览的高度跟着脚本行数走(设计稿里代码框紧贴内容,提示框紧随其后)—— +/// 编辑器放进 StackPanel 拿到的是无限高,自己不会收缩,只能按文档高度显式给。 +/// +public sealed partial class NewCollectionDialogView : UserControl +{ + /// 预览框的上下限:太矮看不出是代码框,太高会把提示框挤出对话框。 + private const double MinPreview = 40; + private const double MaxPreview = 330; + + /// 用给定的视图模型初始化。 + internal NewCollectionDialogView(NewCollectionDialogViewModel viewModel) + { + InitializeComponent(); + DataContext = viewModel; + AttachedToVisualTree += (_, _) => Dispatcher.UIThread.Post(() => + { + NameBox.Focus(); + FitPreview(); + }); + NameBox.KeyDown += (_, e) => + { + if (e.Key == Key.Enter && viewModel.CanCreate) + { + viewModel.CreateCommand.Execute(null); + e.Handled = true; + } + }; + PreviewEditor.Editor.TextArea.TextView.VisualLinesChanged += (_, _) => FitPreview(); + } + + /// 设计器 / headless 用的无参构造。 + public NewCollectionDialogView() + { + InitializeComponent(); + } + + private void FitPreview() + { + // 文档高度 + 编辑器上下内边距(6 + 6)+ 一点余量,避免最后一行贴边。 + double wanted = PreviewEditor.Editor.TextArea.TextView.DocumentHeight + 20; + double height = Math.Clamp(wanted, MinPreview, MaxPreview); + // 放得下就不要滚动条(那一道竖线在只读预览里只是噪音);放不下才让它出来。 + PreviewEditor.Editor.VerticalScrollBarVisibility = wanted > MaxPreview + ? Avalonia.Controls.Primitives.ScrollBarVisibility.Auto + : Avalonia.Controls.Primitives.ScrollBarVisibility.Hidden; + if (Math.Abs(PreviewEditor.Height - height) > 0.5) + { + PreviewEditor.Height = height; + } + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Dialogs/NewCollectionDialogViewModel.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Dialogs/NewCollectionDialogViewModel.cs new file mode 100644 index 0000000..ba28418 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Dialogs/NewCollectionDialogViewModel.cs @@ -0,0 +1,893 @@ +using System.Collections.ObjectModel; +using System.Globalization; +using System.Text; +using MongoDB.Bson; +using MongoDB.Driver; +using VelaShell.Plugin.Mongo.Bson; +using VelaShell.Plugin.Mongo.Core; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 新建集合的五种形态(设计稿 17 那排卡片)。 +internal enum NewCollectionKind +{ + /// 普通集合。 + Plain, + + /// 时序集合。 + TimeSeries, + + /// 固定集合。 + Capped, + + /// 聚簇集合。 + Clustered, + + /// 视图。 + View +} + +/// 集合名的校验结论。 +internal enum CollectionNameState +{ + /// 还没填。 + Empty, + + /// 可用。 + Available, + + /// 同名集合 / 视图已存在。 + Exists, + + /// 不合法(含 $、以 system. 开头、命名空间超长…)。 + Invalid +} + +/// 下拉里的一个单位(天 = 86400 秒、MB = 1048576 字节)。 +/// 显示名。 +/// 换算到基本单位的倍数。 +internal sealed record NewCollUnit(string Label, long Factor) +{ + /// + public override string ToString() => Label; +} + +/// +/// 新建集合(设计稿 17)。左边填,右边实时显示**将执行的 mongosh 命令** —— +/// 预览与真正发出去的命令出自同一个 ,两者不可能对不上。 +/// +internal sealed class NewCollectionDialogViewModel : DialogViewModel +{ + private readonly HashSet _existing = new(StringComparer.Ordinal); + private string _name = ""; + private NewCollectionKind _kind = NewCollectionKind.Plain; + private string _timeField = "ts"; + private string _metaField = ""; + private string _granularity = "seconds"; + private string _expireValue = ""; + private NewCollUnit _expireUnit; + private string _cappedSize = "100"; + private NewCollUnit _cappedUnit; + private string _cappedMax = ""; + private string? _viewSource; + private string _pipelineText = "[\n { $match: {} }\n]"; + private bool _useCollation; + private string _collationLocale = "zh"; + private int _collationStrength = 2; + private bool _copyIndexes; + private string? _copyIndexesFrom; + private IReadOnlyList _sourceIndexes = []; + private bool _editValidationAfter; + private bool _isCreating; + private string _error = ""; + + /// 构造。 + /// 外壳服务。 + /// 在哪个库里建。 + /// 一打开就选中的卡片(对象树「视图」分组的右键「新建视图」直接落在视图卡上)。 + /// 视图的源集合(从某个集合 / 视图上右键新建视图时,预先填成它)。 + public NewCollectionDialogViewModel(IMongoWorkspace workspace, string database, + NewCollectionKind kind = NewCollectionKind.Plain, string? viewSource = null) + : base(workspace) + { + Database = database; + Title = workspace.Loc[kind == NewCollectionKind.View ? "Nav_NewView" : "Nav_NewCollection"]; + Subtitle = database; + _kind = kind; + _viewSource = viewSource; + ExpireUnits = + [ + new(Loc["NewColl_UnitSeconds"], 1), + new(Loc["NewColl_UnitMinutes"], 60), + new(Loc["NewColl_UnitHours"], 3_600), + new(Loc["NewColl_UnitDays"], 86_400) + ]; + _expireUnit = ExpireUnits[3]; + SizeUnits = [new("KB", 1L << 10), new("MB", 1L << 20), new("GB", 1L << 30)]; + _cappedUnit = SizeUnits[1]; + CreateCommand = new(CreateAsync, () => CanCreate); + foreach (CollectionInfo info in workspace.CollectionsOf(database)) + { + _existing.Add(info.Name); + } + FillSources(workspace.CollectionsOf(database)); + _ = LoadExistingAsync(); + } + + /// 库名。 + public string Database { get; } + + /// + public override string IconKey => "Mongo.plus"; + + /// + public override string IconToken => "VelaAccent"; + + /// + public override double Width => 940; + + /// + public override double Height => 560; + + /// + public override bool CanCloseWithEscape => !_isCreating; + + // ── 名称 ───────────────────────────────────────────────────────────── + + /// 集合名称。 + public string Name + { + get => _name; + set + { + if (SetProperty(ref _name, value)) + { + Changed(nameof(NameState), nameof(NameMessage), nameof(NameOk), nameof(NameBad), nameof(NameIconKey)); + } + } + } + + /// 名称校验结论。 + public CollectionNameState NameState => ValidateName(_name, Database, _existing, out _); + + /// 名称旁那行小字(✓ 可用 · shop.device_metrics / 已存在 · shop.orders / 非法原因)。 + public string NameMessage + { + get + { + CollectionNameState state = ValidateName(_name, Database, _existing, out string? reason); + string ns = $"{Database}.{_name.Trim()}"; + return state switch + { + CollectionNameState.Available => Loc.Format("NewColl_NameAvailable", ns), + CollectionNameState.Exists => Loc.Format("NewColl_NameExists", ns), + CollectionNameState.Invalid => Loc[reason ?? "NewColl_NameInvalid"], + _ => "" + }; + } + } + + /// 名称可用(绿)。 + public bool NameOk => NameState == CollectionNameState.Available; + + /// 名称不可用(红框)。 + public bool NameBad => NameState is CollectionNameState.Exists or CollectionNameState.Invalid; + + /// 名称提示的图标。 + public string NameIconKey => NameOk ? "Mongo.circle-check" : "Mongo.circle-x"; + + /// + /// 集合名规则(服务器的硬性约束,不是风格建议):不能空、不能含 $ 与空字符、 + /// 不能以 system. 开头、库.集合 不超过 255 字节;首尾空白多半是手滑,也拦下。 + /// + internal static CollectionNameState ValidateName(string name, string database, IReadOnlySet existing, out string? reasonKey) + { + reasonKey = null; + if (name.Length == 0) + { + return CollectionNameState.Empty; + } + if (name.Trim().Length != name.Length) + { + reasonKey = "NewColl_NameWhitespace"; + return CollectionNameState.Invalid; + } + if (name.Contains('$', StringComparison.Ordinal) || name.Contains('\0', StringComparison.Ordinal)) + { + reasonKey = "NewColl_NameDollar"; + return CollectionNameState.Invalid; + } + if (name.StartsWith("system.", StringComparison.Ordinal)) + { + reasonKey = "NewColl_NameSystem"; + return CollectionNameState.Invalid; + } + if (name.StartsWith('.') || name.EndsWith('.')) + { + reasonKey = "NewColl_NameDot"; + return CollectionNameState.Invalid; + } + if (Encoding.UTF8.GetByteCount($"{database}.{name}") > 255) + { + reasonKey = "NewColl_NameTooLong"; + return CollectionNameState.Invalid; + } + return existing.Contains(name) ? CollectionNameState.Exists : CollectionNameState.Available; + } + + // ── 类型 ───────────────────────────────────────────────────────────── + + /// 类型。 + public NewCollectionKind Kind + { + get => _kind; + set + { + if (SetProperty(ref _kind, value)) + { + Changed(nameof(IsPlain), nameof(IsTimeSeries), nameof(IsCapped), nameof(IsClustered), nameof(IsView), + nameof(OptionsTitle), nameof(OptionsNote), nameof(TipTitle), nameof(TipBody), nameof(CanEditValidation), + nameof(CanCopyIndexes)); + } + } + } + + /// 卡片:普通集合。 + public bool IsPlain { get => _kind == NewCollectionKind.Plain; set { if (value) { Kind = NewCollectionKind.Plain; } } } + + /// 卡片:时序集合。 + public bool IsTimeSeries { get => _kind == NewCollectionKind.TimeSeries; set { if (value) { Kind = NewCollectionKind.TimeSeries; } } } + + /// 卡片:固定集合。 + public bool IsCapped { get => _kind == NewCollectionKind.Capped; set { if (value) { Kind = NewCollectionKind.Capped; } } } + + /// 卡片:聚簇集合。 + public bool IsClustered { get => _kind == NewCollectionKind.Clustered; set { if (value) { Kind = NewCollectionKind.Clustered; } } } + + /// 卡片:视图。 + public bool IsView { get => _kind == NewCollectionKind.View; set { if (value) { Kind = NewCollectionKind.View; } } } + + /// 选项面板标题。 + public string OptionsTitle => Loc[_kind switch + { + NewCollectionKind.TimeSeries => "NewColl_OptTimeSeries", + NewCollectionKind.Capped => "NewColl_OptCapped", + NewCollectionKind.Clustered => "NewColl_OptClustered", + NewCollectionKind.View => "NewColl_OptView", + _ => "NewColl_OptPlain" + }]; + + /// 选项面板标题旁的灰字(创建后改不了的那几样)。 + public string OptionsNote => Loc[_kind switch + { + NewCollectionKind.TimeSeries => "NewColl_NoteTimeSeries", + NewCollectionKind.Capped => "NewColl_NoteCapped", + NewCollectionKind.Clustered => "NewColl_NoteClustered", + NewCollectionKind.View => "NewColl_NoteView", + _ => "NewColl_NotePlain" + }]; + + /// 右侧提示框标题。 + public string TipTitle => Loc[_kind switch + { + NewCollectionKind.TimeSeries => "NewColl_TipTimeSeriesTitle", + NewCollectionKind.Capped => "NewColl_TipCappedTitle", + NewCollectionKind.Clustered => "NewColl_TipClusteredTitle", + NewCollectionKind.View => "NewColl_TipViewTitle", + _ => "NewColl_TipPlainTitle" + }]; + + /// 右侧提示框正文。 + public string TipBody => Loc[_kind switch + { + NewCollectionKind.TimeSeries => "NewColl_TipTimeSeries", + NewCollectionKind.Capped => "NewColl_TipCapped", + NewCollectionKind.Clustered => "NewColl_TipClustered", + NewCollectionKind.View => "NewColl_TipView", + _ => "NewColl_TipPlain" + }]; + + // ── 时序选项 ───────────────────────────────────────────────────────── + + /// timeField(必填)。 + public string TimeField + { + get => _timeField; + set + { + if (SetProperty(ref _timeField, value)) + { + Changed(nameof(SampleDocument)); + } + } + } + + /// metaField(可选)。 + public string MetaField + { + get => _metaField; + set + { + if (SetProperty(ref _metaField, value)) + { + Changed(nameof(SampleDocument)); + } + } + } + + /// 粒度:seconds / minutes / hours。 + public string Granularity + { + get => _granularity; + set + { + if (SetProperty(ref _granularity, value)) + { + Changed(nameof(IsSeconds), nameof(IsMinutes), nameof(IsHours)); + } + } + } + + /// 分段:seconds。 + public bool IsSeconds { get => _granularity == "seconds"; set { if (value) { Granularity = "seconds"; } } } + + /// 分段:minutes。 + public bool IsMinutes { get => _granularity == "minutes"; set { if (value) { Granularity = "minutes"; } } } + + /// 分段:hours。 + public bool IsHours { get => _granularity == "hours"; set { if (value) { Granularity = "hours"; } } } + + /// 自动过期的数值(空 = 不过期)。 + public string ExpireValue + { + get => _expireValue; + set + { + if (SetProperty(ref _expireValue, value)) + { + Changed(nameof(ExpireSecondsText)); + } + } + } + + /// 自动过期的单位。 + public NewCollUnit ExpireUnit + { + get => _expireUnit; + set + { + if (value is not null && SetProperty(ref _expireUnit, value)) + { + Changed(nameof(ExpireSecondsText)); + } + } + } + + /// 单位下拉的选项。 + public IReadOnlyList ExpireUnits { get; } + + /// 换算后的秒数(= 7,776,000 s);填的不是正整数时为空。 + public string ExpireSecondsText => ExpireSeconds is { } s ? $"= {BsonText.Grouped(s)} s" : ""; + + /// 换算后的秒数;没填或填错为 。 + public long? ExpireSeconds => + long.TryParse(_expireValue.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out long n) + && n > 0 && n <= long.MaxValue / _expireUnit.Factor + ? n * _expireUnit.Factor + : null; + + /// 示例文档(按填的字段名现拼,让人一眼看出 timeField / metaField 落在文档的哪里)。 + public string SampleDocument + { + get + { + string time = _timeField.Trim().Length > 0 ? BsonText.FieldName(_timeField.Trim()) : "ts"; + string meta = _metaField.Trim().Length > 0 ? $", {BsonText.FieldName(_metaField.Trim())}: {{ … }}" : ""; + return $"{{ {time}: ISODate(…){meta}, value: 21.4 }}"; + } + } + + // ── 固定集合选项 ───────────────────────────────────────────────────── + + /// 容量上限的数值。 + public string CappedSize + { + get => _cappedSize; + set => SetAndRefresh(ref _cappedSize, value); + } + + /// 容量单位。 + public NewCollUnit CappedUnit + { + get => _cappedUnit; + set + { + if (value is not null) + { + SetAndRefresh(ref _cappedUnit, value); + } + } + } + + /// 容量单位选项。 + public IReadOnlyList SizeUnits { get; } + + /// 文档数上限(可选)。 + public string CappedMax + { + get => _cappedMax; + set => SetAndRefresh(ref _cappedMax, value); + } + + // ── 视图选项 ───────────────────────────────────────────────────────── + + /// 视图的源集合。 + public string? ViewSource + { + get => _viewSource; + set => SetAndRefresh(ref _viewSource, value); + } + + /// 视图的管道(mongosh 写法)。 + public string PipelineText + { + get => _pipelineText; + set => SetAndRefresh(ref _pipelineText, value ?? ""); + } + + /// 可选的源集合(集合与视图)。 + public ObservableCollection ViewSources { get; } = []; + + // ── 更多 ───────────────────────────────────────────────────────────── + + /// 指定排序规则。 + public bool UseCollation + { + get => _useCollation; + set => SetAndRefresh(ref _useCollation, value); + } + + /// 排序规则的 locale。 + public string CollationLocale + { + get => _collationLocale; + set => SetAndRefresh(ref _collationLocale, value); + } + + /// 排序规则的 strength(1–5)。 + public int CollationStrength + { + get => _collationStrength; + set => SetAndRefresh(ref _collationStrength, Math.Clamp(value, 1, 5)); + } + + /// strength 下拉的选项。 + public IReadOnlyList Strengths { get; } = [1, 2, 3, 4, 5]; + + /// 排序规则未勾选时那行灰字(locale: "zh", strength: 2)。 + public string CollationSummary => $"locale: {BsonText.Quote(_collationLocale)}, strength: {_collationStrength}"; + + /// 复制索引。 + public bool CopyIndexes + { + get => _copyIndexes; + set + { + if (!SetProperty(ref _copyIndexes, value)) + { + return; + } + if (value && _copyIndexesFrom is null && IndexSources.Count > 0) + { + CopyIndexesFrom = IndexSources[0]; + } + Changed(); + } + } + + /// 从哪个集合复制索引。 + public string? CopyIndexesFrom + { + get => _copyIndexesFrom; + set + { + if (SetProperty(ref _copyIndexesFrom, value)) + { + _sourceIndexes = []; + Changed(); + _ = LoadSourceIndexesAsync(value); + } + } + } + + /// 可复制索引的源集合(不含视图)。 + public ObservableCollection IndexSources { get; } = []; + + /// 复制索引可用(视图没有索引)。 + public bool CanCopyIndexes => _kind != NewCollectionKind.View; + + /// 创建后立即打开「验证规则」页。 + public bool EditValidationAfter + { + get => _editValidationAfter; + set => SetProperty(ref _editValidationAfter, value); + } + + /// 「创建后编辑验证规则」可用(时序集合与视图不支持验证规则)。 + public bool CanEditValidation => _kind is not (NewCollectionKind.TimeSeries or NewCollectionKind.View); + + // ── 预览与创建 ─────────────────────────────────────────────────────── + + /// 右侧「将执行的命令」。 + public string Preview + { + get + { + string name = _name.Trim().Length > 0 ? _name.Trim() : Loc["NewColl_NamePlaceholder"]; + BsonDocument command = BuildCreateCommand(lenient: true); + if (_kind == NewCollectionKind.View) + { + BsonDocument? collation = command.TryGetValue("collation", out BsonValue c) ? c.AsBsonDocument : null; + return ObjectScripts.CreateView(name, _viewSource ?? "", command.GetValue("pipeline", new BsonArray()).AsBsonArray, collation); + } + var options = new BsonDocument(command.Where(static e => e.Name != "create")); + var lines = new List { ObjectScripts.CreateCollection(name, options) }; + if (_copyIndexes && CanCopyIndexes) + { + lines.AddRange(_sourceIndexes.Where(ObjectScripts.IsReplayable).Select(i => ObjectScripts.CreateIndex(name, i))); + } + return string.Join("\n", lines); + } + } + + /// 选项里的错误(时间字段没填、管道写错…);空 = 没问题。 + public string OptionsError + { + get + { + switch (_kind) + { + case NewCollectionKind.TimeSeries: + { + string time = _timeField.Trim(); + string meta = _metaField.Trim(); + if (time.Length == 0) + { + return Loc["NewColl_ErrTimeField"]; + } + if (time.Contains('$', StringComparison.Ordinal) || time.Contains('.', StringComparison.Ordinal) || time == "_id") + { + return Loc["NewColl_ErrFieldName"]; + } + if (meta.Length > 0 && (meta == time || meta == "_id" || meta.Contains('$', StringComparison.Ordinal) || meta.Contains('.', StringComparison.Ordinal))) + { + return Loc["NewColl_ErrMetaField"]; + } + if (_expireValue.Trim().Length > 0 && ExpireSeconds is null) + { + return Loc["NewColl_ErrExpire"]; + } + break; + } + case NewCollectionKind.Capped: + if (CappedBytes is null) + { + return Loc["NewColl_ErrSize"]; + } + if (_cappedMax.Trim().Length > 0 && CappedMaxDocs is null) + { + return Loc["NewColl_ErrMax"]; + } + break; + case NewCollectionKind.View: + if (string.IsNullOrEmpty(_viewSource)) + { + return Loc["NewColl_ErrViewSource"]; + } + if (!TryParsePipeline(out _, out string? error)) + { + return Loc.Format("NewColl_ErrPipeline", error); + } + break; + } + if (_useCollation && _collationLocale.Trim().Length == 0) + { + return Loc["NewColl_ErrCollation"]; + } + if (_copyIndexes && CanCopyIndexes && _copyIndexesFrom is null) + { + return Loc["NewColl_ErrCopySource"]; + } + return ""; + } + } + + /// 能不能点「创建集合」。 + public bool CanCreate => !_isCreating && NameState == CollectionNameState.Available && OptionsError.Length == 0; + + /// 正在创建。 + public bool IsCreating + { + get => _isCreating; + private set + { + if (SetProperty(ref _isCreating, value)) + { + Changed(); + } + } + } + + /// 底栏左侧的错误(选项错误或服务器拒绝的原因)。 + public string Error + { + get => _error.Length > 0 ? _error : OptionsError; + private set + { + if (SetProperty(ref _error, value)) + { + RaisePropertyChanged(nameof(HasError)); + } + } + } + + /// 底栏有没有错误。 + public bool HasError => Error.Length > 0; + + /// 创建。 + public AsyncCommand CreateCommand { get; } + + private long? CappedBytes => + double.TryParse(_cappedSize.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out double n) && n > 0 + ? (long)Math.Ceiling(n * _cappedUnit.Factor) + : null; + + private long? CappedMaxDocs => + long.TryParse(_cappedMax.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out long n) && n > 0 ? n : null; + + private bool TryParsePipeline(out BsonArray pipeline, out string? error) + { + try + { + pipeline = ShellJson.ParseArray(_pipelineText); + if (pipeline.Any(static s => !s.IsBsonDocument || s.AsBsonDocument.ElementCount != 1)) + { + error = Loc["NewColl_ErrStage"]; + return false; + } + error = null; + return true; + } + catch (ShellJsonException ex) + { + pipeline = []; + error = ex.Message; + return false; + } + } + + /// + /// 要发给服务器的 create 命令。 为真时(预览用)填错的项直接略过, + /// 让预览一直有东西可看;真正创建时选项错误早已拦在 上。 + /// + internal BsonDocument BuildCreateCommand(bool lenient = false) + { + var command = new BsonDocument("create", _name.Trim()); + switch (_kind) + { + case NewCollectionKind.TimeSeries: + { + var ts = new BsonDocument("timeField", _timeField.Trim()); + if (_metaField.Trim().Length > 0) + { + ts.Add("metaField", _metaField.Trim()); + } + ts.Add("granularity", _granularity); + command.Add("timeseries", ts); + if (ExpireSeconds is { } seconds) + { + command.Add("expireAfterSeconds", Number(seconds)); + } + break; + } + case NewCollectionKind.Capped: + command.Add("capped", true); + command.Add("size", Number(CappedBytes ?? 0)); + if (CappedMaxDocs is { } max) + { + command.Add("max", Number(max)); + } + break; + case NewCollectionKind.Clustered: + command.Add("clusteredIndex", new BsonDocument + { + { "key", new BsonDocument("_id", 1) }, + { "unique", true } + }); + break; + case NewCollectionKind.View: + command.Add("viewOn", _viewSource ?? ""); + command.Add("pipeline", TryParsePipeline(out BsonArray pipeline, out _) || lenient ? pipeline : []); + break; + } + if (_useCollation && _collationLocale.Trim().Length > 0) + { + command.Add("collation", new BsonDocument + { + { "locale", _collationLocale.Trim() }, + { "strength", _collationStrength } + }); + } + return command; + } + + private static BsonValue Number(long value) => + value is >= int.MinValue and <= int.MaxValue ? new BsonInt32((int)value) : new BsonInt64(value); + + /// 创建(「创建集合」按钮;单测直接等它)。 + internal async Task CreateAsync() + { + if (!CanCreate || !Workspace.EnsureWritable(Database)) + { + return; + } + string name = _name.Trim(); + NewCollectionKind kind = _kind; + bool editValidation = _editValidationAfter && CanEditValidation; + IsCreating = true; + Error = ""; + try + { + await Workspace.Connection.RunCommandAsync(Database, BuildCreateCommand()).ConfigureAwait(true); + (int copied, List failed) = _copyIndexes && CanCopyIndexes && _copyIndexesFrom is { } source + ? await CopyIndexesAsync(source, name).ConfigureAwait(true) + : (0, []); + Workspace.Toast(new() + { + Title = Loc.Format("NewColl_Created", $"{Database}.{name}"), + Detail = failed.Count > 0 + ? Loc.Format("NewColl_IndexesFailed", copied, string.Join(", ", failed)) + : copied > 0 ? Loc.Format("NewColl_IndexesCopied", copied) : null, + Kind = failed.Count > 0 ? ToastKind.Warning : ToastKind.Success + }); + ObjectsChanged.Raise(Workspace, Database); + Close(); + await Workspace.RefreshTreeAsync(Database).ConfigureAwait(true); + if (kind != NewCollectionKind.View && editValidation) + { + Workspace.OpenDesign(Database, name, DesignPage.Validation); + } + else + { + Workspace.OpenCollection(Database, name); + } + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Error = Loc.Format("Common_Failed", MongoConnector.Describe(ex)); + } + finally + { + IsCreating = false; + } + } + + /// + /// 把源集合的二级索引逐个建到新集合上。逐个建而不是一条 createIndexes 全包: + /// 时序集合不支持某些索引形态(如唯一索引),一条失败不该连累其余。 + /// + private async Task<(int Copied, List Failed)> CopyIndexesAsync(string source, string target) + { + IReadOnlyList indexes = await Workspace.Connection.ListIndexesAsync(Database, source).ConfigureAwait(true); + int copied = 0; + var failed = new List(); + foreach (BsonDocument index in indexes.Where(ObjectScripts.IsReplayable)) + { + var spec = new BsonDocument("key", index["key"]); + spec.AddRange(ObjectScripts.IndexOptions(index)); + try + { + await Workspace.Connection.RunCommandAsync(Database, new BsonDocument + { + { "createIndexes", target }, + { "indexes", new BsonArray { spec } } + }).ConfigureAwait(true); + copied++; + } + catch (MongoCommandException) + { + failed.Add(index.GetValue("name", "?").ToString() ?? "?"); + } + } + return (copied, failed); + } + + // ── 加载 ───────────────────────────────────────────────────────────── + + /// 重新列一次集合:对象树可能还没展开过这个库,"已存在"不能只看树上那份。 + private async Task LoadExistingAsync() + { + try + { + IReadOnlyList all = await Workspace.Connection.ListCollectionsAsync(Database).ConfigureAwait(true); + _existing.Clear(); + foreach (CollectionInfo info in all) + { + _existing.Add(info.Name); + } + FillSources(all); + Changed(nameof(NameState), nameof(NameMessage), nameof(NameOk), nameof(NameBad), nameof(NameIconKey)); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + // 列不出来就用对象树上那份;创建时服务器自己会拒绝重名。 + } + } + + private void FillSources(IReadOnlyList all) + { + string? keepView = _viewSource; + ViewSources.Clear(); + IndexSources.Clear(); + foreach (CollectionInfo info in all.Where(static c => c.Kind != CollectionKind.System).OrderBy(static c => c.Name, StringComparer.Ordinal)) + { + ViewSources.Add(info.Name); + if (info.Kind is not (CollectionKind.View or CollectionKind.TimeSeries)) + { + IndexSources.Add(info.Name); + } + } + if (keepView is not null && ViewSources.Contains(keepView)) + { + ViewSource = keepView; + } + else if (ViewSources.Count > 0 && _viewSource is null) + { + ViewSource = ViewSources.FirstOrDefault(static n => !n.Contains('.', StringComparison.Ordinal)) ?? ViewSources[0]; + } + } + + private async Task LoadSourceIndexesAsync(string? source) + { + if (source is null) + { + return; + } + try + { + IReadOnlyList indexes = await Workspace.Connection.ListIndexesAsync(Database, source).ConfigureAwait(true); + if (_copyIndexesFrom == source) + { + _sourceIndexes = indexes; + Changed(); + } + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + } + } + + private void SetAndRefresh(ref T field, T value, [System.Runtime.CompilerServices.CallerMemberName] string? name = null) + { + if (SetProperty(ref field, value, name)) + { + Changed(name == nameof(UseCollation) || name == nameof(CollationLocale) || name == nameof(CollationStrength) + ? nameof(CollationSummary) + : null); + } + } + + /// 任何输入变了:预览、校验与按钮可用性一起重算(它们都是派生值,算一次很便宜)。 + private void Changed(params string?[] extra) + { + foreach (string? name in extra) + { + if (name is not null) + { + RaisePropertyChanged(name); + } + } + RaisePropertiesChanged(nameof(Preview), nameof(OptionsError), nameof(Error), nameof(HasError), nameof(CanCreate)); + CreateCommand.RaiseCanExecuteChanged(); + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/DimExtension.cs b/plugins/VelaShell.Plugin.Mongo/Ui/DimExtension.cs new file mode 100644 index 0000000..61e8fcf --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/DimExtension.cs @@ -0,0 +1,34 @@ +using Avalonia.Markup.Xaml; +using Avalonia.Media; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// {ui:Dim VelaWarning, 0.12}:宿主令牌按固定透明度淡化后的画刷(见 )。 +/// 设计稿的已修改单元格、TTL 徽章、警告条都是"语义色 + 12% 淡底"这一种写法。 +/// +public sealed class DimExtension : MarkupExtension +{ + /// 无参构造(XAML 用)。 + public DimExtension() + { + } + + /// 构造。 + /// 宿主令牌名。 + /// 不透明度。 + public DimExtension(string token, double opacity) + { + Token = token; + Opacity = opacity; + } + + /// 宿主令牌名。 + public string Token { get; set; } = "VelaAccent"; + + /// 不透明度(默认 0.12,与设计稿的 *Dim 一致)。 + public double Opacity { get; set; } = 0.12; + + /// + public override object ProvideValue(IServiceProvider serviceProvider) => ThemeBrushes.GetDim(Token, Opacity); +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Editor/CodeEditor.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Editor/CodeEditor.cs new file mode 100644 index 0000000..d133183 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Editor/CodeEditor.cs @@ -0,0 +1,774 @@ +using System.Xml; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.Threading; +using AvaloniaEdit; +using AvaloniaEdit.Highlighting; +using AvaloniaEdit.Highlighting.Xshd; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 带语法高亮、补全、诊断与整行标记的代码编辑框(查询编辑器、JSON 视图、管道阶段、验证规则、 +/// 文档编辑器的 JSON 页共用)。 +/// +/// 里面是 AvaloniaEdit 的 (与 DockerPanel 同一条路:只在编译期引用, +/// 运行时回落宿主那一份)。外面只露出界面要的那几样:文本(双向)、语言、只读、诊断、整行标记、 +/// 补全来源、光标位置。为什么不是 TextBox:TextBox 的整块文本共用一个前景色,按 token 着色、 +/// 波浪线、补全弹层它都给不了 —— 而查询编辑器的价值恰恰就在这几样上。 +/// +/// +public sealed class CodeEditor : UserControl +{ + private static readonly Lock RegistrationGate = new(); + private static bool _registered; + + private readonly TextEditor _editor; + private readonly DecorationRenderer _decorations; + private readonly MarkerMargin _markers; + private readonly Popup _popup; + private readonly CompletionPopupView _popupView; + private readonly EventHandler _onAppResourcesChanged; + private CompletionSession? _session; + private CancellationTokenSource? _completionRequest; + 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), CodeLanguage.Shell); + + /// 只读。 + 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 static readonly StyledProperty HighlightCurrentLineProperty = + AvaloniaProperty.Register(nameof(HighlightCurrentLine), true); + + /// 诊断。 + public static readonly StyledProperty?> DiagnosticsProperty = + AvaloniaProperty.Register?>(nameof(Diagnostics)); + + /// 整行标记。 + public static readonly StyledProperty?> LineMarksProperty = + AvaloniaProperty.Register?>(nameof(LineMarks)); + + /// 补全来源;为 null 即不提供补全。 + public static readonly StyledProperty>?> CompletionProviderProperty = + AvaloniaProperty.Register>?>(nameof(CompletionProvider)); + + /// + /// 按内容定高(放在 StackPanel 这种给无限高的容器里时用:metadata、阶段体、命令预览)。 + /// 开着时不出竖向滚动条,高度 = 行数 × 行高 + 内边距,并受 限制。 + /// + public static readonly StyledProperty FitContentProperty = + AvaloniaProperty.Register(nameof(FitContent)); + + /// 按内容定高时最多显示几行(超出则出滚动条)。 + public static readonly StyledProperty MaxFitLinesProperty = + AvaloniaProperty.Register(nameof(MaxFitLines), 30); + + /// 光标位置(双向:视图模型可以读,也可以设来移动光标)。 + public static readonly StyledProperty CaretOffsetProperty = + AvaloniaProperty.Register(nameof(CaretOffset), defaultBindingMode: Avalonia.Data.BindingMode.TwoWay); + + /// 光标所在行(1 起,只读)。 + public static readonly StyledProperty CaretLineProperty = + AvaloniaProperty.Register(nameof(CaretLine), 1, defaultBindingMode: Avalonia.Data.BindingMode.OneWayToSource); + + /// 光标所在列(1 起,只读)。 + public static readonly StyledProperty CaretColumnProperty = + AvaloniaProperty.Register(nameof(CaretColumn), 1, defaultBindingMode: Avalonia.Data.BindingMode.OneWayToSource); + + /// 构造。 + public CodeEditor() + { + _editor = new TextEditor + { + Background = Brushes.Transparent, + BorderThickness = new(0), + Padding = new(8, 6), + ShowLineNumbers = true, + HorizontalScrollBarVisibility = ScrollBarVisibility.Auto, + VerticalScrollBarVisibility = ScrollBarVisibility.Auto + }; + _editor.Options.IndentationSize = 2; + _editor.Options.ConvertTabsToSpaces = true; + _editor.Options.HighlightCurrentLine = true; + _editor.Options.EnableHyperlinks = false; + _editor.Options.EnableEmailHyperlinks = false; + // 文档末尾之下不留空白可滚:小块编辑器(阶段体、metadata、筛选框)里那一截空白只会让内容跑出视野。 + _editor.Options.AllowScrollBelowDocument = false; + _editor.TextArea.TextView.LinkTextForegroundBrush = Brushes.Transparent; + + _decorations = new DecorationRenderer(Brush, () => FontFamily, () => FontSize); + _editor.TextArea.TextView.BackgroundRenderers.Add(_decorations); + _markers = new MarkerMargin(Brush); + _editor.TextArea.LeftMargins.Insert(0, _markers); + + _editor.TextChanged += (_, _) => + { + if (FitContent) + { + InvalidateMeasure(); + } + if (_syncing) + { + return; + } + _syncing = true; + SetCurrentValue(TextProperty, _editor.Text); + _syncing = false; + }; + _editor.TextArea.Caret.PositionChanged += (_, _) => + { + AvaloniaEdit.Document.TextLocation location = _editor.TextArea.Caret.Location; + _syncing = true; + SetCurrentValue(CaretOffsetProperty, _editor.CaretOffset); + _syncing = false; + SetCurrentValue(CaretLineProperty, location.Line); + SetCurrentValue(CaretColumnProperty, location.Column); + CaretMoved?.Invoke(this, EventArgs.Empty); + if (_session is not null && (_editor.CaretOffset < _session.ReplaceOffset)) + { + ClosePopup(); + } + }; + _editor.TextArea.TextEntered += OnTextEntered; + _editor.TextArea.AddHandler(KeyDownEvent, OnEditorKeyDown, RoutingStrategies.Tunnel); + _editor.TextArea.LostFocus += (_, _) => Dispatcher.UIThread.Post(() => + { + if (_popupView?.IsKeyboardFocusWithin != true && !_editor.TextArea.IsKeyboardFocusWithin) + { + ClosePopup(); + } + }); + + // DataContext 显式置空:弹层挂在本控件下面,不设就继承宿主视图的视图模型(集合标签、检查器…), + // 而弹层的编译绑定按 CompletionSession 转型 —— 每个编辑框一出现就是一串绑定错误。打开时才换成补全会话。 + _popupView = new CompletionPopupView { DataContext = null }; + _popupView.ItemClicked += item => + { + if (_session is not null) + { + _session.Selected = item; + Accept(); + _editor.TextArea.Focus(); + } + }; + _popup = new Popup + { + Child = _popupView, + PlacementTarget = _editor.TextArea.TextView, + Placement = PlacementMode.AnchorAndGravity, + PlacementAnchor = Avalonia.Controls.Primitives.PopupPositioning.PopupAnchor.BottomLeft, + PlacementGravity = Avalonia.Controls.Primitives.PopupPositioning.PopupGravity.BottomRight, + IsLightDismissEnabled = false + }; + + var host = new Panel(); + host.Children.Add(_editor); + host.Children.Add(_popup); + Content = host; + Focusable = false; + + // 默认字体与前景按**样式优先级**绑:调用方在 AXAML 里写的 FontSize / FontFamily(本地值)要能盖过它。 + Bind(FontFamilyProperty, this.GetResourceObservable("VelaUiMonoFont"), Avalonia.Data.BindingPriority.Style); + Bind(FontSizeProperty, this.GetResourceObservable("VelaFontSize12"), Avalonia.Data.BindingPriority.Style); + Bind(ForegroundProperty, this.GetResourceObservable("VelaTextPrimary"), Avalonia.Data.BindingPriority.Style); + + // 高亮配色是一次性取值(xshd 要 Color 不要 Brush),换肤时必须有人叫醒它 —— + // 应用级资源变更覆盖全部三种换肤情形(与 DockerPanel 的 CodeEditor 同一个理由)。 + ActualThemeVariantChanged += (_, _) => ApplyLanguage(); + _onAppResourcesChanged = (_, _) => ApplyLanguage(); + } + + /// 光标动了。 + public event EventHandler? CaretMoved; + + /// 里面那个编辑器(查询编辑器要直接用它取选区、按语句定位)。 + public TextEditor Editor => _editor; + + /// + 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); + } + + /// + public bool HighlightCurrentLine + { + get => GetValue(HighlightCurrentLineProperty); + set => SetValue(HighlightCurrentLineProperty, value); + } + + /// + public IReadOnlyList? Diagnostics + { + get => GetValue(DiagnosticsProperty); + set => SetValue(DiagnosticsProperty, value); + } + + /// + public IReadOnlyList? LineMarks + { + get => GetValue(LineMarksProperty); + set => SetValue(LineMarksProperty, value); + } + + /// + public Func>? CompletionProvider + { + get => GetValue(CompletionProviderProperty); + set => SetValue(CompletionProviderProperty, value); + } + + /// + public bool FitContent + { + get => GetValue(FitContentProperty); + set => SetValue(FitContentProperty, value); + } + + /// + public int MaxFitLines + { + get => GetValue(MaxFitLinesProperty); + set => SetValue(MaxFitLinesProperty, value); + } + + /// + protected override Size MeasureOverride(Size availableSize) + { + Size measured = base.MeasureOverride(availableSize); + if (!FitContent) + { + return measured; + } + double lineHeight = _editor.TextArea.TextView.DefaultLineHeight; + if (double.IsNaN(lineHeight) || lineHeight <= 0) + { + lineHeight = FontSize * 1.4; + } + int lines = Math.Clamp(_editor.Document.LineCount, 1, Math.Max(1, MaxFitLines)); + double height = (lines * lineHeight) + _editor.Padding.Top + _editor.Padding.Bottom + 2; + return new Size(measured.Width, Math.Min(height, availableSize.Height)); + } + + /// + public int CaretOffset + { + get => GetValue(CaretOffsetProperty); + set => SetValue(CaretOffsetProperty, value); + } + + /// + public int CaretLine + { + get => GetValue(CaretLineProperty); + set => SetValue(CaretLineProperty, value); + } + + /// + public int CaretColumn + { + get => GetValue(CaretColumnProperty); + set => SetValue(CaretColumnProperty, value); + } + + /// 选中的文本(没选中为空串)。 + public string SelectedText => _editor.SelectedText; + + /// 在光标处插入文本(| 标出插入后光标落点)。 + public void InsertAtCaret(string text) => Replace(_editor.CaretOffset, 0, text); + + /// 替换一段文本(| 标出替换后光标落点)。 + public void Replace(int offset, int length, string text) + { + int caret = text.IndexOf('|', StringComparison.Ordinal); + string clean = caret >= 0 ? text.Remove(caret, 1) : text; + offset = Math.Clamp(offset, 0, _editor.Document.TextLength); + length = Math.Clamp(length, 0, _editor.Document.TextLength - offset); + _editor.Document.Replace(offset, length, clean); + _editor.CaretOffset = offset + (caret >= 0 ? caret : clean.Length); + } + + /// + /// 原样替换一段文本(不认 | 光标标记 —— a || b、正则里的竖线都按字面写进去)。 + /// + /// 起点。 + /// 长度。 + /// 新文本。 + /// 替换后光标落在新文本里的位置;为 null 则落在末尾。 + public void ReplaceRaw(int offset, int length, string text, int? caretInText = null) + { + offset = Math.Clamp(offset, 0, _editor.Document.TextLength); + length = Math.Clamp(length, 0, _editor.Document.TextLength - offset); + _editor.Document.Replace(offset, length, text); + _editor.CaretOffset = offset + Math.Clamp(caretInText ?? text.Length, 0, text.Length); + } + + /// 选中一段。 + public void Select(int offset, int length) + { + offset = Math.Clamp(offset, 0, _editor.Document.TextLength); + _editor.Select(offset, Math.Clamp(length, 0, _editor.Document.TextLength - offset)); + } + + /// 滚到某一行。 + public void ScrollToLine(int line) => _editor.ScrollToLine(Math.Max(1, line)); + + /// 把焦点给编辑区。 + public void FocusEditor() => _editor.TextArea.Focus(); + + /// + /// 应用光标所在(或光标所在行第一条)诊断的一键修复;没有可修的返回 false。 + /// + public bool TryApplyFix() + { + if (Diagnostics is not { Count: > 0 } diagnostics) + { + return false; + } + int caret = _editor.CaretOffset; + int line = _editor.Document.GetLineByOffset(caret).LineNumber; + EditorDiagnostic? fix = diagnostics.FirstOrDefault(d => d.FixText is not null && caret >= d.Offset && caret <= d.Offset + d.Length) + ?? diagnostics.FirstOrDefault(d => d.FixText is not null + && d.Offset <= _editor.Document.TextLength + && _editor.Document.GetLineByOffset(d.Offset).LineNumber == line); + if (fix is null) + { + return false; + } + ReplaceRaw(fix.Offset, fix.Length, fix.FixText!); + return true; + } + + /// 主动弹出补全(Ctrl+Space 的同一条路)。 + public void RequestCompletion() => _ = RequestCompletionAsync(explicitRequest: true); + + /// + /// 补全弹层开着。外面在编辑区上另挂按键处理的(筛选框的「Enter 即查找」)要先看它: + /// 同一元素上的隧道处理器按**注册的逆序**被调用,后挂的会抢在这里的 Enter / Tab 接受补全之前。 + /// + public bool IsCompletionOpen => _session is not null; + + /// + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == TextProperty && !_syncing) + { + _syncing = true; + string incoming = Text ?? ""; + // 只在真不一样时才写:同一份文本写回去会把光标顶到开头,用户打一个字就跳一次。 + if (_editor.Text != incoming) + { + _editor.Text = incoming; + } + _syncing = false; + } + else if (change.Property == CaretOffsetProperty && !_syncing) + { + _editor.CaretOffset = Math.Clamp(CaretOffset, 0, _editor.Document.TextLength); + } + 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; + } + else if (change.Property == FitContentProperty || change.Property == MaxFitLinesProperty) + { + _editor.VerticalScrollBarVisibility = FitContent && _editor.Document.LineCount <= MaxFitLines + ? ScrollBarVisibility.Disabled + : ScrollBarVisibility.Auto; + InvalidateMeasure(); + } + else if (change.Property == HighlightCurrentLineProperty) + { + _editor.Options.HighlightCurrentLine = HighlightCurrentLine; + } + else if (change.Property == DiagnosticsProperty || change.Property == LineMarksProperty) + { + _decorations.Diagnostics = Diagnostics ?? []; + _decorations.LineMarks = LineMarks ?? []; + _markers.Update(_editor.Document, _decorations.Diagnostics, _decorations.LineMarks); + _editor.TextArea.TextView.InvalidateLayer(AvaloniaEdit.Rendering.KnownLayer.Background); + } + } + + /// + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + if (Application.Current is { } app) + { + app.ResourcesChanged += _onAppResourcesChanged; + } + ApplyLanguage(); + } + + /// + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + if (Application.Current is { } app) + { + app.ResourcesChanged -= _onAppResourcesChanged; + } + ClosePopup(); + base.OnDetachedFromVisualTree(e); + } + + // ── 补全 ──────────────────────────────────────────────────────────────── + + private void OnTextEntered(object? sender, TextInputEventArgs e) + { + if (CompletionProvider is null || IsReadOnly || string.IsNullOrEmpty(e.Text)) + { + return; + } + char typed = e.Text[^1]; + if (_session is not null) + { + if (IsWordChar(typed) || typed is '"' or '\'') + { + if (!_session.Filter(CurrentPrefix())) + { + ClosePopup(); + } + else + { + _popupView.Reveal(_session.Selected); + } + return; + } + ClosePopup(); + } + // 自动弹出的时机:敲下 $ / . / 引号 / 冒号后的空格,或在一个词的第一个字母。 + if (typed is '$' or '.' or '"' or '\'' || (IsWordChar(typed) && CurrentPrefix().Length == 1) || typed == ' ' && PreviousNonSpace() == ':') + { + _ = RequestCompletionAsync(explicitRequest: false); + } + } + + private void OnEditorKeyDown(object? sender, KeyEventArgs e) + { + bool control = e.KeyModifiers.HasFlag(KeyModifiers.Control); + if (control && e.Key == Key.Space) + { + if (_session is not null) + { + _session.ToggleDetail(); + } + else + { + _ = RequestCompletionAsync(explicitRequest: true); + } + e.Handled = true; + return; + } + // Alt+⏎:应用光标所在诊断的一键修复(行尾提示里写着的那个)。 + if (e.Key == Key.Enter && e.KeyModifiers.HasFlag(KeyModifiers.Alt) && !IsReadOnly && TryApplyFix()) + { + e.Handled = true; + return; + } + if (_session is null) + { + return; + } + switch (e.Key) + { + case Key.Down: + _session.Move(1); + _popupView.Reveal(_session.Selected); + e.Handled = true; + break; + case Key.Up: + _session.Move(-1); + _popupView.Reveal(_session.Selected); + e.Handled = true; + break; + case Key.PageDown: + _session.Move(8); + _popupView.Reveal(_session.Selected); + e.Handled = true; + break; + case Key.PageUp: + _session.Move(-8); + _popupView.Reveal(_session.Selected); + e.Handled = true; + break; + case Key.Enter or Key.Tab: + Accept(); + e.Handled = true; + break; + case Key.Escape: + ClosePopup(); + e.Handled = true; + break; + case Key.Back: + Dispatcher.UIThread.Post(() => + { + if (_session is not null && (_editor.CaretOffset <= _session.ReplaceOffset || !_session.Filter(CurrentPrefix()))) + { + ClosePopup(); + } + }); + break; + } + } + + private async Task RequestCompletionAsync(bool explicitRequest) + { + if (CompletionProvider is not { } provider) + { + return; + } + _completionRequest?.Cancel(); + _completionRequest?.Dispose(); + var cts = new CancellationTokenSource(); + _completionRequest = cts; + CompletionSet? set; + try + { + set = await provider(new CompletionRequest(_editor.Text, _editor.CaretOffset, explicitRequest)).ConfigureAwait(true); + } + catch (Exception) + { + return; + } + if (cts.IsCancellationRequested || set is null || set.Items.Count == 0) + { + return; + } + int prefixLength = Math.Max(0, _editor.CaretOffset - set.ReplaceOffset); + string prefix = _editor.Document.GetText(Math.Clamp(set.ReplaceOffset, 0, _editor.Document.TextLength), + Math.Min(prefixLength, _editor.Document.TextLength - set.ReplaceOffset)); + var session = new CompletionSession(set) { InitialPrefix = prefix }; + if (prefix.Length > 0 && !session.Filter(prefix)) + { + return; + } + _session = session; + _popupView.DataContext = session; + PlacePopup(); + _popup.IsOpen = true; + } + + private void PlacePopup() + { + Rect caret = _editor.TextArea.Caret.CalculateCaretRectangle(); + Vector scroll = _editor.TextArea.TextView.ScrollOffset; + _popup.PlacementRect = new Rect(caret.X - scroll.X, caret.Y - scroll.Y, Math.Max(1, caret.Width), caret.Height); + } + + private void Accept() + { + if (_session?.Selected is not { } item) + { + ClosePopup(); + return; + } + int start = _session.ReplaceOffset; + int length = Math.Max(0, _editor.CaretOffset - start); + ClosePopup(); + Replace(start, length, item.InsertText ?? item.Label); + } + + private void ClosePopup() + { + _completionRequest?.Cancel(); + _session = null; + _popup.IsOpen = false; + } + + private string CurrentPrefix() + { + if (_session is null) + { + int start = _editor.CaretOffset; + while (start > 0 && IsWordChar(_editor.Document.GetCharAt(start - 1))) + { + start--; + } + return _editor.Document.GetText(start, _editor.CaretOffset - start); + } + int from = Math.Clamp(_session.ReplaceOffset, 0, _editor.Document.TextLength); + return _editor.Document.GetText(from, Math.Max(0, _editor.CaretOffset - from)); + } + + private char PreviousNonSpace() + { + for (int i = _editor.CaretOffset - 1; i >= 0; i--) + { + char c = _editor.Document.GetCharAt(i); + if (!char.IsWhiteSpace(c)) + { + return c; + } + } + return '\0'; + } + + private static bool IsWordChar(char c) => char.IsLetterOrDigit(c) || c is '_' or '$' or '.'; + + // ── 配色 ──────────────────────────────────────────────────────────────── + + private IBrush Brush(string token) => ThemeBrushes.Get(token, Brushes.Gray); + + private void ApplyLanguage() + { + _editor.FontFamily = FontFamily; + _editor.FontSize = FontSize; + if (Foreground is { } foreground) + { + _editor.Foreground = foreground; + } + _editor.TextArea.Caret.CaretBrush = Brush("VelaAccent"); + _editor.TextArea.SelectionBrush = ThemeBrushes.GetDim("VelaAccent", 0.25); + _editor.TextArea.SelectionBorder = null; + _editor.TextArea.SelectionForeground = null; + _editor.LineNumbersForeground = Brush("VelaTextMuted"); + _editor.TextArea.TextView.CurrentLineBackground = ThemeBrushes.GetDim("VelaTextPrimary", 0.04); + _editor.TextArea.TextView.CurrentLineBorder = null; + _editor.SyntaxHighlighting = Definition(Language) is { } definition ? Recolor(definition) : null; + } + + private IHighlightingDefinition Recolor(IHighlightingDefinition definition) + { + foreach (HighlightingColor color in definition.NamedHighlightingColors) + { + if (Role(color.Name) is { } token && Resource(token) is ISolidColorBrush brush) + { + color.Foreground = new SimpleHighlightingBrush(brush.Color); + } + } + return definition; + } + + /// xshd 里的角色名 → 宿主令牌名(与设计稿的语法着色逐条对应,见 Syntax/Mongosh.xshd 头注)。 + internal static string? Role(string name) => name switch + { + "Comment" or "Null" => "VelaTextTertiary", + "String" => "VelaShellCyan", + "FieldPath" => "VelaWarning", + "Number" => "VelaShellGreen", + "Keyword" => "VelaAccent", + "Method" or "Bool" => "VelaShellYellow", + "Operator" => "VelaShellMagenta", + "Constructor" => "VelaInfo", + "ObjectIdCtor" => "VelaShellBlue", + "Key" => "VelaTextSecondary", + _ => null + }; + + private object? Resource(string key) => + this.TryFindResource(key, ActualThemeVariant, out object? value) ? value : null; + + /// + /// 语法定义随插件自带(嵌入资源),注册名带 Mongo. 前缀: + /// 是整个进程共享的,宿主与 DockerPanel 也往里注册 —— 不加前缀就会互相顶掉。 + /// + private static IHighlightingDefinition? Definition(CodeLanguage language) + { + if (language == CodeLanguage.None) + { + return null; + } + EnsureRegistered(); + return HighlightingManager.Instance.GetDefinition(language == CodeLanguage.Json ? "Mongo.Json" : "Mongo.Mongosh"); + } + + private static void EnsureRegistered() + { + lock (RegistrationGate) + { + if (_registered) + { + return; + } + _registered = true; + System.Reflection.Assembly assembly = typeof(CodeEditor).Assembly; + foreach ((string resource, string[] extensions) in new[] + { + ("VelaShell.Plugin.Mongo.Syntax.Mongosh.xshd", new[] { ".mongodb.js" }), + ("VelaShell.Plugin.Mongo.Syntax.Json.xshd", new[] { ".ejson" }) + }) + { + try + { + using Stream? stream = assembly.GetManifestResourceStream(resource); + if (stream is null) + { + continue; + } + using var reader = XmlReader.Create(stream); + IHighlightingDefinition definition = HighlightingLoader.Load(reader, HighlightingManager.Instance); + HighlightingManager.Instance.RegisterHighlighting(definition.Name, extensions, definition); + } + catch (Exception) + { + // 一份定义坏掉不该让编辑器打不开 —— 那一种退化成纯文本,其余照常。 + } + } + } + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Editor/CompletionPopupView.axaml b/plugins/VelaShell.Plugin.Mongo/Ui/Editor/CompletionPopupView.axaml new file mode 100644 index 0000000..685a4ef --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Editor/CompletionPopupView.axaml @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Editor/CompletionPopupView.axaml.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Editor/CompletionPopupView.axaml.cs new file mode 100644 index 0000000..b9ff74c --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Editor/CompletionPopupView.axaml.cs @@ -0,0 +1,48 @@ +using System.Diagnostics; +using Avalonia.Controls; +using Avalonia.Input; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 补全弹层视图。点一项 = 接受;文档链接用系统浏览器打开。 +public sealed partial class CompletionPopupView : UserControl +{ + /// 用户点了一项。 + public event Action? ItemClicked; + + /// 构造。 + public CompletionPopupView() + { + InitializeComponent(); + List.AddHandler(PointerReleasedEvent, (_, e) => + { + if ((e.Source as Control)?.DataContext is CompletionItem item) + { + ItemClicked?.Invoke(item); + } + }, Avalonia.Interactivity.RoutingStrategies.Bubble, handledEventsToo: true); + DocsLink.Click += (_, _) => + { + if ((DataContext as CompletionSession)?.Selected?.DocsUrl is { Length: > 0 } url) + { + try + { + Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); + } + catch (Exception) + { + // 打不开浏览器不是补全的事。 + } + } + }; + } + + /// 把选中项滚进视野。 + public void Reveal(CompletionItem? item) + { + if (item is not null) + { + List.ScrollIntoView(item); + } + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Editor/CompletionSession.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Editor/CompletionSession.cs new file mode 100644 index 0000000..436e73a --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Editor/CompletionSession.cs @@ -0,0 +1,112 @@ +using System.Collections.ObjectModel; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 一次打开着的补全:候选项随用户继续打字就地过滤,选中项驱动右侧详情栏。 +/// +public sealed class CompletionSession : ObservableObject +{ + private readonly IReadOnlyList _all; + private CompletionItem? _selected; + private string _prefix = ""; + + /// 构造。 + /// 候选集。 + public CompletionSession(CompletionSet set) + { + Set = set; + _all = set.Items; + foreach (CompletionItem item in _all) + { + Items.Add(item); + } + _selected = Items.FirstOrDefault(); + } + + /// 候选集。 + public CompletionSet Set { get; } + + /// 过滤后的候选项。 + public ObservableCollection Items { get; } = []; + + /// 选中项。 + public CompletionItem? Selected + { + get => _selected; + set + { + if (SetProperty(ref _selected, value)) + { + RaisePropertyChanged(nameof(ShowDetail)); + } + } + } + + /// 详情栏开着(用户可用 Ctrl+Space 切换)。 + public bool DetailEnabled { get; set; } = true; + + /// 右侧详情栏显示与否。 + public bool ShowDetail => DetailEnabled && _selected?.HasDetail == true; + + /// 有没有标题行。 + public bool HasHeader => !string.IsNullOrEmpty(Set.Header); + + /// 有没有底部提示。 + public bool HasFooter => !string.IsNullOrEmpty(Set.Footer); + + /// 替换区间起点。 + public int ReplaceOffset => Set.ReplaceOffset; + + /// 当前替换区间长度(随打字增长)。 + public int ReplaceLength => Set.ReplaceLength + (_prefix.Length - InitialPrefix.Length); + + /// 打开时光标前那半截词。 + public string InitialPrefix { get; init; } = ""; + + /// 按新的前缀过滤:前缀匹配在前,包含匹配在后;一个都不剩时返回 false(调用方关掉弹层)。 + public bool Filter(string prefix) + { + _prefix = prefix; + string needle = prefix.TrimStart('"', '\''); + List next = + [ + .. _all.Where(i => Matches(i.Label, needle, prefix: true)), + .. _all.Where(i => !Matches(i.Label, needle, prefix: true) && Matches(i.Label, needle, prefix: false)) + ]; + Items.Clear(); + foreach (CompletionItem item in next) + { + Items.Add(item); + } + Selected = Items.FirstOrDefault(); + return Items.Count > 0; + } + + private static bool Matches(string label, string needle, bool prefix) + { + string bare = label.TrimStart('"', '\''); + return needle.Length == 0 + || (prefix + ? bare.StartsWith(needle, StringComparison.OrdinalIgnoreCase) || label.StartsWith(needle, StringComparison.OrdinalIgnoreCase) + : bare.Contains(needle, StringComparison.OrdinalIgnoreCase)); + } + + /// 上下移动选中。 + public void Move(int delta) + { + if (Items.Count == 0) + { + return; + } + int index = _selected is null ? 0 : Items.IndexOf(_selected); + Selected = Items[Math.Clamp(index + delta, 0, Items.Count - 1)]; + } + + /// 切换详情栏(Ctrl+Space 再按一次)。 + public void ToggleDetail() + { + DetailEnabled = !DetailEnabled; + RaisePropertyChanged(nameof(ShowDetail)); + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Editor/EditorModels.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Editor/EditorModels.cs new file mode 100644 index 0000000..c1e17d7 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Editor/EditorModels.cs @@ -0,0 +1,148 @@ +namespace VelaShell.Plugin.Mongo.Ui; + +/// 这块文本按哪种语法着色。 +public enum CodeLanguage +{ + /// 不着色。 + None, + + /// mongosh(查询编辑器、筛选栏、管道阶段、文档编辑器 JSON 页的 Shell 写法)。 + Shell, + + /// 扩展 JSON(Relaxed / Canonical)。 + Json +} + +/// 诊断的严重程度。 +public enum DiagnosticSeverity +{ + /// 错误(红色波浪线 + 行号旁红点 + 行尾红底提示)。 + Error, + + /// 警告(橙色波浪线)。 + Warning, + + /// 提示(信息色虚线)。 + Info +} + +/// +/// 一条诊断:/ 是文档里的字符区间。 +/// 非空时行尾提示后面带一个快捷修复的说明(Alt+↵ 修复)。 +/// +/// 起点。 +/// 长度。 +/// 提示文字。 +/// 严重程度。 +/// 一键修复:用它替换这个区间;没有为 。 +/// 修复的快捷键说明。 +public sealed record EditorDiagnostic( + int Offset, + int Length, + string Message, + DiagnosticSeverity Severity = DiagnosticSeverity.Error, + string? FixText = null, + string? FixLabel = null); + +/// 整行标记的种类(JSON 视图里"已修改 / 新增字段"那种整行底色)。 +public enum LineMarkKind +{ + /// 已修改(橙色淡底 + 左侧 2px 橙条)。 + Modified, + + /// 新增(绿色淡底 + 左侧 2px 绿条)。 + Added, + + /// 删除(红色淡底)。 + Removed, + + /// 当前 / 高亮(强调色淡底)。 + Highlight +} + +/// +/// 一行的标记:底色 + 行尾小字(已修改 · 原值 "paid"、新增字段)。 +/// +/// 行号(1 起)。 +/// 种类。 +/// 行尾小字;没有为 。 +public sealed record LineMark(int Line, LineMarkKind Kind, string? Annotation = null); + +/// +/// 补全列表里的一项(设计稿 03 / 02 / 09 的补全项:图标 + 文字 + 右侧类别;选中时右侧详情栏)。 +/// +public sealed class CompletionItem +{ + /// 列表里显示的文字($sort)。 + public required string Label { get; init; } + + /// 接受时插入的文本;缺省等于 。可以含一个 | 表示插入后光标落点。 + public string? InsertText { get; init; } + + /// 图标(Mongo.layers)。 + public string IconKey { get; init; } = "Mongo.variable"; + + /// 图标颜色令牌。 + public string IconToken { get; init; } = "VelaTextTertiary"; + + /// 右侧小字(聚合阶段、字段 · 抽样、分布 62%)。 + public string? Category { get; init; } + + /// 详情栏标题旁的徽章(聚合阶段)。 + public string? Badge { get; init; } + + /// 详情栏说明。 + public string? Description { get; init; } + + /// 详情栏里的语法示例(等宽代码块)。 + public string? Syntax { get; init; } + + /// 详情栏里一排小标签的标题(上游 $group 输出的字段)。 + public string? ChipsTitle { get; init; } + + /// 详情栏里一排小标签(_id、name、orders)。 + public IReadOnlyList Chips { get; init; } = []; + + /// 文档链接。 + public string? DocsUrl { get; init; } + + /// 值分布(0–1):值建议列表里画一根比例条(设计稿 02)。 + public double? Ratio { get; init; } + + /// 是否有详情(没有就不展开右栏)。 + public bool HasDetail => Description is not null || Syntax is not null || Chips.Count > 0; + + /// 有没有比例条。 + public bool HasRatio => Ratio is not null; + + /// 比例条文字(62%)。 + public string RatioText => Ratio is { } r ? $"{r * 100:0}%" : ""; + + /// 比例条宽度(满 80 像素)。 + public double RatioWidth => (Ratio ?? 0) * 80; +} + +/// 一次补全的结果。 +public sealed class CompletionSet +{ + /// 候选项。 + public required IReadOnlyList Items { get; init; } + + /// 接受时替换的区间起点(通常是光标前那个半截词的起点)。 + public required int ReplaceOffset { get; init; } + + /// 替换区间的长度。 + public required int ReplaceLength { get; init; } + + /// 列表顶上的一行标题(值建议 status · 抽样 1,000 份文档);没有为空。 + public string? Header { get; init; } + + /// 列表底部的操作提示(↵ 插入 · Tab 插入片段 · Ctrl+Space 切换详情)。 + public string? Footer { get; init; } +} + +/// 补全请求。 +/// 全文。 +/// 光标位置。 +/// 用户按了 Ctrl+Space(而不是打字自动弹出)。 +public sealed record CompletionRequest(string Text, int CaretOffset, bool Explicit); diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Editor/EditorRenderers.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Editor/EditorRenderers.cs new file mode 100644 index 0000000..5c2e9ac --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Editor/EditorRenderers.cs @@ -0,0 +1,207 @@ +using System.Globalization; +using Avalonia; +using Avalonia.Media; +using Avalonia.Media.TextFormatting; +using AvaloniaEdit.Document; +using AvaloniaEdit.Editing; +using AvaloniaEdit.Rendering; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 编辑器的装饰层:整行标记的底色与左侧色条、诊断波浪线、行尾提示框与行尾小字。 +/// +/// 画在 (文字之下):底色与波浪线都不该盖住字。 +/// 行尾的提示文字也画在这里 —— 它在行末空白处,本来就没有字可盖。 +/// +/// +internal sealed class DecorationRenderer(Func brush, Func font, Func fontSize) : IBackgroundRenderer +{ + /// 诊断。 + public IReadOnlyList Diagnostics { get; set; } = []; + + /// 整行标记。 + public IReadOnlyList LineMarks { get; set; } = []; + + /// + public KnownLayer Layer => KnownLayer.Background; + + /// + public void Draw(TextView textView, DrawingContext drawingContext) + { + if (!textView.VisualLinesValid || textView.Document is not { } document) + { + return; + } + double width = textView.Bounds.Width; + var marksByLine = LineMarks.GroupBy(static m => m.Line).ToDictionary(static g => g.Key, static g => g.First()); + + foreach (VisualLine visualLine in textView.VisualLines) + { + int lineNumber = visualLine.FirstDocumentLine.LineNumber; + double top = visualLine.VisualTop - textView.ScrollOffset.Y; + if (marksByLine.TryGetValue(lineNumber, out LineMark? mark)) + { + string token = Token(mark.Kind); + drawingContext.FillRectangle(ThemeBrushes.GetDim(token, 0.12), new Rect(0, top, width, visualLine.Height)); + drawingContext.FillRectangle(brush(token), new Rect(0, top, 2, visualLine.Height)); + if (mark.Annotation is { Length: > 0 } note) + { + DrawTrailing(textView, drawingContext, visualLine, top, note, brush(token), null); + } + } + } + + foreach (EditorDiagnostic diagnostic in Diagnostics) + { + int start = Math.Clamp(diagnostic.Offset, 0, document.TextLength); + int length = Math.Clamp(diagnostic.Length, 1, Math.Max(1, document.TextLength - start)); + var segment = new TextSegment { StartOffset = start, Length = length }; + IBrush stroke = brush(diagnostic.Severity switch + { + DiagnosticSeverity.Warning => "VelaWarning", + DiagnosticSeverity.Info => "VelaInfo", + _ => "VelaError" + }); + foreach (Rect rect in BackgroundGeometryBuilder.GetRectsForSegment(textView, segment)) + { + DrawWave(drawingContext, stroke, rect); + } + // 行尾提示:错误给一个淡红底的小框(设计稿 03 第 6 行),其余只给文字。 + DocumentLine line = document.GetLineByOffset(start); + if (textView.GetVisualLine(line.LineNumber) is { } visual) + { + double top = visual.VisualTop - textView.ScrollOffset.Y; + // 修复说明("Alt+↵ 修复")用三级字色跟在消息后面,与设计稿 03 第 6 行一致。 + DrawTrailing(textView, drawingContext, visual, top, diagnostic.Message, stroke, + diagnostic.Severity == DiagnosticSeverity.Error ? ThemeBrushes.GetDim("VelaError", 0.12) : null, + icon: diagnostic.Severity == DiagnosticSeverity.Error, + suffix: diagnostic.FixLabel, + suffixBrush: brush("VelaTextTertiary")); + } + } + } + + private void DrawTrailing(TextView textView, DrawingContext context, VisualLine visualLine, double top, string text, + IBrush foreground, IBrush? background, bool icon = false, string? suffix = null, IBrush? suffixBrush = null) + { + TextLine last = visualLine.TextLines[^1]; + double x = visualLine.GetTextLineVisualXPosition(last, visualLine.VisualLengthWithEndOfLineMarker) - textView.ScrollOffset.X + 24; + var formatted = new FormattedText( + (icon ? "⊗ " : "") + text, + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + new Typeface(font()), + Math.Max(9, fontSize() - 1), + foreground); + FormattedText? tail = suffix is { Length: > 0 } + ? new FormattedText(" " + suffix, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, new Typeface(font()), + Math.Max(9, fontSize() - 1), suffixBrush ?? foreground) + : null; + double y = top + ((visualLine.Height - formatted.Height) / 2); + if (background is not null) + { + double width = formatted.Width + (tail?.Width ?? 0); + context.FillRectangle(background, new Rect(x - 6, top + 2, width + 12, visualLine.Height - 4), 3); + } + context.DrawText(formatted, new Point(x, y)); + if (tail is not null) + { + context.DrawText(tail, new Point(x + formatted.Width, y)); + } + } + + private static void DrawWave(DrawingContext context, IBrush brush, Rect rect) + { + var geometry = new StreamGeometry(); + using (StreamGeometryContext g = geometry.Open()) + { + double y = rect.Bottom - 1; + double x = rect.Left; + g.BeginFigure(new Point(x, y), false); + bool up = true; + while (x < rect.Right) + { + x += 2; + g.LineTo(new Point(Math.Min(x, rect.Right), up ? y - 2 : y)); + up = !up; + } + g.EndFigure(false); + } + context.DrawGeometry(null, new Pen(brush, 1), geometry); + } + + private static string Token(LineMarkKind kind) => kind switch + { + LineMarkKind.Modified => "VelaWarning", + LineMarkKind.Added => "VelaStatusConnected", + LineMarkKind.Removed => "VelaError", + _ => "VelaAccent" + }; +} + +/// +/// 行号左边那一列小圆点:有错误的行一个红点(设计稿 03 第 6 行)、改过的行一个橙点。 +/// +internal sealed class MarkerMargin(Func brush) : AbstractMargin +{ + private readonly Dictionary _dots = []; + + /// 重算圆点(诊断与整行标记变了之后)。 + public void Update(TextDocument? document, IReadOnlyList diagnostics, IReadOnlyList marks) + { + _dots.Clear(); + foreach (LineMark mark in marks) + { + if (mark.Kind == LineMarkKind.Modified) + { + _dots[mark.Line] = "VelaWarning"; + } + } + if (document is not null) + { + foreach (EditorDiagnostic diagnostic in diagnostics.Where(static d => d.Severity == DiagnosticSeverity.Error)) + { + int offset = Math.Clamp(diagnostic.Offset, 0, document.TextLength); + _dots[document.GetLineByOffset(offset).LineNumber] = "VelaError"; + } + } + InvalidateVisual(); + } + + /// + protected override void OnTextViewChanged(TextView? oldTextView, TextView? newTextView) + { + if (oldTextView is not null) + { + oldTextView.VisualLinesChanged -= OnVisualLinesChanged; + } + base.OnTextViewChanged(oldTextView, newTextView); + if (newTextView is not null) + { + newTextView.VisualLinesChanged += OnVisualLinesChanged; + } + } + + private void OnVisualLinesChanged(object? sender, EventArgs e) => InvalidateVisual(); + + /// + protected override Size MeasureOverride(Size availableSize) => new(10, 0); + + /// + public override void Render(DrawingContext context) + { + if (TextView is not { VisualLinesValid: true } view || _dots.Count == 0) + { + return; + } + foreach (VisualLine line in view.VisualLines) + { + if (_dots.TryGetValue(line.FirstDocumentLine.LineNumber, out string? token)) + { + double y = line.VisualTop - view.ScrollOffset.Y + (line.Height / 2); + context.DrawEllipse(brush(token), null, new Point(5, y), 3, 3); + } + } + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Glyph.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Glyph.cs new file mode 100644 index 0000000..3ae9b20 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Glyph.cs @@ -0,0 +1,122 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 一枚描边图标。 +/// +/// lucide 的路径是**描边**的(没有填充),用 PathIcon 那种填充渲染会糊成一团黑。 +/// 这个控件按 24×24 的视框等比缩放,描边宽度固定在 2(与 lucide 原始设计一致), +/// 于是 13px 与 20px 的图标看起来是同一套线宽。 +/// +/// +/// 图标既可以直接给几何(),也可以给资源键()—— +/// 后者让视图模型能用字符串决定画哪一个,而不必在视图里堆一张 DataTrigger 大表。 +/// +/// +public sealed class Glyph : Control +{ + /// lucide 的设计视框边长。 + private const double ViewBox = 24; + + /// 图标几何。 + public static readonly StyledProperty DataProperty = + AvaloniaProperty.Register(nameof(Data)); + + /// 图标的资源键(如 Icon.play / Mongo.leaf)。 + public static readonly StyledProperty KeyProperty = + AvaloniaProperty.Register(nameof(Key)); + + /// 描边画刷。 + public static readonly StyledProperty BrushProperty = + AvaloniaProperty.Register(nameof(Brush)); + + /// 边长(逻辑像素),宽高相同。 + public static readonly StyledProperty SizeProperty = + AvaloniaProperty.Register(nameof(Size), 14d); + + static Glyph() + { + AffectsRender(DataProperty, BrushProperty, SizeProperty); + AffectsMeasure(SizeProperty); + KeyProperty.Changed.AddClassHandler((glyph, _) => glyph.ResolveKey()); + // 图标是定尺寸的,默认的 Stretch 对它没有意义:排版会把它拉成整行高, + // 而 Render 从原点开画 —— 于是图标贴在行顶,旁边居中的文字与它错开一截。 + // 这是"图标和文字不在一个水平中轴上"的根因,逐处补 VerticalAlignment 补不干净。 + // 枚举与 Layoutable.VerticalAlignment 属性同名,这里必须写全限定。 + VerticalAlignmentProperty.OverrideDefaultValue(Avalonia.Layout.VerticalAlignment.Center); + } + + /// 图标几何。 + public Geometry? Data + { + get => GetValue(DataProperty); + set => SetValue(DataProperty, value); + } + + /// 图标的资源键。 + public string? Key + { + get => GetValue(KeyProperty); + set => SetValue(KeyProperty, value); + } + + /// 描边画刷。 + public IBrush? Brush + { + get => GetValue(BrushProperty); + set => SetValue(BrushProperty, value); + } + + /// 边长。 + public double Size + { + get => GetValue(SizeProperty); + set => SetValue(SizeProperty, value); + } + + /// + protected override Size MeasureOverride(Size availableSize) => new(Size, Size); + + /// + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + // 资源要等控件进了可视树才查得到 —— 在那之前 FindResource 一定落空。 + ResolveKey(); + } + + private void ResolveKey() + { + if (Key is not { Length: > 0 } key) + { + return; + } + if (this.TryFindResource(key, out var resource) && resource is Geometry geometry) + { + Data = geometry; + } + } + + /// + public override void Render(DrawingContext context) + { + if (Data is not { } geometry || Brush is not { } brush) + { + return; + } + var scale = Size / ViewBox; + // 在实际分到的框里居中再画。默认对齐下这一步是零位移(框正好是 Size×Size); + // 但调用方显式写了 Stretch、或父容器给了更大的框时,图标仍然待在正中, + // 而不是缩在左上角。 + var transform = Matrix.CreateScale(scale, scale) * + Matrix.CreateTranslation((Bounds.Width - Size) / 2, (Bounds.Height - Size) / 2); + using (context.PushTransform(transform)) + { + // 线宽写在 24 的坐标系里,缩放之后自然与 lucide 在各尺寸下的观感一致。 + context.DrawGeometry(null, new Pen(brush, 2, lineCap: PenLineCap.Round, lineJoin: PenLineJoin.Round), geometry); + } + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/GridFs/GridFsControls.cs b/plugins/VelaShell.Plugin.Mongo/Ui/GridFs/GridFsControls.cs new file mode 100644 index 0000000..833c2ba --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/GridFs/GridFsControls.cs @@ -0,0 +1,131 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 文件表 + 拖放区的上下分配(设计稿 06:表有几行就占几行高,拖放区吃掉剩下的全部)。 +/// +/// 用 Grid 的 Auto 行做不到:Auto 行给子元素的是无限高,ListBox 一旦拿到无限高就不再虚拟化, +/// 一万个文件就是一万个容器。这里给第一个子元素的高度上限是"总高 − 拖放区最小高", +/// 于是表少时贴着内容、表多时自己滚动,拖放区始终至少露出 。 +/// +/// +public sealed class GridFsSplitPanel : Panel +{ + /// 第二个子元素(拖放区)的最小高度。 + public static readonly StyledProperty MinRestProperty = + AvaloniaProperty.Register(nameof(MinRest), 96); + + static GridFsSplitPanel() + { + AffectsMeasure(MinRestProperty); + } + + /// 拖放区最小高度。 + public double MinRest + { + get => GetValue(MinRestProperty); + set => SetValue(MinRestProperty, value); + } + + /// + protected override Size MeasureOverride(Size availableSize) + { + if (Children.Count == 0) + { + return default; + } + double topMax = double.IsInfinity(availableSize.Height) ? double.PositiveInfinity : Math.Max(0, availableSize.Height - MinRest); + Control top = Children[0]; + top.Measure(new Size(availableSize.Width, topMax)); + double topHeight = Math.Min(top.DesiredSize.Height, topMax); + double rest = double.IsInfinity(availableSize.Height) ? MinRest : Math.Max(0, availableSize.Height - topHeight); + double width = top.DesiredSize.Width; + for (int i = 1; i < Children.Count; i++) + { + Children[i].Measure(new Size(availableSize.Width, rest)); + width = Math.Max(width, Children[i].DesiredSize.Width); + } + return new Size(double.IsInfinity(availableSize.Width) ? width : availableSize.Width, + double.IsInfinity(availableSize.Height) ? topHeight + rest : availableSize.Height); + } + + /// + protected override Size ArrangeOverride(Size finalSize) + { + if (Children.Count == 0) + { + return finalSize; + } + double topHeight = Math.Min(Children[0].DesiredSize.Height, Math.Max(0, finalSize.Height - MinRest)); + Children[0].Arrange(new Rect(0, 0, finalSize.Width, topHeight)); + for (int i = 1; i < Children.Count; i++) + { + Children[i].Arrange(new Rect(0, topHeight, finalSize.Width, Math.Max(0, finalSize.Height - topHeight))); + } + return finalSize; + } +} + +/// +/// 细进度条(底栏 180×6、上传行里 4px 高的那根):圆角轨道 + 强调色填充。 +/// 不用 Fluent 的 ProgressBar —— 它的模板在宿主与 headless 下长得不一样,而这里只要两块圆角矩形。 +/// +public sealed class GridFsBar : Control +{ + /// 进度(0–1)。 + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value)); + + /// 轨道画刷。 + public static readonly StyledProperty TrackProperty = + AvaloniaProperty.Register(nameof(Track)); + + /// 填充画刷。 + public static readonly StyledProperty FillProperty = + AvaloniaProperty.Register(nameof(Fill)); + + static GridFsBar() + { + AffectsRender(ValueProperty, TrackProperty, FillProperty); + } + + /// 进度。 + public double Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + /// 轨道画刷。 + public IBrush? Track + { + get => GetValue(TrackProperty); + set => SetValue(TrackProperty, value); + } + + /// 填充画刷。 + public IBrush? Fill + { + get => GetValue(FillProperty); + set => SetValue(FillProperty, value); + } + + /// + public override void Render(DrawingContext context) + { + Rect bounds = new(Bounds.Size); + double radius = bounds.Height / 2; + if (Track is { } track) + { + context.DrawRectangle(track, null, bounds, radius, radius); + } + double width = bounds.Width * Math.Clamp(Value, 0, 1); + if (Fill is { } fill && width > 0) + { + context.DrawRectangle(fill, null, new Rect(0, 0, Math.Max(width, bounds.Height), bounds.Height), radius, radius); + } + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/GridFs/GridFsDetailsViewModel.cs b/plugins/VelaShell.Plugin.Mongo/Ui/GridFs/GridFsDetailsViewModel.cs new file mode 100644 index 0000000..f57b123 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/GridFs/GridFsDetailsViewModel.cs @@ -0,0 +1,588 @@ +using System.Collections.ObjectModel; +using Avalonia.Media.Imaging; +using MongoDB.Bson; +using MongoDB.Driver; +using VelaShell.Plugin.Mongo.Bson; +using VelaShell.Plugin.Mongo.Core; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 右侧 320px 详情(设计稿 06 右栏):文件名 + 版本徽章 + 下载 / 外部打开 / 删除; +/// 图片预览;fs.files 字段;metadata(只读显示,可切到编辑并保存);同名文件的版本列表 +/// (恢复 / 下载 / 删除;旧版本可勾选几个一起删,或「只保留最新版本」)。 +/// +/// 点版本列表里的旧版本,详情就切到那一份 —— 于是头部的删除按钮删的永远是"正在看的这一份", +/// 与工具栏上"删除这个文件名的全部版本"界线分明。版本列表里的删除只针对旧版本,最新那份永远不在其列。 +/// +/// +internal sealed class GridFsDetailsViewModel : ObservableObject, IDisposable +{ + private readonly GridFsTabViewModel _owner; + private CancellationTokenSource? _loadCts; + private GridFsEntry? _entry; + private GridFsFile? _file; + private Bitmap? _image; + private string _previewMessage = ""; + private string _imageInfo = ""; + private bool _isPreviewLoading; + private string _metadataText = ""; + private string _metadataDraft = ""; + private string? _metadataError; + private bool _isEditing; + + /// 构造。 + /// 所属标签页。 + public GridFsDetailsViewModel(GridFsTabViewModel owner) + { + _owner = owner; + DownloadCommand = new(() => _file is { } f ? _owner.DownloadFileAsync(f) : Task.CompletedTask, () => _file is not null); + OpenExternalCommand = new(() => _file is { } f ? _owner.OpenExternalAsync(f) : Task.CompletedTask, () => _file is not null); + DeleteCommand = new(() => _file is { } f ? _owner.DeleteVersionAsync(f, [.. Versions]) : Task.CompletedTask, () => _file is not null); + ZoomCommand = new(Zoom, () => _image is not null); + EditMetadataCommand = new(BeginEdit, () => _file is not null); + SaveMetadataCommand = new(SaveMetadataAsync); + CancelMetadataCommand = new(() => IsEditing = false); + RestoreCommand = new(row => _owner.RestoreVersionAsync(row.File, row.Label), static row => row.CanRestore); + DownloadVersionCommand = new(row => _owner.DownloadFileAsync(row.File)); + ShowVersionCommand = new(row => _ = ShowFileAsync(row.File)); + DeleteVersionCommand = new(row => _owner.DeleteVersionAsync(row.File, [.. Versions]), static row => row.CanRestore); + DeleteCheckedVersionsCommand = new( + () => _owner.DeleteVersionsAsync([.. Versions.Where(static v => v.IsChecked)], [.. Versions]), + () => CheckedVersionCount > 0); + KeepLatestOnlyCommand = new( + () => _owner.DeleteVersionsAsync([.. Versions.Where(static v => !v.IsCurrent)], [.. Versions]), + () => HasOldVersions); + } + + private Loc Loc => _owner.Loc; + + /// 当前行(文件或目录);没选为 。 + public GridFsEntry? Entry + { + get => _entry; + private set + { + if (SetProperty(ref _entry, value)) + { + RaisePropertiesChanged(nameof(HasEntry), nameof(IsEmpty), nameof(IsFolder), nameof(IsFile), nameof(Name), + nameof(IconKey), nameof(IconToken), nameof(FolderSummary), nameof(FolderLastUpload)); + } + } + } + + /// 正在看的那一份(默认最新版本)。 + public GridFsFile? File + { + get => _file; + private set + { + if (SetProperty(ref _file, value)) + { + RaisePropertiesChanged(nameof(IdText), nameof(FilenameText), nameof(LengthText), nameof(ChunkText), nameof(UploadText), + nameof(BadgeText), nameof(IsShowingLatest), nameof(Name)); + DownloadCommand.RaiseCanExecuteChanged(); + OpenExternalCommand.RaiseCanExecuteChanged(); + DeleteCommand.RaiseCanExecuteChanged(); + EditMetadataCommand.RaiseCanExecuteChanged(); + } + } + } + + /// 有选中。 + public bool HasEntry => _entry is not null; + + /// 空态(没选)。 + public bool IsEmpty => _entry is null; + + /// 选的是文件。 + public bool IsFile => _entry?.IsFile == true; + + /// 选的是目录。 + public bool IsFolder => _entry?.IsFolder == true; + + /// 头部名字。 + public string Name => _file?.BaseName ?? _entry?.Name ?? ""; + + /// 头部图标。 + public string IconKey => _entry?.IconKey ?? "Fs.file"; + + /// 头部图标颜色。 + public string IconToken => _entry?.IconToken ?? "VelaTextTertiary"; + + /// 头部徽章:最新版本「v3 · 最新」,旧版本「v2」。 + public string BadgeText + { + get + { + GridFsVersionRow? row = Versions.FirstOrDefault(v => _file is not null && v.File.Id == _file.Id); + if (row is null) + { + return ""; + } + return row.IsCurrent ? Loc.Format("Fs_Latest", row.Number) : row.Label; + } + } + + /// 看的是最新版本(徽章强调色);旧版本徽章转警告色,提醒"这不是当前那一份"。 + public bool IsShowingLatest => Versions.FirstOrDefault(v => _file is not null && v.File.Id == _file.Id)?.IsCurrent ?? true; + + // ── 目录 ─────────────────────────────────────────────────────────────── + + /// 目录摘要「12 个文件 · 61.9 MB」。 + public string FolderSummary => _entry is { IsFolder: true } folder + ? Loc.Format("Fs_FolderSummary", BsonText.Grouped(folder.FileCount), BsonText.Bytes(folder.Size)) + : ""; + + /// 目录里最近一次上传。 + public string FolderLastUpload => _entry is { IsFolder: true, Uploaded: { } at } + ? Loc.Format("Fs_LastUpload", GridFsEntry.FormatLocal(at, "yyyy-MM-dd HH:mm")) + : ""; + + // ── 预览 ─────────────────────────────────────────────────────────────── + + /// + /// 解出来的图片。换图时**不**主动 Dispose 旧的:「缩放预览」对话框可能还挂着它, + /// 而列表刷新随时会让详情重载 —— 提前释放就是一张画到一半的空图;交给终结器回收。 + /// + public Bitmap? Image + { + get => _image; + private set + { + if (SetProperty(ref _image, value)) + { + RaisePropertyChanged(nameof(HasImage)); + ZoomCommand.RaiseCanExecuteChanged(); + } + } + } + + /// 有图。 + public bool HasImage => _image is not null; + + /// 没图时预览框里那句(「无法预览」「超过 8 MB,不预览」「正在加载预览…」)。 + public string PreviewMessage + { + get => _previewMessage; + private set => SetProperty(ref _previewMessage, value); + } + + /// 预览加载中。 + public bool IsPreviewLoading + { + get => _isPreviewLoading; + private set => SetProperty(ref _isPreviewLoading, value); + } + + /// 「1600 × 1600 · JPEG」。 + public string ImageInfo + { + get => _imageInfo; + private set => SetProperty(ref _imageInfo, value); + } + + // ── fs.files 字段 ────────────────────────────────────────────────────── + + /// 小节标题(fs.files)。 + public string FilesTitle => _owner.Bucket.FilesCollection; + + /// _id(ObjectId 缩写)。 + public string IdText => _file?.Id switch + { + BsonObjectId oid => BsonText.Shorten(oid.Value.ToString()), + { } other => BsonText.Cell(other), + null => "" + }; + + /// 完整文件名。 + public string FilenameText => _file?.Filename ?? ""; + + /// 字节数(千分位)。 + public string LengthText => _file is { } f ? BsonText.Grouped(f.Length) : ""; + + /// 「261,120 · 4 块」。 + public string ChunkText => _file is { } f ? Loc.Format("Fs_ChunkInfo", BsonText.Grouped(f.ChunkSize), BsonText.Grouped(f.ChunkCount)) : ""; + + /// 上传时间(本地,秒)。 + public string UploadText => _file?.UploadDate is { } at ? GridFsEntry.FormatLocal(at, "yyyy-MM-dd HH:mm:ss") : "—"; + + // ── metadata ─────────────────────────────────────────────────────────── + + /// metadata 的 mongosh 写法(只读显示)。 + public string MetadataText + { + get => _metadataText; + private set + { + if (SetProperty(ref _metadataText, value)) + { + RaisePropertyChanged(nameof(MetadataHeight)); + } + } + } + + /// + /// 只读 metadata 框的高度:按行数撑开(每行 14px + 编辑器上下内边距与边框),最多 180 再滚动。 + /// 编辑器(AvaloniaEdit)放在 StackPanel 里拿到的是无限高,量不出内容高度 —— 只能由这里给。 + /// + public double MetadataHeight => Math.Clamp(((_metadataText.Count(static c => c == '\n') + 1) * 14) + 18, 40, 180); + + /// 编辑中的草稿。 + public string MetadataDraft + { + get => _metadataDraft; + set + { + if (SetProperty(ref _metadataDraft, value ?? "")) + { + MetadataError = ShellJson.TryParseDocument(_metadataDraft, out _, out string? error) + ? null + : Loc.Format("Fs_MetadataInvalid", error); + } + } + } + + /// 草稿解析错误;没有为 。 + public string? MetadataError + { + get => _metadataError; + private set + { + if (SetProperty(ref _metadataError, value)) + { + RaisePropertyChanged(nameof(HasMetadataError)); + } + } + } + + /// 有解析错误。 + public bool HasMetadataError => _metadataError is not null; + + /// 正在编辑 metadata。 + public bool IsEditing + { + get => _isEditing; + private set + { + if (SetProperty(ref _isEditing, value)) + { + RaisePropertyChanged(nameof(IsViewing)); + } + } + } + + /// 只读显示中。 + public bool IsViewing => !_isEditing; + + // ── 版本 ─────────────────────────────────────────────────────────────── + + /// 同名文件的各个版本,最新在前。 + public ObservableCollection Versions { get; } = []; + + /// 有旧版本(版本列表下面那条「删除选中 / 只保留最新版本」才出现)。 + public bool HasOldVersions => Versions.Count > 1; + + /// 勾了几个旧版本。 + public int CheckedVersionCount => Versions.Count(static v => v.IsChecked); + + /// 「删除选中的 2 个版本」;没勾时是「勾选旧版本可一起删除」的提示。 + public string DeleteCheckedText => CheckedVersionCount > 0 + ? Loc.Format("Fs_DeleteChecked", CheckedVersionCount) + : Loc["Fs_PickVersions"]; + + /// 「只保留最新版本(删除其余 3 个)」。 + public string KeepLatestText => Loc.Format("Fs_KeepLatestOnly", Math.Max(0, Versions.Count - 1)); + + /// 「删除选中的 N 个版本」按钮在不在:勾了才在,没勾时那里是一句提示。 + public bool HasCheckedVersions => CheckedVersionCount > 0; + + // ── 命令 ─────────────────────────────────────────────────────────────── + + /// 下载正在看的这一份。 + public AsyncCommand DownloadCommand { get; } + + /// 外部打开(下到临时目录交给系统)。 + public AsyncCommand OpenExternalCommand { get; } + + /// 只删正在看的这一份(一个版本)。 + public AsyncCommand DeleteCommand { get; } + + /// 缩放预览。 + public RelayCommand ZoomCommand { get; } + + /// 编辑 metadata。 + public RelayCommand EditMetadataCommand { get; } + + /// 保存 metadata(updateOne)。 + public AsyncCommand SaveMetadataCommand { get; } + + /// 放弃编辑。 + public RelayCommand CancelMetadataCommand { get; } + + /// 恢复某个旧版本。 + public AsyncCommand RestoreCommand { get; } + + /// 下载某个版本。 + public AsyncCommand DownloadVersionCommand { get; } + + /// 在详情里看某个版本。 + public RelayCommand ShowVersionCommand { get; } + + /// 只删这一个旧版本(版本行上的垃圾桶)。 + public AsyncCommand DeleteVersionCommand { get; } + + /// 一次删掉勾选的几个旧版本。 + public AsyncCommand DeleteCheckedVersionsCommand { get; } + + /// 只保留最新版本:其余旧版本全删。 + public AsyncCommand KeepLatestOnlyCommand { get; } + + // ── 加载 ─────────────────────────────────────────────────────────────── + + /// 换一行显示(目录只显示摘要;文件读版本列表并加载预览)。 + public async Task ShowAsync(GridFsEntry? entry) + { + _loadCts?.Cancel(); + var cts = new CancellationTokenSource(); + _loadCts = cts; + IsEditing = false; + Entry = entry; + if (entry is not { IsFile: true, File: { } latest }) + { + SetVersions([], null); + File = null; + Image = null; + ImageInfo = ""; + PreviewMessage = ""; + MetadataText = ""; + RaisePropertiesChanged(nameof(BadgeText), nameof(IsShowingLatest)); + return; + } + bool sameName = Versions.Count > 0 && Versions[0].File.Filename == latest.Filename; + GridFsFile shown = sameName && _file is { } current && current.Filename == latest.Filename ? current : latest; + try + { + IReadOnlyList versions = await _owner.Service.VersionsAsync(latest.Filename, cts.Token).ConfigureAwait(true); + if (cts.IsCancellationRequested) + { + return; + } + if (versions.Count == 0) + { + versions = [latest]; + } + if (versions.All(v => v.Id != shown.Id)) + { + shown = versions[0]; + } + else + { + shown = versions.First(v => v.Id == shown.Id); + } + SetVersions(versions, shown); + } + catch (OperationCanceledException) + { + return; + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + SetVersions([latest], latest); + shown = latest; + } + await ShowFileAsync(shown).ConfigureAwait(true); + } + + /// + /// 换一组版本行。同一个文件名重读时(删了几个、恢复了一个),还在的那几份保留勾选 —— + /// 勾了三个、删的时候取消了,不该让人从头再勾一遍。 + /// + private void SetVersions(IReadOnlyList versions, GridFsFile? shown) + { + HashSet keepChecked = versions.Count > 0 && Versions.Count > 0 && Versions[0].File.Filename == versions[0].Filename + ? [.. Versions.Where(static v => v.IsChecked).Select(static v => v.File.Id)] + : []; + foreach (GridFsVersionRow row in Versions) + { + row.PropertyChanged -= OnVersionChanged; + } + Versions.Clear(); + for (int i = 0; i < versions.Count; i++) + { + var row = new GridFsVersionRow(versions[i], versions.Count - i, i == 0, shown is not null && versions[i].Id == shown.Id) + { + IsChecked = keepChecked.Contains(versions[i].Id) + }; + row.PropertyChanged += OnVersionChanged; + Versions.Add(row); + } + RaiseVersionSelection(); + RaisePropertiesChanged(nameof(HasOldVersions), nameof(KeepLatestText)); + KeepLatestOnlyCommand.RaiseCanExecuteChanged(); + } + + private void OnVersionChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(GridFsVersionRow.IsChecked)) + { + RaiseVersionSelection(); + } + } + + private void RaiseVersionSelection() + { + RaisePropertiesChanged(nameof(CheckedVersionCount), nameof(HasCheckedVersions), nameof(DeleteCheckedText)); + DeleteCheckedVersionsCommand.RaiseCanExecuteChanged(); + } + + /// 切到某一份(版本列表里点了 v2)。 + private async Task ShowFileAsync(GridFsFile file) + { + _loadCts?.Cancel(); + var cts = new CancellationTokenSource(); + _loadCts = cts; + IsEditing = false; + File = file; + foreach (GridFsVersionRow row in Versions) + { + row.IsShown = row.File.Id == file.Id; + } + RaisePropertiesChanged(nameof(BadgeText), nameof(IsShowingLatest)); + MetadataText = file.Metadata is { } meta ? BsonText.Pretty(meta) : "{}"; + await LoadPreviewAsync(file, cts.Token).ConfigureAwait(true); + } + + /// + /// 图片预览:image/* 且不超过 8 MB 才读(整块进内存),解码放到线程池上。 + /// 解不出来(假字节、不支持的格式)就老实说「无法预览」,布局不塌。 + /// + private async Task LoadPreviewAsync(GridFsFile file, CancellationToken cancellationToken) + { + string type = file.ContentType ?? GridFsPaths.GuessContentType(file.Filename); + string format = FormatName(type, file.Filename); + if (GridFsPaths.Categorize(file.Filename, type) != GridFsCategory.Image) + { + Image = null; + ImageInfo = type; + PreviewMessage = Loc["Fs_NoPreview"]; + return; + } + if (file.Length > GridFsService.PreviewLimit) + { + Image = null; + ImageInfo = format; + PreviewMessage = Loc["Fs_PreviewTooLarge"]; + return; + } + Image = null; + PreviewMessage = Loc["Fs_PreviewLoading"]; + IsPreviewLoading = true; + try + { + byte[] bytes = await _owner.Service.ReadAllAsync(file.Id, cancellationToken).ConfigureAwait(true); + Bitmap? bitmap = await Task.Run(() => GridFsTabViewModel.Decode(bytes), cancellationToken).ConfigureAwait(true); + if (cancellationToken.IsCancellationRequested) + { + bitmap?.Dispose(); + return; + } + Image = bitmap; + ImageInfo = bitmap is null ? $"— · {format}" : $"{bitmap.PixelSize.Width} × {bitmap.PixelSize.Height} · {format}"; + PreviewMessage = bitmap is null ? Loc["Fs_NoPreview"] : ""; + } + catch (OperationCanceledException) + { + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Image = null; + ImageInfo = format; + PreviewMessage = Loc["Fs_NoPreview"]; + } + finally + { + if (!cancellationToken.IsCancellationRequested) + { + IsPreviewLoading = false; + } + } + } + + /// 「JPEG」「PNG」「SVG」:contentType 的子类型大写;没有就用扩展名。 + internal static string FormatName(string contentType, string filename) + { + int slash = contentType.IndexOf('/'); + string sub = slash >= 0 ? contentType[(slash + 1)..] : Path.GetExtension(filename).TrimStart('.'); + int plus = sub.IndexOf('+'); + if (plus > 0) + { + sub = sub[..plus]; + } + return sub.ToUpperInvariant(); + } + + private void Zoom() + { + if (_image is { } image && _file is { } file) + { + _owner.Workspace.ShowDialog(new GridFsImageDialogViewModel(_owner, file, image, ImageInfo)); + } + } + + private void BeginEdit() + { + if (_file is null) + { + return; + } + MetadataDraft = _file.Metadata is { } meta ? BsonText.Pretty(meta) : "{\n \n}"; + MetadataError = null; + IsEditing = true; + } + + /// 保存 metadata:解析草稿 → 过写护栏 → updateOne → 重读这一份并刷新列表(contentType 可能变了)。 + private async Task SaveMetadataAsync() + { + if (_file is not { } file) + { + return; + } + if (!ShellJson.TryParseDocument(_metadataDraft, out BsonDocument parsed, out string? error)) + { + MetadataError = Loc.Format("Fs_MetadataInvalid", error); + return; + } + if (!_owner.Workspace.EnsureWritable(_owner.Database)) + { + return; + } + try + { + await _owner.Service.UpdateMetadataAsync(file.Id, parsed.ElementCount == 0 ? null : parsed).ConfigureAwait(true); + _owner.Workspace.Toast(new() { Title = Loc["Fs_MetadataSaved"], Kind = ToastKind.Success }); + IsEditing = false; + if (await _owner.Service.GetAsync(file.Id).ConfigureAwait(true) is { } fresh) + { + if (Versions.FirstOrDefault(v => v.File.Id == fresh.Id) is { } row) + { + row.File = fresh; + } + _file = null; + await ShowFileAsync(fresh).ConfigureAwait(true); + } + await _owner.ReloadAsync().ConfigureAwait(true); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + MetadataError = Loc.Format("Common_Failed", MongoConnector.Describe(ex)); + } + } + + /// + public void Dispose() + { + _loadCts?.Cancel(); + Image = null; + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/GridFs/GridFsEntry.cs b/plugins/VelaShell.Plugin.Mongo/Ui/GridFs/GridFsEntry.cs new file mode 100644 index 0000000..8cddd4f --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/GridFs/GridFsEntry.cs @@ -0,0 +1,371 @@ +using System.Globalization; +using Avalonia.Media.Imaging; +using VelaShell.Plugin.Mongo.Bson; +using VelaShell.Plugin.Mongo.Core; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 文件表里一行的种类。 +internal enum GridFsEntryKind +{ + /// 「..」返回上级。 + Parent, + + /// 虚拟目录。 + Folder, + + /// 文件(一个文件名,显示它的最新版本)。 + File, + + /// 正在上传(队列里、目标落在当前目录的那几个)。 + Upload +} + +/// +/// 文件表的一行(设计稿 06 中部)。 +/// +/// 一行代表**一个文件名**而不是一份 files 文档:同名的三份在表里是一行「v3」, +/// 旧版本在右侧详情的「版本(同名文件)」里列出 —— 这是 GridFS 版本语义在界面上的样子。 +/// +/// +internal sealed class GridFsEntry : ObservableObject +{ + private bool _isChecked; + private double _progress; + private string _progressText = ""; + private Bitmap? _thumbnail; + + private GridFsEntry(GridFsEntryKind kind, string name, string path) + { + Kind = kind; + Name = name; + Path = path; + } + + /// 勾选状态变了(视图模型据此重算底栏的"已选 N 项")。 + public event Action? CheckedChanged; + + /// 种类。 + public GridFsEntryKind Kind { get; } + + /// 显示名(当前目录下的相对名;搜索结果是相对当前目录的路径)。 + public string Name { get; } + + /// 目录:完整前缀(带结尾斜杠);文件 / 上传:完整文件名;上级:上级前缀。 + public string Path { get; } + + /// 文件的最新版本;目录与上级为 。 + public GridFsFile? File { get; private init; } + + /// 同名文件的份数。 + public int Versions { get; private init; } + + /// 目录下(递归)的文件名个数。 + public long FileCount { get; private init; } + + /// 字节数(目录为下属各文件最新版本之和)。 + public long Size { get; private init; } + + /// 上传时间(目录为最近一次)。 + public DateTime? Uploaded { get; private init; } + + /// contentType(没写就按扩展名猜)。 + public string ContentType { get; private init; } = ""; + + /// 大类。 + public GridFsCategory Category { get; private init; } + + /// 上传任务(上传行)。 + public GridFsTransfer? Transfer { get; private init; } + + /// 是不是目录。 + public bool IsFolder => Kind == GridFsEntryKind.Folder; + + /// 是不是文件。 + public bool IsFile => Kind == GridFsEntryKind.File; + + /// 是不是「..」。 + public bool IsParent => Kind == GridFsEntryKind.Parent; + + /// 是不是上传中的行。 + public bool IsUpload => Kind == GridFsEntryKind.Upload; + + /// 能不能勾选(上级与上传行不能)。 + public bool CanCheck => Kind is GridFsEntryKind.Folder or GridFsEntryKind.File; + + /// 勾选。 + public bool IsChecked + { + get => _isChecked; + set + { + if (CanCheck && SetProperty(ref _isChecked, value)) + { + CheckedChanged?.Invoke(this); + } + } + } + + /// 图标键。 + public string IconKey => Kind switch + { + GridFsEntryKind.Parent => "Mongo.corner-left-up", + GridFsEntryKind.Folder => "Mongo.folder", + GridFsEntryKind.Upload => "Mongo.file-up", + _ => Category switch + { + GridFsCategory.Image => "Mongo.file-image", + GridFsCategory.Document => "Mongo.file-text", + GridFsCategory.Video => "Mongo.film", + _ => "Fs.file" + } + }; + + /// + /// 图标颜色令牌。按设计稿:图片用 BSON 日期那支品红、PDF 用错误红、视频用信息青、 + /// 目录用宿主资源管理器的文件夹橙 —— 与宿主 SFTP 面板看同一类东西时是同一种颜色。 + /// + public string IconToken => Kind switch + { + GridFsEntryKind.Parent => "VelaTextTertiary", + GridFsEntryKind.Folder => "VelaFileFolderIcon", + GridFsEntryKind.Upload => "VelaAccent", + _ => Category switch + { + GridFsCategory.Image => "VelaShellMagenta", + GridFsCategory.Document => ContentType.Contains("pdf", StringComparison.OrdinalIgnoreCase) ? "VelaError" : "VelaInfo", + GridFsCategory.Video => "VelaInfo", + _ => "VelaTextTertiary" + } + }; + + /// 大小列。 + public string SizeText => Kind == GridFsEntryKind.Parent ? "" : BsonText.Bytes(Size); + + /// contentType 列。 + public string ContentTypeText => Kind is GridFsEntryKind.File or GridFsEntryKind.Upload ? ContentType : ""; + + /// 上传时间列(本地时间)。 + public string UploadedText => Uploaded is { } at ? FormatLocal(at, "yyyy-MM-dd HH:mm") : ""; + + /// 块列(4 × 255K)。 + public string ChunksText => File is { } file ? GridFsPaths.ChunkSummary(file.ChunkCount, file.ChunkSize) : ""; + + /// 版本列(v3)。 + public string VersionText => Kind switch + { + GridFsEntryKind.File => "v" + Versions.ToString(CultureInfo.InvariantCulture), + GridFsEntryKind.Upload => "—", + _ => "" + }; + + /// 同名多版本:版本号画成强调色徽章。 + public bool HasVersionBadge => Kind == GridFsEntryKind.File && Versions > 1; + + /// 只有一份:版本号是淡色小字。 + public bool HasPlainVersion => !HasVersionBadge && VersionText.Length > 0; + + /// 目录名后面的计数。 + public string CountText => Kind == GridFsEntryKind.Folder ? BsonText.Grouped(FileCount) : ""; + + /// 上传进度(0–1)。 + public double Progress + { + get => _progress; + set => SetProperty(ref _progress, value); + } + + /// 上传进度的块数(3 / 5)。 + public string ProgressText + { + get => _progressText; + set => SetProperty(ref _progressText, value); + } + + /// 缩略图(缩略图视图里懒加载)。 + public Bitmap? Thumbnail + { + get => _thumbnail; + set + { + if (SetProperty(ref _thumbnail, value)) + { + RaisePropertyChanged(nameof(HasThumbnail)); + } + } + } + + /// 有缩略图。 + public bool HasThumbnail => _thumbnail is not null; + + /// 「..」行。 + public static GridFsEntry Parent(string parentPrefix) => new(GridFsEntryKind.Parent, "..", parentPrefix); + + /// 目录行。 + public static GridFsEntry Folder(string name, string prefix, long files, long bytes, DateTime? last) => + new(GridFsEntryKind.Folder, name, prefix) { FileCount = files, Size = bytes, Uploaded = last }; + + /// 文件行。 + public static GridFsEntry ForFile(string name, GridFsFile latest, int versions) + { + string type = latest.ContentType ?? GridFsPaths.GuessContentType(latest.Filename); + return new(GridFsEntryKind.File, name, latest.Filename) + { + File = latest, + Versions = versions, + FileCount = 1, + Size = latest.Length, + Uploaded = latest.UploadDate, + ContentType = type, + Category = GridFsPaths.Categorize(latest.Filename, type) + }; + } + + /// 上传中的行。 + public static GridFsEntry ForUpload(string name, GridFsTransfer transfer) + { + string type = transfer.ContentType; + return new(GridFsEntryKind.Upload, name, transfer.Filename) + { + Transfer = transfer, + Size = transfer.Size, + ContentType = type, + Category = GridFsPaths.Categorize(transfer.Filename, type), + ProgressText = $"0 / {transfer.ChunkTotal.ToString(CultureInfo.InvariantCulture)}" + }; + } + + /// UTC → 本地时间文字。 + internal static string FormatLocal(DateTime utc, string format) => + DateTime.SpecifyKind(utc, DateTimeKind.Utc).ToLocalTime().ToString(format, CultureInfo.InvariantCulture); +} + +/// 传输的种类。 +internal enum GridFsTransferKind +{ + /// 本地 → GridFS。 + Upload, + + /// GridFS → 本地。 + Download +} + +/// +/// 传输队列里的一项。上传与下载共用一条队列、一根进度条(设计稿 06 底栏), +/// 一次只跑一个:并发上传在 GridFS 上省不了多少时间(瓶颈是同一条连接上的写), +/// 却会让进度条和"剩余时间"失去意义。 +/// +internal sealed class GridFsTransfer +{ + private long _bytes; + + /// 种类。 + public required GridFsTransferKind Kind { get; init; } + + /// 本地路径(上传的源 / 下载的目标)。 + public required string LocalPath { get; init; } + + /// GridFS 文件名(上传的目标 / 下载的源)。 + public required string Filename { get; init; } + + /// 下载的源文件。 + public GridFsFile? Source { get; init; } + + /// 上传的 metadata。 + public MongoDB.Bson.BsonDocument? Metadata { get; init; } + + /// 总字节。 + public long Size { get; init; } + + /// 块大小。 + public int ChunkSize { get; init; } = GridFsService.DefaultChunkSize; + + /// contentType(上传行显示用)。 + public string ContentType { get; init; } = ""; + + /// 下载完成之后做的事(「外部打开」:下到临时目录再交给系统)。 + public Func? Then { get; init; } + + /// 显示名(不含目录)。 + public string Name => GridFsPaths.BaseName(Filename); + + /// 总块数。 + public long ChunkTotal => Math.Max(1, (Size + ChunkSize - 1) / ChunkSize); + + /// 已走过的字节(任意线程写,UI 定时读)。 + public long Bytes => Interlocked.Read(ref _bytes); + + /// 进度回调(驱动在线程池线程上调)。 + public IProgress Reporter => new Sink(this); + + private sealed class Sink(GridFsTransfer owner) : IProgress + { + public void Report(long value) => Interlocked.Exchange(ref owner._bytes, value); + } +} + +/// 面包屑的一节(products › SKU-7710)。 +/// 显示名。 +/// 点它跳到的前缀。 +/// 是不是最后一节(当前目录,主色;其余是目录名青色)。 +internal sealed record GridFsCrumb(string Name, string Prefix, bool IsLast); + +/// +/// 右侧详情「版本(同名文件)」里的一行。旧版本可以勾选(一次删几个)、单独删除、恢复、下载; +/// 最新那一份不给勾 —— 删它走详情头部的删除按钮,那里的确认框会讲清谁接替成为最新。 +/// +/// 这一份。 +/// 版本号(按上传时间从旧到新 1、2、3)。 +/// 是不是最新的那份。 +/// 是不是详情当前展示的那份。 +internal sealed class GridFsVersionRow(GridFsFile file, int number, bool isCurrent, bool isShown) : ObservableObject +{ + private GridFsFile _file = file; + private bool _isShown = isShown; + private bool _isChecked; + + /// 这一份(改了 metadata 之后换成重读的那份)。 + public GridFsFile File + { + get => _file; + set + { + if (SetProperty(ref _file, value)) + { + RaisePropertiesChanged(nameof(SizeText), nameof(DateText)); + } + } + } + + /// 版本号(按上传时间从旧到新 1、2、3;GridFS 不存版本号,删掉旧的之后其余的按位置重排)。 + public int Number { get; } = number; + + /// 是不是最新的那份。 + public bool IsCurrent { get; } = isCurrent; + + /// 是不是详情当前展示的那份。 + public bool IsShown + { + get => _isShown; + set => SetProperty(ref _isShown, value); + } + + /// 勾选了(准备一起删)。只有旧版本能勾。 + public bool IsChecked + { + get => _isChecked; + set => SetProperty(ref _isChecked, value && !IsCurrent); + } + + /// v3。 + public string Label => "v" + Number.ToString(CultureInfo.InvariantCulture); + + /// 大小。 + public string SizeText => BsonText.Bytes(File.Length); + + /// 上传时间(09-26 18:20)。 + public string DateText => File.UploadDate is { } at ? GridFsEntry.FormatLocal(at, "MM-dd HH:mm") : "—"; + + /// 不是最新的才能恢复(也才能在这一行上删、才能勾选)。 + public bool CanRestore => !IsCurrent; +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/GridFs/GridFsImageDialogView.axaml b/plugins/VelaShell.Plugin.Mongo/Ui/GridFs/GridFsImageDialogView.axaml new file mode 100644 index 0000000..e221b89 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/GridFs/GridFsImageDialogView.axaml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/GridFs/GridFsNewBucketDialogView.axaml.cs b/plugins/VelaShell.Plugin.Mongo/Ui/GridFs/GridFsNewBucketDialogView.axaml.cs new file mode 100644 index 0000000..fa4eebb --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/GridFs/GridFsNewBucketDialogView.axaml.cs @@ -0,0 +1,31 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Threading; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 新建存储桶对话框视图:打开即聚焦名字;Enter 等同于点新建。 +public sealed partial class GridFsNewBucketDialogView : UserControl +{ + /// 用给定的视图模型初始化。 + internal GridFsNewBucketDialogView(GridFsNewBucketDialogViewModel viewModel) + { + InitializeComponent(); + DataContext = viewModel; + AttachedToVisualTree += (_, _) => Dispatcher.UIThread.Post(() => NameBox.Focus()); + KeyDown += (_, e) => + { + if (e.Key == Key.Enter && viewModel.CreateCommand.CanExecute(null)) + { + viewModel.CreateCommand.Execute(null); + e.Handled = true; + } + }; + } + + /// 设计器 / headless 用的无参构造。 + public GridFsNewBucketDialogView() + { + InitializeComponent(); + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/GridFs/GridFsNewBucketDialogViewModel.cs b/plugins/VelaShell.Plugin.Mongo/Ui/GridFs/GridFsNewBucketDialogViewModel.cs new file mode 100644 index 0000000..f73bb99 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/GridFs/GridFsNewBucketDialogViewModel.cs @@ -0,0 +1,115 @@ +using Avalonia.Controls; +using MongoDB.Driver; +using VelaShell.Plugin.Mongo.Core; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 「新建存储桶」对话框:一个名字 → <name>.files + <name>.chunks 两个集合与两条标准索引, +/// 建完刷新对象树并直接打开新桶。框里把要建的东西逐条列出 —— 新建桶在库里是"两个集合", +/// 用户该知道对象树里会多出什么。 +/// +internal sealed class GridFsNewBucketDialogViewModel : DialogViewModel, IViewFactory +{ + private readonly string _database; + private string _name = ""; + private string? _error; + + /// 构造。 + /// 外壳服务。 + /// 在哪个库里建。 + public GridFsNewBucketDialogViewModel(IMongoWorkspace workspace, string database) + : base(workspace) + { + _database = database; + Title = Loc["Fs_NewBucketTitle"]; + Subtitle = database; + CreateCommand = new AsyncCommand(CreateAsync, () => GridFsPaths.ValidateBucketName(_name) is null); + } + + /// + public override string IconKey => "Mongo.folder-plus"; + + /// + public override string IconToken => "VelaWarning"; + + /// + public override double Width => 440; + + /// 桶名。 + public string Name + { + get => _name; + set + { + if (SetProperty(ref _name, value ?? "")) + { + string? key = _name.Length == 0 ? null : GridFsPaths.ValidateBucketName(_name); + Error = key is null ? null : Loc[key]; + RaisePropertiesChanged(nameof(FilesName), nameof(ChunksName)); + CreateCommand.RaiseCanExecuteChanged(); + } + } + } + + /// 预览:images.files。 + public string FilesName => (_name.Trim().Length == 0 ? "…" : _name.Trim()) + ".files"; + + /// 预览:images.chunks。 + public string ChunksName => (_name.Trim().Length == 0 ? "…" : _name.Trim()) + ".chunks"; + + /// 错误。 + public string? Error + { + get => _error; + private set + { + if (SetProperty(ref _error, value)) + { + RaisePropertyChanged(nameof(HasError)); + } + } + } + + /// 有错误。 + public bool HasError => _error is not null; + + /// 新建。 + public AsyncCommand CreateCommand { get; } + + private async Task CreateAsync() + { + string name = _name.Trim(); + if (GridFsPaths.ValidateBucketName(name) is { } invalid) + { + Error = Loc[invalid]; + return; + } + if (!Workspace.EnsureWritable(_database)) + { + return; + } + try + { + IReadOnlyList existing = await Workspace.Connection.ListCollectionsAsync(_database).ConfigureAwait(true); + if (existing.Any(c => c.Name == name + ".files" || c.Name == name + ".chunks")) + { + Error = Loc["Fs_BucketExists"]; + return; + } + await GridFsService.CreateBucketAsync(Workspace.Connection, _database, name).ConfigureAwait(true); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Error = Loc.Format("Common_Failed", MongoConnector.Describe(ex)); + return; + } + Close(); + Workspace.Toast(new() { Title = Loc.Format("Fs_BucketCreated", $"{_database}.{name}"), Kind = ToastKind.Success }); + await Workspace.RefreshTreeAsync(_database).ConfigureAwait(true); + Workspace.OpenGridFs(_database, name); + } + + /// + public Control CreateView() => new GridFsNewBucketDialogView(this); +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/GridFs/GridFsRenameDialogView.axaml b/plugins/VelaShell.Plugin.Mongo/Ui/GridFs/GridFsRenameDialogView.axaml new file mode 100644 index 0000000..f173f40 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/GridFs/GridFsRenameDialogView.axaml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/GridFs/GridFsTabView.axaml.cs b/plugins/VelaShell.Plugin.Mongo/Ui/GridFs/GridFsTabView.axaml.cs new file mode 100644 index 0000000..76ebb5d --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/GridFs/GridFsTabView.axaml.cs @@ -0,0 +1,242 @@ +using System.Diagnostics; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Platform.Storage; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// GridFS 文件管理视图。代码后置只做视图模型做不了的事: +/// 双击 / 键盘手势、从系统拖进来的文件(拖放事件与数据格式只有控件层看得到)、 +/// 桶下拉与行右键菜单(弹出层在独立可视树里,菜单现建最省事)、以及"交给系统打开"(只有 TopLevel 拿得到 Launcher)。 +/// +public sealed partial class GridFsTabView : UserControl +{ + private readonly GridFsTabViewModel? _viewModel; + + /// 用给定的视图模型初始化。 + internal GridFsTabView(GridFsTabViewModel viewModel) + { + _viewModel = viewModel; + InitializeComponent(); + DataContext = viewModel; + viewModel.Launcher = LaunchAsync; + + FileList.DoubleTapped += OnRowDoubleTapped; + TileList.DoubleTapped += OnRowDoubleTapped; + FileList.ContextRequested += OnRowContextRequested; + TileList.ContextRequested += OnRowContextRequested; + BucketButton.Click += OnBucketClick; + AddHandler(KeyDownEvent, OnKeyDown, RoutingStrategies.Bubble); + + // 只读的 metadata 框按行数定高(见 MetadataHeight);AvaloniaEdit 默认还会多留半屏的"文档下方空白", + // 于是内容明明放得下也挂着一根滚动条。这里收掉它,滚轮仍能滚超长的 metadata。 + MetaView.Editor.Options.AllowScrollBelowDocument = false; + MetaView.Editor.VerticalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Hidden; + MetaView.Editor.HorizontalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Hidden; + + ListArea.AddHandler(DragDrop.DragEnterEvent, OnDragOver); + ListArea.AddHandler(DragDrop.DragOverEvent, OnDragOver); + ListArea.AddHandler(DragDrop.DragLeaveEvent, OnDragLeave); + ListArea.AddHandler(DragDrop.DropEvent, OnDrop); + } + + /// 设计器 / headless 用的无参构造。 + public GridFsTabView() + { + InitializeComponent(); + } + + private void OnRowDoubleTapped(object? sender, TappedEventArgs e) + { + if (_viewModel is not null && (e.Source as Control)?.DataContext is GridFsEntry entry && e.Source is not CheckBox) + { + _viewModel.OpenEntryCommand.Execute(entry); + e.Handled = true; + } + } + + /// + /// 面板内的键盘手势:Enter 打开、Backspace 上一级、Delete 删除、F2 改名、Ctrl+A 全选。 + /// 焦点在搜索框里时一个都不抢 —— 那里的 Backspace / Delete / Ctrl+A 是编辑文字。 + /// + private void OnKeyDown(object? sender, KeyEventArgs e) + { + if (_viewModel is null || e.Handled || e.Source is TextBox || IsInsideEditor(e.Source)) + { + return; + } + bool control = e.KeyModifiers.HasFlag(KeyModifiers.Control); + switch (e.Key) + { + case Key.Enter when _viewModel.SelectedEntry is { } entry: + _viewModel.OpenEntryCommand.Execute(entry); + break; + case Key.Back: + _viewModel.UpCommand.Execute(null); + break; + case Key.Delete: + _viewModel.DeleteCommand.Execute(null); + break; + case Key.F2: + _viewModel.RenameCommand.Execute(null); + break; + case Key.A when control: + _viewModel.AllChecked = true; + break; + default: + return; + } + e.Handled = true; + } + + private static bool IsInsideEditor(object? source) + { + for (var element = source as Avalonia.StyledElement; element is not null; element = element.Parent) + { + if (element is CodeEditor) + { + return true; + } + } + return false; + } + + // ── 拖放 ─────────────────────────────────────────────────────────────── + + private void OnDragOver(object? sender, DragEventArgs e) + { + bool files = e.DataTransfer.TryGetFiles()?.Length > 0; + e.DragEffects = files ? DragDropEffects.Copy : DragDropEffects.None; + DropFrame.Classes.Set("over", files); + e.Handled = true; + } + + private void OnDragLeave(object? sender, DragEventArgs e) => DropFrame.Classes.Set("over", false); + + private void OnDrop(object? sender, DragEventArgs e) + { + DropFrame.Classes.Set("over", false); + e.Handled = true; + if (_viewModel is null) + { + return; + } + // 文件与文件夹都收:文件夹在视图模型里递归展开、保留相对路径。 + List paths = [.. (e.DataTransfer.TryGetFiles() ?? []) + .Select(static item => item.TryGetLocalPath()) + .Where(static p => p is not null) + .Select(static p => p!)]; + if (paths.Count > 0) + { + _ = _viewModel.UploadPathsAsync(paths); + } + } + + // ── 菜单 ─────────────────────────────────────────────────────────────── + + /// 桶下拉:同库的桶(当前的打勾)+ 回到根目录 + 新建存储桶。 + private async void OnBucketClick(object? sender, RoutedEventArgs e) + { + if (_viewModel is null) + { + return; + } + Loc loc = _viewModel.Loc; + IReadOnlyList buckets = await _viewModel.ListBucketsAsync().ConfigureAwait(true); + var items = new List + { + new MenuItem + { + Header = loc.Format("Fs_BucketsOf", _viewModel.Database), + IsEnabled = false + } + }; + foreach (Core.GridFsBucketInfo bucket in buckets) + { + string name = bucket.Name; + items.Add(new MenuItem + { + Header = name, + Icon = new Glyph + { + Key = name == _viewModel.Bucket.Name ? "Mongo.check" : "Mongo.hard-drive", + Size = 13, + Brush = ThemeBrushes.Get(name == _viewModel.Bucket.Name ? "VelaAccent" : "VelaWarning", Avalonia.Media.Brushes.Gray) + }, + Command = new RelayCommand(() => _viewModel.SwitchBucket(name)) + }); + } + items.Add(new Separator()); + items.Add(new MenuItem + { + Header = loc["Fs_GoRoot"], + Icon = new Glyph { Key = "Mongo.corner-left-up", Size = 13, Brush = ThemeBrushes.Get("VelaTextTertiary", Avalonia.Media.Brushes.Gray) }, + Command = _viewModel.NavigateCommand, + CommandParameter = "" + }); + items.Add(new MenuItem + { + Header = loc["Fs_NewBucket"] + "…", + Icon = new Glyph { Key = "Mongo.folder-plus", Size = 13, Brush = ThemeBrushes.Get("VelaTextTertiary", Avalonia.Media.Brushes.Gray) }, + Command = _viewModel.NewBucketCommand + }); + var menu = new ContextMenu { ItemsSource = items }; + menu.Open(BucketButton); + } + + /// 行右键:打开 / 下载 / 外部打开 / 重命名 / 删除(右键先把这一行选中,菜单作用于它或已勾选的那批)。 + private void OnRowContextRequested(object? sender, ContextRequestedEventArgs e) + { + if (_viewModel is null || (e.Source as Control)?.DataContext is not GridFsEntry entry || !entry.CanCheck) + { + return; + } + _viewModel.SelectedEntry = entry; + Loc loc = _viewModel.Loc; + var items = new List(); + + void Add(string label, string icon, System.Windows.Input.ICommand command, object? parameter = null, bool danger = false) + { + items.Add(MenuKit.Command(label, icon, command, parameter, danger)); + } + + if (entry.IsFolder) + { + Add(loc["Fs_OpenFolder"], "Mongo.folder-open", _viewModel.OpenEntryCommand, entry); + } + else + { + Add(loc["Fs_OpenExternal"], "Mongo.external-link", _viewModel.OpenEntryCommand, entry); + } + Add(loc["Fs_Download"], "Mongo.download", _viewModel.DownloadCommand); + Add(loc["Fs_Rename"], "Mongo.pencil-line", _viewModel.RenameCommand); + items.Add(new Separator()); + Add(loc["Fs_Delete"], "Mongo.trash-2", _viewModel.DeleteCommand, danger: true); + new ContextMenu { ItemsSource = items }.Open(e.Source as Control ?? FileList); + e.Handled = true; + } + + // ── 外部打开 ─────────────────────────────────────────────────────────── + + /// 交给系统默认程序打开:先走 TopLevel 的 Launcher,不行再退回 shell 执行。 + private async Task LaunchAsync(string path) + { + try + { + if (TopLevel.GetTopLevel(this)?.Launcher is { } launcher + && await launcher.LaunchFileInfoAsync(new FileInfo(path)).ConfigureAwait(true)) + { + return true; + } + using Process? process = Process.Start(new ProcessStartInfo(path) { UseShellExecute = true }); + // UseShellExecute 交给已在运行的程序打开时会返回 null,那也算成功。 + return true; + } + catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException or PlatformNotSupportedException) + { + return false; + } + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/GridFs/GridFsTabViewModel.Actions.cs b/plugins/VelaShell.Plugin.Mongo/Ui/GridFs/GridFsTabViewModel.Actions.cs new file mode 100644 index 0000000..6c721f0 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/GridFs/GridFsTabViewModel.Actions.cs @@ -0,0 +1,1042 @@ +using System.Diagnostics; +using System.Globalization; +using Avalonia.Media.Imaging; +using Avalonia.Threading; +using MongoDB.Bson; +using MongoDB.Driver; +using VelaShell.Plugin.Mongo.Bson; +using VelaShell.Plugin.Mongo.Core; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 待上传的一个本地文件。 +/// 本地路径。 +/// 相对上传目标目录的名字(上传文件夹时带上文件夹自己的名字与子目录)。 +/// 字节数。 +internal sealed record GridFsUploadItem(string LocalPath, string Relative, long Size); + +/// GridFS 标签页的写操作与传输队列。 +internal sealed partial class GridFsTabViewModel +{ + /// 缩略图只读这么大以内的图片(整块进内存,一屏几十张)。 + private const long ThumbnailLimit = 4L * 1024 * 1024; + + private readonly Queue _queue = new(); + private readonly List _pendingRows = []; + private readonly DispatcherTimer _transferTicker; + private readonly Stopwatch _transferClock = new(); + private CancellationTokenSource? _transferCts; + private CancellationTokenSource? _thumbCts; + private GridFsTransfer? _current; + private string? _reselectPath; + private string _lastTransfer = ""; + private double _transferProgress; + private string _transferStats = ""; + + /// 上传文件(多选)。 + public AsyncCommand UploadFilesCommand { get; private set; } = null!; + + /// 上传文件夹(递归,保留相对路径)。 + public AsyncCommand UploadFolderCommand { get; private set; } = null!; + + /// 下载勾选 / 选中的项。 + public AsyncCommand DownloadCommand { get; private set; } = null!; + + /// 重命名。 + public AsyncCommand RenameCommand { get; private set; } = null!; + + /// 删除(每个文件的全部版本)。 + public AsyncCommand DeleteCommand { get; private set; } = null!; + + /// 新建存储桶。 + public RelayCommand NewBucketCommand { get; private set; } = null!; + + /// 检查孤儿块。 + public AsyncCommand CheckOrphansCommand { get; private set; } = null!; + + /// 取消全部传输。 + public RelayCommand CancelTransfersCommand { get; private set; } = null!; + + /// + /// 交给系统打开一个本地文件(视图在构造时接上 —— 只有 TopLevel 拿得到 Launcher)。 + /// 返回是否打开成功;没接上为 (headless 测试)。 + /// + internal Func>? Launcher { get; set; } + + private void InitializeActions() + { + UploadFilesCommand = new(UploadFilesAsync); + UploadFolderCommand = new(UploadFolderAsync); + DownloadCommand = new(DownloadTargetsAsync, () => HasTargets); + // 改名可用条件放宽到"有对象":勾了多个时点它给一句"只选一个",比一个灰着不说为什么的按钮好懂。 + RenameCommand = new(RenameAsync, () => HasTargets); + DeleteCommand = new(DeleteTargetsAsync, () => HasTargets); + NewBucketCommand = new(NewBucket); + CheckOrphansCommand = new(CheckOrphansAsync); + CancelTransfersCommand = new(CancelTransfers); + } + + // ── 拖放区与底栏 ─────────────────────────────────────────────────────── + + /// 上传落到的目录(给人看:根是 /)。 + public string UploadTarget => UploadPrefix.Length == 0 ? "/" : UploadPrefix; + + /// 上传落到的前缀。 + private string UploadPrefix => _virtualDirs ? _prefix : ""; + + /// 拖放区主文字「拖拽文件到此处上传到 products/SKU-7710/」。 + public string DropText => Loc.Format("Fs_DropHere", UploadTarget); + + /// 正在传输。 + public bool IsTransferring => _current is not null; + + /// 底栏图标(上传 / 下载)。 + public string TransferIcon => _current?.Kind == GridFsTransferKind.Download ? "Mongo.download" : "Mongo.arrow-up-from-line"; + + /// 底栏图标颜色:传输中强调色,空闲淡色。 + public string TransferIconToken => _current is null ? "VelaTextMuted" : "VelaAccent"; + + /// 「正在上传 detail-07.jpg」;空闲时是上一批的结果或「无传输任务」。 + public string TransferTitle => _current is { } job + ? Loc.Format(job.Kind == GridFsTransferKind.Upload ? "Fs_Uploading" : "Fs_Downloading", job.Name) + : _lastTransfer.Length > 0 ? _lastTransfer : Loc["Fs_Idle"]; + + /// 当前这一个的进度(0–1)。 + public double TransferProgress + { + get => _transferProgress; + private set => SetProperty(ref _transferProgress, value); + } + + /// 「62% · 3.1 MB/s · 剩余 1 s」。 + public string TransferStats + { + get => _transferStats; + private set => SetProperty(ref _transferStats, value); + } + + /// 「队列:2 个文件」;队列空为空。 + public string QueueText => _queue.Count > 0 ? Loc.Format("Fs_Queue", _queue.Count) : ""; + + /// 队列里还有等着的。 + public bool HasQueue => _queue.Count > 0; + + private void RaiseTransfer() => + RaisePropertiesChanged(nameof(IsTransferring), nameof(TransferIcon), nameof(TransferIconToken), nameof(TransferTitle), + nameof(QueueText), nameof(HasQueue)); + + // ── 上传 ─────────────────────────────────────────────────────────────── + + private async Task UploadFilesAsync() + { + if (!Workspace.EnsureWritable(Database)) + { + return; + } + IReadOnlyList files = await Workspace.PickOpenFilesAsync(Loc["Fs_PickUpload"], [FileKind.Any], multiple: true) + .ConfigureAwait(true); + if (files.Count > 0) + { + await UploadPathsAsync(files).ConfigureAwait(true); + } + } + + private async Task UploadFolderAsync() + { + if (!Workspace.EnsureWritable(Database)) + { + return; + } + string? folder = await Workspace.PickFolderAsync(Loc["Fs_PickUploadFolder"]).ConfigureAwait(true); + if (folder is not null) + { + await UploadPathsAsync([folder]).ConfigureAwait(true); + } + } + + /// + /// 上传一批本地路径(文件或文件夹,文件夹递归、保留相对路径)到当前目录。 + /// 时先弹「上传到 GridFS」小对话框:可改目标目录与 metadata(默认带 contentType 与 uploader)。 + /// 拖放与工具栏按钮都走这里。 + /// + internal async Task UploadPathsAsync(IReadOnlyList paths, bool ask = true, BsonDocument? metadata = null) + { + if (!Workspace.EnsureWritable(Database)) + { + return; + } + IReadOnlyList items = ExpandPaths(paths); + if (items.Count == 0) + { + Workspace.Toast(new() { Title = Loc["Fs_UploadNothing"], Kind = ToastKind.Info }); + return; + } + string prefix = UploadPrefix; + BsonDocument template = metadata ?? DefaultMetadata(items); + if (ask) + { + var dialog = new GridFsUploadDialogViewModel(Workspace, Bucket, prefix, items, template); + Workspace.ShowDialog(dialog); + if (await dialog.Result.ConfigureAwait(true) is not { } plan) + { + return; + } + prefix = plan.Prefix; + template = plan.Metadata; + } + Enqueue([.. items.Select(item => + { + BsonDocument meta = MetadataFor(template, item.Relative); + return new GridFsTransfer + { + Kind = GridFsTransferKind.Upload, + LocalPath = item.LocalPath, + Filename = prefix + item.Relative, + Size = item.Size, + Metadata = meta, + ContentType = meta.GetValue("contentType", "").ToString() ?? "" + }; + })]); + } + + /// 把选中的本地路径展开成文件清单。读不到的子目录跳过(权限),而不是整批失败。 + internal static IReadOnlyList ExpandPaths(IEnumerable paths) + { + var items = new List(); + var options = new EnumerationOptions { RecurseSubdirectories = true, IgnoreInaccessible = true }; + foreach (string path in paths) + { + if (File.Exists(path)) + { + items.Add(new(path, Path.GetFileName(path), new FileInfo(path).Length)); + } + else if (Directory.Exists(path)) + { + string root = SafeSegment(new DirectoryInfo(path).Name); + foreach (string file in Directory.EnumerateFiles(path, "*", options).Order(StringComparer.Ordinal)) + { + string relative = Path.GetRelativePath(path, file).Replace('\\', '/'); + items.Add(new(file, root + "/" + relative, new FileInfo(file).Length)); + } + } + } + return items; + } + + /// 默认 metadata:所有文件同一种 contentType 时直接写上;uploader 取当前用户。 + internal BsonDocument DefaultMetadata(IReadOnlyList items) + { + var doc = new BsonDocument(); + List types = [.. items.Select(static i => GridFsPaths.GuessContentType(i.Relative)).Distinct(StringComparer.OrdinalIgnoreCase)]; + if (types.Count == 1) + { + doc["contentType"] = types[0]; + } + doc["uploader"] = Uploader; + return doc; + } + + /// 上传者:已认证用户名(去掉 @库),没开认证就用本机用户名。 + private string Uploader => + Workspace.Connection.Privileges.User is { Length: > 0 } user ? user.Split('@')[0] : Environment.UserName; + + /// 某个文件的 metadata:模板里没写 contentType 的,按这个文件的扩展名补上。 + internal static BsonDocument MetadataFor(BsonDocument template, string relative) + { + var doc = template.DeepClone().AsBsonDocument; + if (!doc.Contains("contentType")) + { + doc.InsertAt(0, new BsonElement("contentType", GridFsPaths.GuessContentType(relative))); + } + return doc; + } + + // ── 下载与外部打开 ───────────────────────────────────────────────────── + + private async Task DownloadTargetsAsync() + { + IReadOnlyList targets = Targets; + if (targets.Count == 0) + { + return; + } + if (targets is [{ IsFile: true, File: { } single }]) + { + await DownloadFileAsync(single).ConfigureAwait(true); + return; + } + string? folder = await Workspace.PickFolderAsync(Loc["Fs_PickDownloadFolder"]).ConfigureAwait(true); + if (folder is null) + { + return; + } + await DownloadToFolderAsync(targets, folder).ConfigureAwait(true); + } + + /// 下载一份(某个版本)到用户选的路径。 + internal async Task DownloadFileAsync(GridFsFile file) + { + string? path = await Workspace.PickSaveFileAsync(Loc["Fs_PickSave"], SafeSegment(file.BaseName), [FileKind.Any]) + .ConfigureAwait(true); + if (path is not null) + { + Enqueue([DownloadJob(file, path)]); + } + } + + /// + /// 多项下载到一个文件夹:文件取最新版本,目录递归、按虚拟目录在本地建同样的结构。 + /// + internal async Task DownloadToFolderAsync(IReadOnlyList targets, string folder) + { + var jobs = new List(); + try + { + foreach (GridFsEntry target in targets) + { + if (target is { IsFile: true, File: { } file }) + { + jobs.Add(DownloadJob(file, SafeLocalPath(folder, file.BaseName))); + } + else if (target.IsFolder) + { + string root = target.Path.TrimEnd('/'); + root = GridFsPaths.BaseName(root); + foreach (GridFsFile inner in await _service.FilesUnderAsync(target.Path, latestOnly: true).ConfigureAwait(true)) + { + jobs.Add(DownloadJob(inner, SafeLocalPath(folder, root + "/" + inner.Filename[target.Path.Length..]))); + } + } + } + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Failed(ex); + return; + } + Enqueue(jobs); + } + + /// 外部打开:下到临时目录,再交给系统的默认程序。 + internal Task OpenExternalAsync(GridFsFile file) + { + string dir = Path.Combine(Path.GetTempPath(), "VelaShell", "gridfs", SafeSegment(file.Id.ToString() ?? "file")); + Enqueue([DownloadJob(file, Path.Combine(dir, SafeSegment(file.BaseName)), LaunchAsync)]); + return Task.CompletedTask; + } + + private static GridFsTransfer DownloadJob(GridFsFile file, string localPath, Func? then = null) => new() + { + Kind = GridFsTransferKind.Download, + LocalPath = localPath, + Filename = file.Filename, + Source = file, + Size = file.Length, + ChunkSize = file.ChunkSize > 0 ? file.ChunkSize : GridFsService.DefaultChunkSize, + Then = then + }; + + private async Task LaunchAsync(string path) + { + if (Launcher is { } launcher && await launcher(path).ConfigureAwait(true)) + { + return; + } + Workspace.Toast(new() { Title = Loc.Format("Fs_OpenFailed", Path.GetFileName(path)), Detail = path, Kind = ToastKind.Warning }); + } + + /// + /// GridFS 文件名 → 本地路径。文件名是**别人写进库里的字符串**:../../x、绝对路径、 + /// Windows 不允许的字符都可能出现。逐段清洗、丢掉 . / ..,最后再确认落点仍在目标文件夹里 —— + /// 下载功能不能变成"往任意位置写文件"。 + /// + internal static string SafeLocalPath(string root, string relative) + { + string[] parts = [.. relative.Replace('\\', '/').Split('/', StringSplitOptions.RemoveEmptyEntries) + .Where(static p => p is not "." and not "..") + .Select(SafeSegment)]; + if (parts.Length == 0) + { + parts = ["_"]; + } + string full = Path.GetFullPath(Path.Combine([root, .. parts])); + string rootFull = Path.GetFullPath(root); + string rootWithSep = rootFull.EndsWith(Path.DirectorySeparatorChar) ? rootFull : rootFull + Path.DirectorySeparatorChar; + return full.StartsWith(rootWithSep, StringComparison.OrdinalIgnoreCase) ? full : Path.Combine(rootFull, parts[^1]); + } + + /// 一段文件名里换掉本机不允许的字符;Windows 的保留名(CON、NUL…)前面加下划线。 + internal static string SafeSegment(string segment) + { + char[] invalid = Path.GetInvalidFileNameChars(); + string clean = new([.. segment.Select(c => invalid.Contains(c) || c is ':' or '*' or '?' or '"' or '<' or '>' or '|' ? '_' : c)]); + clean = clean.TrimEnd('.', ' '); + if (clean.Length == 0) + { + return "_"; + } + string stem = Path.GetFileNameWithoutExtension(clean).ToUpperInvariant(); + return stem is "CON" or "PRN" or "AUX" or "NUL" || (stem.Length == 4 && (stem.StartsWith("COM", StringComparison.Ordinal) + || stem.StartsWith("LPT", StringComparison.Ordinal)) && char.IsDigit(stem[3])) + ? "_" + clean + : clean; + } + + // ── 传输队列 ─────────────────────────────────────────────────────────── + + private void Enqueue(IReadOnlyList jobs) + { + if (jobs.Count == 0) + { + return; + } + bool uploads = false; + foreach (GridFsTransfer job in jobs) + { + _queue.Enqueue(job); + if (job.Kind == GridFsTransferKind.Upload) + { + _pendingRows.Add(GridFsEntry.ForUpload(GridFsPaths.BaseName(job.Filename), job)); + uploads = true; + } + } + if (uploads) + { + ApplyEntries(); + } + RaiseTransfer(); + if (_current is null) + { + _ = PumpAsync(); + } + } + + /// + /// 串行跑队列。上传完一个就重列一次目录(节流到每秒一次)—— 新文件立刻出现在表里, + /// 而不是等一整批传完。失败的那一个提示出来,队列继续往下走。 + /// + private async Task PumpAsync() + { + var cts = new CancellationTokenSource(); + _transferCts = cts; + int done = 0; + long bytes = 0; + bool uploaded = false; + GridFsTransferKind kind = GridFsTransferKind.Upload; + var sinceReload = Stopwatch.StartNew(); + _transferTicker.Start(); + try + { + while (_queue.Count > 0 && !cts.IsCancellationRequested) + { + GridFsTransfer job = _queue.Dequeue(); + _current = job; + kind = job.Kind; + _transferClock.Restart(); + TransferProgress = 0; + TransferStats = ""; + RaiseTransfer(); + try + { + if (job.Kind == GridFsTransferKind.Upload) + { + await using var source = new FileStream(job.LocalPath, FileMode.Open, FileAccess.Read, FileShare.Read, + 1 << 16, FileOptions.Asynchronous | FileOptions.SequentialScan); + await _service.UploadAsync(job.Filename, source, job.Metadata, job.Reporter, job.ChunkSize, cts.Token) + .ConfigureAwait(true); + uploaded = true; + } + else + { + Directory.CreateDirectory(Path.GetDirectoryName(job.LocalPath)!); + await using (var target = new FileStream(job.LocalPath, FileMode.Create, FileAccess.Write, FileShare.None, + 1 << 16, FileOptions.Asynchronous)) + { + await _service.DownloadAsync(job.Source!.Id, target, job.Reporter, cts.Token).ConfigureAwait(true); + } + if (job.Then is { } then) + { + await then(job.LocalPath).ConfigureAwait(true); + } + } + done++; + bytes += job.Size; + } + catch (OperationCanceledException) + { + DeletePartial(job); + break; + } + catch (Exception ex) when (ex is MongoException or TimeoutException or IOException or UnauthorizedAccessException) + { + DeletePartial(job); + Workspace.Toast(new() + { + Title = Loc.Format("Fs_TransferFailed", job.Name, MongoConnector.Describe(ex)), + Kind = ToastKind.Error, + Duration = TimeSpan.FromSeconds(8) + }); + } + finally + { + RemovePendingRow(job); + } + if (job.Kind == GridFsTransferKind.Upload && _queue.Count > 0 && sinceReload.Elapsed > TimeSpan.FromSeconds(1)) + { + sinceReload.Restart(); + await ReloadAsync().ConfigureAwait(true); + } + } + } + finally + { + bool cancelled = cts.IsCancellationRequested; + _transferTicker.Stop(); + _current = null; + if (ReferenceEquals(_transferCts, cts)) + { + _transferCts = null; + } + if (cancelled) + { + _queue.Clear(); + _pendingRows.Clear(); + } + _lastTransfer = cancelled + ? Loc["Fs_TransfersCancelled"] + : done == 0 ? "" : Loc.Format(kind == GridFsTransferKind.Upload ? "Fs_UploadDone" : "Fs_DownloadDone", done, BsonText.Bytes(bytes)); + TransferProgress = 0; + TransferStats = ""; + RaiseTransfer(); + if (uploaded || cancelled) + { + await ReloadAsync().ConfigureAwait(true); + } + cts.Dispose(); + } + } + + /// 定时把字节数翻成进度、速度与剩余时间(驱动的回调在线程池上,界面只按 150 ms 取一次)。 + private void UpdateTransferProgress() + { + if (_current is not { } job) + { + return; + } + long done = job.Bytes; + double ratio = job.Size > 0 ? Math.Min(1, done / (double)job.Size) : 0; + double seconds = _transferClock.Elapsed.TotalSeconds; + double speed = seconds > 0.05 ? done / seconds : 0; + string remaining = speed > 0 ? Duration((job.Size - done) / speed) : "—"; + TransferProgress = ratio; + TransferStats = Loc.Format("Fs_TransferStats", (ratio * 100).ToString("0", CultureInfo.InvariantCulture) + "%", + BsonText.Bytes((long)speed), remaining); + if (_pendingRows.FirstOrDefault(r => ReferenceEquals(r.Transfer, job)) is { } row) + { + row.Progress = ratio; + long chunks = Math.Min(job.ChunkTotal, done / Math.Max(1, job.ChunkSize)); + row.ProgressText = $"{chunks.ToString(CultureInfo.InvariantCulture)} / {job.ChunkTotal.ToString(CultureInfo.InvariantCulture)}"; + } + } + + private string Duration(double seconds) => seconds switch + { + < 60 => Loc.Format("Fs_Seconds", Math.Max(1, (int)Math.Ceiling(seconds))), + < 3600 => Loc.Format("Fs_Minutes", (int)Math.Ceiling(seconds / 60)), + _ => Loc.Format("Fs_Hours", (seconds / 3600).ToString("0.0", CultureInfo.InvariantCulture)) + }; + + private void RemovePendingRow(GridFsTransfer job) + { + GridFsEntry? row = _pendingRows.FirstOrDefault(r => ReferenceEquals(r.Transfer, job)); + if (row is not null) + { + _pendingRows.Remove(row); + Entries.Remove(row); + } + } + + private static void DeletePartial(GridFsTransfer job) + { + if (job.Kind != GridFsTransferKind.Download) + { + return; + } + try + { + File.Delete(job.LocalPath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // 删不掉半截文件不影响主流程;它会在下一次同名下载时被覆盖。 + } + } + + private void CancelTransfers() + { + _transferCts?.Cancel(); + _queue.Clear(); + foreach (GridFsEntry row in _pendingRows.Where(r => !ReferenceEquals(r.Transfer, _current)).ToList()) + { + _pendingRows.Remove(row); + Entries.Remove(row); + } + RaiseTransfer(); + } + + // ── 重命名 ───────────────────────────────────────────────────────────── + + private Task RenameAsync() + { + IReadOnlyList targets = Targets; + if (targets.Count != 1) + { + Workspace.Toast(new() { Title = Loc["Fs_RenamePickOne"], Kind = ToastKind.Info }); + return Task.CompletedTask; + } + if (Workspace.EnsureWritable(Database)) + { + GridFsEntry entry = targets[0]; + long affected = entry.IsFolder ? entry.FileCount : entry.Versions; + Workspace.ShowDialog(new GridFsRenameDialogViewModel(Workspace, entry, affected, name => ApplyRenameAsync(entry, name))); + } + return Task.CompletedTask; + } + + /// 执行改名;返回要显示在对话框里的错误,成功为 。 + internal async Task ApplyRenameAsync(GridFsEntry entry, string newName) + { + if (!Workspace.EnsureWritable(Database)) + { + return Loc[Workspace.Guard.Check(Database) ?? "Common_ReadOnlyBlocked"]; + } + string value = newName.Trim().Replace('\\', '/').TrimStart('/'); + if (value.Length == 0 || value.TrimEnd('/').Length == 0) + { + return Loc["Fs_RenameEmpty"]; + } + try + { + long modified; + string newPath; + if (entry.IsFolder) + { + string to = GridFsPaths.NormalizePrefix(value); + if (to == entry.Path) + { + return null; + } + if (to.StartsWith(entry.Path, StringComparison.Ordinal)) + { + return Loc["Fs_RenameIntoSelf"]; + } + if (await _service.AnyUnderAsync(to).ConfigureAwait(true)) + { + return Loc["Fs_RenameFolderExists"]; + } + modified = await _service.RenameFolderAsync(entry.Path, to).ConfigureAwait(true); + newPath = to; + } + else + { + if (value.EndsWith('/')) + { + return Loc["Fs_RenameEmpty"]; + } + if (value == entry.Path) + { + return null; + } + if (await _service.ExistsAsync(value).ConfigureAwait(true)) + { + return Loc["Fs_RenameExists"]; + } + modified = await _service.RenameAsync(entry.Path, value).ConfigureAwait(true); + newPath = value; + } + Workspace.Toast(new() { Title = Loc.Format("Fs_Renamed", BsonText.Grouped(modified)), Kind = ToastKind.Success }); + if (_selectedEntry?.Path == entry.Path) + { + // 改了名的那一行在刷新后换了路径;让 ApplyEntries 按新路径把它选回来。 + _reselectPath = newPath; + } + await ReloadAsync().ConfigureAwait(true); + return null; + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + return Loc.Format("Common_Failed", MongoConnector.Describe(ex)); + } + } + + // ── 删除 ─────────────────────────────────────────────────────────────── + + /// + /// 删除勾选 / 选中的项:**每个文件名的全部版本**,目录则是其下全部文件的全部版本。 + /// 确认框把这一点写明(列出份数、块数与大小);单个多版本文件另给一条"只删最新版本"的出口。 + /// 只删某一个版本走右侧详情的删除按钮。 + /// + private async Task DeleteTargetsAsync() + { + IReadOnlyList targets = Targets; + if (targets.Count == 0 || !Workspace.EnsureWritable(Database)) + { + return; + } + var victims = new List(); + try + { + foreach (GridFsEntry target in targets) + { + victims.AddRange(target.IsFolder + ? await _service.FilesUnderAsync(target.Path, latestOnly: false).ConfigureAwait(true) + : await _service.VersionsAsync(target.Path).ConfigureAwait(true)); + } + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Failed(ex); + return; + } + if (victims.Count == 0) + { + await ReloadAsync().ConfigureAwait(true); + return; + } + long bytes = victims.Sum(static f => f.Length); + long chunks = victims.Sum(static f => f.ChunkCount); + int names = victims.Select(static f => f.Filename).Distinct(StringComparer.Ordinal).Count(); + bool single = targets is [{ IsFile: true }]; + GridFsFile latest = victims[0]; + bool confirmed = await Workspace.ConfirmAsync(new() + { + Title = Loc["Fs_DeleteTitle"], + Message = single + ? Loc.Format("Fs_DeleteBodyOne", latest.BaseName, victims.Count, BsonText.Bytes(bytes), BsonText.Grouped(chunks)) + : Loc.Format("Fs_DeleteBodyMany", targets.Count, BsonText.Grouped(names), BsonText.Grouped(victims.Count), BsonText.Bytes(bytes)), + ConfirmLabel = Loc["Fs_DeleteConfirm"], + IconKey = "Mongo.trash-2", + Facts = + [ + new(Loc["Fs_FactFiles"], BsonText.Grouped(names)), + new(Loc["Fs_FactVersions"], BsonText.Grouped(victims.Count)), + new(Loc["Fs_FactChunks"], BsonText.Grouped(chunks)), + new(Loc["Fs_FactSize"], BsonText.Bytes(bytes)) + ], + TypeToConfirm = Workspace.Guard.ConfirmWrites ? Bucket.Name : null, + AsideLabel = single && victims.Count > 1 ? Loc.Format("Fs_DeleteOnlyLatest", victims.Count - 1) : null, + AsideAction = single && victims.Count > 1 ? () => _ = DeleteVersionCoreAsync(latest) : null + }).ConfigureAwait(true); + if (!confirmed) + { + return; + } + try + { + long deleted = await _service.DeleteManyAsync([.. victims.Select(static f => f.Id)]).ConfigureAwait(true); + Workspace.Toast(new() { Title = Loc.Format("Fs_Deleted", BsonText.Grouped(deleted)), Kind = ToastKind.Success }); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Failed(ex); + } + foreach (GridFsEntry target in targets) + { + target.IsChecked = false; + } + await ReloadAsync().ConfigureAwait(true); + } + + /// + /// 只删一个版本(右侧详情的删除按钮)。确认框写清"其余版本保留、谁会成为最新"。 + /// + internal async Task DeleteVersionAsync(GridFsFile file, IReadOnlyList versions) + { + if (!Workspace.EnsureWritable(Database)) + { + return; + } + GridFsVersionRow? row = versions.FirstOrDefault(v => v.File.Id == file.Id); + string label = row?.Label ?? "v1"; + string date = file.UploadDate is { } at ? GridFsEntry.FormatLocal(at, "yyyy-MM-dd HH:mm") : "—"; + string message; + if (versions.Count <= 1) + { + message = Loc.Format("Fs_DeleteOnlyVersionBody", file.BaseName); + } + else if (row?.IsCurrent ?? true) + { + message = Loc.Format("Fs_DeleteLatestBody", label, file.BaseName, date, versions.Count - 1, versions[1].Label); + } + else + { + message = Loc.Format("Fs_DeleteOldBody", label, file.BaseName, date, versions.Count - 1); + } + if (!await Workspace.ConfirmAsync(new() + { + Title = Loc["Fs_DeleteVersionTitle"], + Message = message, + ConfirmLabel = versions.Count <= 1 ? Loc["Fs_DeleteTitle"] : Loc["Fs_DeleteVersionTitle"], + IconKey = "Mongo.trash-2", + Facts = + [ + new(Loc["Fs_FactSize"], BsonText.Bytes(file.Length)), + new(Loc["Fs_FactChunks"], BsonText.Grouped(file.ChunkCount)) + ], + TypeToConfirm = Workspace.Guard.ConfirmWrites ? Bucket.Name : null + }).ConfigureAwait(true)) + { + return; + } + await DeleteVersionCoreAsync(file).ConfigureAwait(true); + } + + /// + /// 一次删掉同一个文件名的几个旧版本(版本列表里勾选的,或「只保留最新版本」的其余全部)。 + /// 最新版本永远不在其列 —— 删它走详情头部的删除按钮。确认框写明删哪几个、保留几个、能腾出多少空间, + /// 以及剩下的版本会按上传时间重新编号(GridFS 不存版本号,v 几只是位置)。 + /// + /// 要删的版本行。 + /// 这个文件名的全部版本行(最新在前)。 + internal async Task DeleteVersionsAsync(IReadOnlyList doomed, IReadOnlyList versions) + { + List old = [.. doomed.Where(static v => !v.IsCurrent)]; + if (old.Count == 0 || !Workspace.EnsureWritable(Database)) + { + return; + } + if (old.Count == 1) + { + await DeleteVersionAsync(old[0].File, versions).ConfigureAwait(true); + return; + } + GridFsFile latest = versions.First(static v => v.IsCurrent).File; + long bytes = old.Sum(static v => v.File.Length); + long chunks = old.Sum(static v => v.File.ChunkCount); + string labels = string.Join(", ", old.OrderBy(static v => v.Number).Select(static v => v.Label)); + int kept = versions.Count - old.Count; + if (!await Workspace.ConfirmAsync(new() + { + Title = Loc["Fs_DeleteVersionsTitle"], + Message = Loc.Format("Fs_DeleteVersionsBody", labels, latest.BaseName, old.Count, kept), + ConfirmLabel = Loc.Format("Fs_DeleteVersionsConfirm", old.Count), + IconKey = "Mongo.trash-2", + Facts = + [ + new(Loc["Fs_FactVersions"], BsonText.Grouped(old.Count)), + new(Loc["Fs_FactChunks"], BsonText.Grouped(chunks)), + new(Loc["Fs_FactFreed"], BsonText.Bytes(bytes)) + ], + TypeToConfirm = Workspace.Guard.ConfirmWrites ? Bucket.Name : null + }).ConfigureAwait(true)) + { + return; + } + if (!Workspace.EnsureWritable(Database)) + { + return; + } + try + { + long deleted = await _service.DeleteManyAsync([.. old.Select(static v => v.File.Id)]).ConfigureAwait(true); + Workspace.Toast(new() + { + Title = Loc.Format("Fs_VersionsDeleted", BsonText.Grouped(deleted), BsonText.Bytes(bytes)), + Detail = latest.BaseName, + Kind = ToastKind.Success + }); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Failed(ex); + } + await ReloadAsync().ConfigureAwait(true); + } + + private async Task DeleteVersionCoreAsync(GridFsFile file) + { + if (!Workspace.EnsureWritable(Database)) + { + return; + } + try + { + await _service.DeleteAsync(file.Id).ConfigureAwait(true); + Workspace.Toast(new() { Title = Loc.Format("Fs_Deleted", 1), Kind = ToastKind.Success }); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Failed(ex); + } + await ReloadAsync().ConfigureAwait(true); + } + + /// 恢复旧版本:以新的 uploadDate 再写一份,成为最新版本(什么都不删)。 + internal async Task RestoreVersionAsync(GridFsFile version, string label) + { + if (!Workspace.EnsureWritable(Database)) + { + return; + } + try + { + await _service.RestoreAsync(version).ConfigureAwait(true); + Workspace.Toast(new() { Title = Loc.Format("Fs_Restored", label, version.BaseName), Kind = ToastKind.Success }); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Failed(ex); + } + await ReloadAsync().ConfigureAwait(true); + } + + // ── 孤儿块与新桶 ─────────────────────────────────────────────────────── + + /// + /// 检查孤儿块:先报告(数量、大小、涉及几个已不存在的文件),确认后才清理。 + /// 只读模式下照样能查 —— 查是读操作,清理那一步才过写护栏。 + /// + private async Task CheckOrphansAsync() + { + IReadOnlyList orphans; + try + { + orphans = await _service.FindOrphansAsync().ConfigureAwait(true); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Failed(ex); + return; + } + if (orphans.Count == 0) + { + Workspace.Toast(new() + { + Title = Loc["Fs_OrphansNone"], + Detail = Loc.Format("Fs_OrphansNoneDetail", Bucket.ChunksCollection), + Kind = ToastKind.Success + }); + return; + } + long chunks = orphans.Sum(static o => o.Chunks); + long bytes = orphans.Sum(static o => o.Bytes); + if (!await Workspace.ConfirmAsync(new() + { + Title = Loc["Fs_OrphansTitle"], + Message = Loc.Format("Fs_OrphansBody", BsonText.Grouped(chunks), BsonText.Bytes(bytes), BsonText.Grouped(orphans.Count), + Bucket.FilesCollection), + ConfirmLabel = Loc["Fs_OrphansConfirm"], + IconKey = "Mongo.layers", + Facts = + [ + new(Loc["Fs_FactOrphanFiles"], BsonText.Grouped(orphans.Count)), + new(Loc["Fs_FactChunks"], BsonText.Grouped(chunks)), + new(Loc["Fs_FactSize"], BsonText.Bytes(bytes)) + ], + TypeToConfirm = Workspace.Guard.ConfirmWrites ? Bucket.Name : null + }).ConfigureAwait(true)) + { + return; + } + if (!Workspace.EnsureWritable(Database)) + { + return; + } + try + { + long deleted = await _service.DeleteOrphansAsync([.. orphans.Select(static o => o.FilesId)]).ConfigureAwait(true); + Workspace.Toast(new() { Title = Loc.Format("Fs_OrphansCleaned", BsonText.Grouped(deleted)), Kind = ToastKind.Success }); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Failed(ex); + } + } + + private void NewBucket() + { + if (!Workspace.EnsureWritable(Database)) + { + return; + } + Workspace.ShowDialog(new GridFsNewBucketDialogViewModel(Workspace, Database)); + } + + /// 同库的桶(路径行的桶下拉用)。 + internal async Task> ListBucketsAsync() + { + try + { + return await GridFsService.ListBucketsAsync(Workspace.Connection, Database).ConfigureAwait(true); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Failed(ex); + return [Bucket]; + } + } + + /// 切到同库另一个桶(外壳打开 / 复用它的标签)。 + internal void SwitchBucket(string name) + { + if (name != Bucket.Name) + { + Workspace.OpenGridFs(Database, name); + } + } + + // ── 缩略图 ───────────────────────────────────────────────────────────── + + /// + /// 缩略图视图里给图片懒加载缩略图:只取 4 MB 以内的、最多 120 张,一张一张来 —— + /// 缩略图是锦上添花,不能为它把连接塞满。 + /// + private async Task LoadThumbnailsAsync() + { + _thumbCts?.Cancel(); + var cts = new CancellationTokenSource(); + _thumbCts = cts; + List candidates = [.. Entries + .Where(static e => e is { IsFile: true, Category: GridFsCategory.Image, Thumbnail: null, File.Length: > 0 and <= ThumbnailLimit }) + .Take(120)]; + foreach (GridFsEntry entry in candidates) + { + if (cts.IsCancellationRequested) + { + return; + } + try + { + byte[] bytes = await _service.ReadAllAsync(entry.File!.Id, cts.Token).ConfigureAwait(true); + entry.Thumbnail = await Task.Run(() => Decode(bytes, 192), cts.Token).ConfigureAwait(true); + } + catch (OperationCanceledException) + { + return; + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + return; + } + } + } + + /// 解一张图;解不了(不是图、截断、格式不支持)给 。 + internal static Bitmap? Decode(byte[] bytes, int? width = null) + { + try + { + using var stream = new MemoryStream(bytes, writable: false); + return width is { } w ? Bitmap.DecodeToWidth(stream, w) : new Bitmap(stream); + } +#pragma warning disable CA1031 // 解码器对坏图抛的异常类型因平台与格式而异;任何一种都只意味着"这张没法预览"。 + catch (Exception) +#pragma warning restore CA1031 + { + return null; + } + } + + private void Failed(Exception ex) => + Workspace.Toast(new() { Title = Loc.Format("Common_Failed", MongoConnector.Describe(ex)), Kind = ToastKind.Error }); +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/GridFs/GridFsTabViewModel.cs b/plugins/VelaShell.Plugin.Mongo/Ui/GridFs/GridFsTabViewModel.cs new file mode 100644 index 0000000..bc05bab --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/GridFs/GridFsTabViewModel.cs @@ -0,0 +1,705 @@ +using System.Collections.ObjectModel; +using Avalonia.Threading; +using MongoDB.Driver; +using VelaShell.Plugin.Mongo.Bson; +using VelaShell.Plugin.Mongo.Core; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 文件表可排序的列。 +internal enum GridFsSort +{ + /// 文件名。 + Name, + + /// 大小。 + Size, + + /// contentType。 + Type, + + /// 上传时间。 + Uploaded, + + /// 块数。 + Chunks +} + +/// +/// GridFS 文件管理(设计稿 06):36px 工具行 / 32px 路径行 / 文件表 + 拖放区 | 320px 详情 / 32px 传输底栏。 +/// +/// 列目录、搜索、类型芯片全在服务器端做(见 ), +/// 界面只持有当前这一层;传输走一条串行队列,大文件全程流式。 +/// 本文件管"看":目录、排序、勾选、状态行;"动"(上传、下载、改名、删除、孤儿块、新桶)见 .Actions.cs。 +/// +/// +internal sealed partial class GridFsTabViewModel : WorkspaceTab +{ + private readonly GridFsService _service; + private readonly DispatcherTimer _searchDelay; + private CancellationTokenSource? _loadCts; + private List _listed = []; + private string _prefix = ""; + private bool _virtualDirs = true; + private GridFsTypeFilter _typeFilter = GridFsTypeFilter.All; + private string _searchText = ""; + private GridFsSort _sort = GridFsSort.Uploaded; + private bool _sortDescending = true; + private bool _isGridView; + private GridFsEntry? _selectedEntry; + private bool _isLoading; + private bool _truncated; + private string? _loadError; + private IReadOnlyList _crumbs = []; + private bool _bulkChecking; + private bool _applying; + + /// 构造。 + /// 外壳服务。 + /// 桶。 + public GridFsTabViewModel(IMongoWorkspace workspace, GridFsBucketInfo bucket) + : base(workspace) + { + Bucket = bucket; + Title = bucket.Name; + Scope = "@" + bucket.Database; + _service = new GridFsService(workspace.Connection, bucket); + Details = new GridFsDetailsViewModel(this); + _searchDelay = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) }; + _searchDelay.Tick += (_, _) => + { + _searchDelay.Stop(); + _ = ReloadAsync(); + }; + _transferTicker = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(150) }; + _transferTicker.Tick += (_, _) => UpdateTransferProgress(); + + NavigateCommand = new(prefix => _ = NavigateAsync(prefix)); + UpCommand = new(() => _ = NavigateAsync(GridFsPaths.Parent(_prefix)), () => _prefix.Length > 0); + OpenEntryCommand = new(OpenEntryAsync); + SortCommand = new(SortBy); + InitializeActions(); + UpdateCrumbs(); + UpdateStatus(); + } + + /// 桶。 + public GridFsBucketInfo Bucket { get; } + + /// 所在库。 + public string Database => Bucket.Database; + + /// + public override TabKind Kind => TabKind.GridFs; + + /// + public override string Key => $"gridfs:{Bucket.Database}.{Bucket.Name}"; + + /// + public override string IconKey => "Mongo.hard-drive"; + + /// + public override string IconToken => "VelaWarning"; + + /// 服务(详情面板与对话框共用)。 + internal GridFsService Service => _service; + + /// 文件表的行(「..」、目录、文件、上传中)。 + public ObservableCollection Entries { get; } = []; + + /// 右侧详情。 + public GridFsDetailsViewModel Details { get; } + + /// 面包屑(当前目录逐级)。 + public IReadOnlyList Crumbs + { + get => _crumbs; + private set => SetProperty(ref _crumbs, value); + } + + /// 跳到某一级。 + public RelayCommand NavigateCommand { get; } + + /// 上一级。 + public RelayCommand UpCommand { get; } + + /// 打开一行(目录进入、「..」返回、文件外部打开)。 + public AsyncCommand OpenEntryCommand { get; } + + /// 点列头排序。 + public RelayCommand SortCommand { get; } + + // ── 目录与筛选 ───────────────────────────────────────────────────────── + + /// 当前虚拟目录(带结尾斜杠;根为空串)。 + public string Prefix => _prefix; + + /// 当前目录给人看的写法(根是 /)。 + public string PrefixText => _prefix.Length == 0 ? "/" : _prefix; + + /// 实际列的前缀:关掉虚拟目录时平铺整个桶。 + private string ListPrefix => _virtualDirs ? _prefix : ""; + + /// 按 / 显示为虚拟目录。 + public bool VirtualDirs + { + get => _virtualDirs; + set + { + if (SetProperty(ref _virtualDirs, value)) + { + RaisePropertiesChanged(nameof(ShowCrumbs), nameof(UploadTarget), nameof(DropText)); + UpdateCrumbs(); + _ = ReloadAsync(); + } + } + } + + /// 面包屑可见(平铺时没有"当前目录"可言)。 + public bool ShowCrumbs => _virtualDirs; + + /// 类型芯片。 + public GridFsTypeFilter TypeFilter + { + get => _typeFilter; + set + { + if (SetProperty(ref _typeFilter, value)) + { + RaisePropertiesChanged(nameof(IsAllTypes), nameof(IsImages), nameof(IsDocuments), nameof(IsVideos)); + _ = ReloadAsync(); + } + } + } + + /// 芯片「全部」。 + public bool IsAllTypes + { + get => _typeFilter == GridFsTypeFilter.All; + set + { + if (value) + { + TypeFilter = GridFsTypeFilter.All; + } + } + } + + /// 芯片「图片」。 + public bool IsImages + { + get => _typeFilter == GridFsTypeFilter.Images; + set + { + if (value) + { + TypeFilter = GridFsTypeFilter.Images; + } + } + } + + /// 芯片「文档」。 + public bool IsDocuments + { + get => _typeFilter == GridFsTypeFilter.Documents; + set + { + if (value) + { + TypeFilter = GridFsTypeFilter.Documents; + } + } + } + + /// 芯片「视频」。 + public bool IsVideos + { + get => _typeFilter == GridFsTypeFilter.Videos; + set + { + if (value) + { + TypeFilter = GridFsTypeFilter.Videos; + } + } + } + + /// 搜索框(停手 300 ms 后到服务器上查)。 + public string SearchText + { + get => _searchText; + set + { + if (SetProperty(ref _searchText, value ?? "")) + { + _searchDelay.Stop(); + _searchDelay.Start(); + } + } + } + + /// 正在搜索(结果平铺,名字是相对当前目录的路径)。 + public bool IsSearching => _searchText.Trim().Length > 0; + + /// 列表视图。 + public bool IsListView + { + get => !_isGridView; + set + { + if (value) + { + IsGridView = false; + } + } + } + + /// 缩略图视图。 + public bool IsGridView + { + get => _isGridView; + set + { + if (SetProperty(ref _isGridView, value)) + { + RaisePropertyChanged(nameof(IsListView)); + if (value) + { + _ = LoadThumbnailsAsync(); + } + } + } + } + + // ── 排序 ─────────────────────────────────────────────────────────────── + + /// 排序列。 + public GridFsSort Sort => _sort; + + /// 倒序。 + public bool SortDescending => _sortDescending; + + /// 列头箭头的图标(arrow-down / arrow-up)。 + public string SortIcon => _sortDescending ? "Mongo.arrow-down" : "Mongo.arrow-up"; + + private void SortBy(string column) + { + if (!Enum.TryParse(column, out GridFsSort sort)) + { + return; + } + if (sort == _sort) + { + _sortDescending = !_sortDescending; + } + else + { + _sort = sort; + // 名字与类型默认正序;大小、时间、块数默认大的在前 —— 点一下就是人最常要的那个方向。 + _sortDescending = sort is GridFsSort.Size or GridFsSort.Uploaded or GridFsSort.Chunks; + } + RaisePropertiesChanged(nameof(Sort), nameof(SortDescending), nameof(SortIcon)); + ApplyEntries(); + } + + private IEnumerable Sorted(IEnumerable entries) + { + // 目录永远在文件前面(资源管理器的习惯);目录没有类型与块数,按这两列排时目录按名字排。 + IEnumerable folders = entries.Where(static e => e.IsFolder); + IEnumerable files = entries.Where(static e => !e.IsFolder); + folders = _sort switch + { + GridFsSort.Size => Order(folders, static e => e.Size), + GridFsSort.Uploaded => Order(folders, static e => e.Uploaded ?? DateTime.MinValue), + _ => folders.OrderBy(static e => e.Name, StringComparer.OrdinalIgnoreCase) + }; + files = _sort switch + { + GridFsSort.Name => Order(files, static e => e.Name, StringComparer.OrdinalIgnoreCase), + GridFsSort.Size => Order(files, static e => e.Size), + GridFsSort.Type => Order(files, static e => e.ContentType, StringComparer.OrdinalIgnoreCase), + GridFsSort.Chunks => Order(files, static e => e.File?.ChunkCount ?? 0), + _ => Order(files, static e => e.Uploaded ?? DateTime.MinValue) + }; + return folders.Concat(files); + } + + private IOrderedEnumerable Order(IEnumerable source, Func key, IComparer? comparer = null) => + _sortDescending ? source.OrderByDescending(key, comparer) : source.OrderBy(key, comparer); + + // ── 选择与勾选 ───────────────────────────────────────────────────────── + + /// 选中的行(右侧详情跟着它)。 + public GridFsEntry? SelectedEntry + { + get => _selectedEntry; + set + { + // 重排列表时 ListBox 会先把选中置空再由 ApplyEntries 指回去 —— 那一下不该让详情闪一次空。 + if (_applying) + { + return; + } + if (SetProperty(ref _selectedEntry, value)) + { + _ = Details.ShowAsync(value is { IsFile: true } or { IsFolder: true } ? value : null); + RaiseSelectionChanged(); + } + } + } + + /// + /// 表头的全选框:全勾 = true、全不勾 = false、部分 = null(三态)。 + /// 设成 true / false 即全勾 / 全清。 + /// + public bool? AllChecked + { + get + { + int checkable = Entries.Count(static e => e.CanCheck); + int checkedCount = Entries.Count(static e => e.IsChecked); + return checkedCount == 0 ? false : checkedCount == checkable ? true : null; + } + set + { + bool on = value ?? false; + _bulkChecking = true; + try + { + foreach (GridFsEntry entry in Entries.Where(static e => e.CanCheck)) + { + entry.IsChecked = on; + } + } + finally + { + _bulkChecking = false; + } + RaiseSelectionChanged(); + } + } + + /// + /// 操作对象:勾了就是勾选的那些,没勾就是选中的那一行。 + /// 与资源管理器一致 —— 勾选是"批量",单击是"这一个"。 + /// + internal IReadOnlyList Targets + { + get + { + List checkedEntries = [.. Entries.Where(static e => e.IsChecked)]; + if (checkedEntries.Count > 0) + { + return checkedEntries; + } + return _selectedEntry is { CanCheck: true } selected ? [selected] : []; + } + } + + /// 有操作对象(下载 / 改名 / 删除可用)。 + public bool HasTargets => Targets.Count > 0; + + /// 底栏右侧「已选 2 项 · 1.9 MB」;没有为空。 + public string SelectionSummary + { + get + { + IReadOnlyList targets = Targets; + return targets.Count == 0 ? "" : Loc.Format("Fs_Selected", targets.Count, BsonText.Bytes(targets.Sum(static e => e.Size))); + } + } + + /// 底栏最右「9 项 · 61.9 MB」。 + public string ListingSummary + { + get + { + string count = BsonText.Grouped(_listed.Count) + (_truncated ? "+" : ""); + return Loc.Format("Fs_Items", count, BsonText.Bytes(_listed.Sum(static e => e.Size))); + } + } + + private void OnEntryChecked(GridFsEntry entry) + { + if (!_bulkChecking) + { + RaiseSelectionChanged(); + } + } + + private void RaiseSelectionChanged() + { + RaisePropertiesChanged(nameof(AllChecked), nameof(SelectionSummary), nameof(HasTargets)); + DownloadCommand.RaiseCanExecuteChanged(); + RenameCommand.RaiseCanExecuteChanged(); + DeleteCommand.RaiseCanExecuteChanged(); + } + + // ── 加载 ─────────────────────────────────────────────────────────────── + + /// 正在加载。 + public bool IsLoading + { + get => _isLoading; + private set + { + if (SetProperty(ref _isLoading, value)) + { + RaisePropertiesChanged(nameof(EmptyText), nameof(HasEmptyText)); + } + } + } + + /// 加载失败的原因;成功为 。 + public string? LoadError + { + get => _loadError; + private set + { + if (SetProperty(ref _loadError, value)) + { + RaisePropertiesChanged(nameof(EmptyText), nameof(HasEmptyText)); + } + } + } + + /// 被截断了(超过 项)。 + public bool IsTruncated + { + get => _truncated; + private set => SetProperty(ref _truncated, value); + } + + /// 截断提示。 + public string TruncatedText => Loc.Format("Fs_Truncated", BsonText.Grouped(GridFsService.ListLimit)); + + /// 空态文字(加载中 / 失败 / 空目录 / 搜索无结果);有内容时为空。 + public string EmptyText => + _isLoading && _listed.Count == 0 ? Loc["Common_Loading"] + : _loadError is { } error ? Loc.Format("Common_Failed", error) + : _listed.Count > 0 ? "" + : IsSearching ? Loc.Format("Fs_NoMatch", _searchText.Trim()) + : Loc["Fs_Empty"]; + + /// 有空态要显示。 + public bool HasEmptyText => EmptyText.Length > 0; + + /// + public override Task LoadAsync() => ReloadAsync(); + + /// + public override Task RefreshAsync() => ReloadAsync(); + + /// + /// 重列当前目录。新的一次会取消上一次 —— 搜索框连敲、芯片连点时, + /// 只有最后一次的结果会落到界面上,不会出现旧结果晚到覆盖新结果。 + /// + public async Task ReloadAsync() + { + _loadCts?.Cancel(); + var cts = new CancellationTokenSource(); + _loadCts = cts; + IsLoading = true; + try + { + string listPrefix = ListPrefix; + GridFsListing listing = await _service.ListAsync(listPrefix, _virtualDirs, _typeFilter, _searchText, cts.Token) + .ConfigureAwait(true); + if (cts.IsCancellationRequested) + { + return; + } + var listed = new List(listing.Items.Count); + foreach (GridFsListItem item in listing.Items) + { + GridFsEntry entry = item.IsFolder + ? GridFsEntry.Folder(item.Folder!.Length == 0 ? "/" : item.Folder, listPrefix + item.Folder + "/", item.Files, item.Bytes, item.LastUpload) + : GridFsEntry.ForFile(Relative(item.Latest!.Filename, listPrefix), item.Latest, item.Versions); + listed.Add(entry); + } + _listed = listed; + IsTruncated = listing.Truncated; + LoadError = null; + ApplyEntries(); + if (_isGridView) + { + _ = LoadThumbnailsAsync(); + } + } + catch (OperationCanceledException) + { + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + if (!cts.IsCancellationRequested) + { + LoadError = MongoConnector.Describe(ex); + _listed = []; + ApplyEntries(); + Workspace.Toast(new() { Title = Loc.Format("Common_Failed", LoadError), Kind = ToastKind.Error }); + } + } + finally + { + if (ReferenceEquals(_loadCts, cts)) + { + IsLoading = false; + RaisePropertiesChanged(nameof(EmptyText), nameof(HasEmptyText)); + } + } + } + + private static string Relative(string filename, string prefix) => + prefix.Length > 0 && filename.StartsWith(prefix, StringComparison.Ordinal) ? filename[prefix.Length..] : filename; + + /// + /// 把已列出的项 + 「..」+ 落在当前目录的上传行排好放进 , + /// 并按路径恢复勾选与选中 —— 刷新、排序、上传完一个文件都不该把用户的选择丢掉。 + /// + private void ApplyEntries() + { + HashSet checkedPaths = [.. Entries.Where(static e => e.IsChecked).Select(static e => e.Path)]; + GridFsEntry? previous = _selectedEntry; + string? selectedPath = _reselectPath ?? previous?.Path; + _reselectPath = null; + foreach (GridFsEntry old in Entries) + { + old.CheckedChanged -= OnEntryChecked; + } + + var rows = new List(); + if (_virtualDirs && _prefix.Length > 0 && !IsSearching) + { + rows.Add(GridFsEntry.Parent(GridFsPaths.Parent(_prefix))); + } + rows.AddRange(Sorted(_listed)); + rows.AddRange(_pendingRows.Where(ShowsUploadRow)); + + _bulkChecking = true; + _applying = true; + try + { + Entries.Clear(); + foreach (GridFsEntry row in rows) + { + if (checkedPaths.Contains(row.Path) && row.CanCheck) + { + row.IsChecked = true; + } + row.CheckedChanged += OnEntryChecked; + Entries.Add(row); + } + } + finally + { + _bulkChecking = false; + _applying = false; + } + + GridFsEntry? reselected = selectedPath is null ? null : rows.FirstOrDefault(r => r.CanCheck && r.Path == selectedPath); + _selectedEntry = reselected; + RaisePropertyChanged(nameof(SelectedEntry)); + // 排序只是换了顺序(同一批对象);刷新则是新对象 —— 只有内容真的变了(多了版本、换了最新一份)才重载详情, + // 不然每上传完一个文件、每点一次列头,右边的预览都要重新从库里读一遍。 + bool unchanged = previous is not null && reselected is not null + && previous.Kind == reselected.Kind + && Equals(previous.File?.Id, reselected.File?.Id) + && previous.Versions == reselected.Versions + && previous.FileCount == reselected.FileCount + && previous.Size == reselected.Size; + if (!unchanged) + { + _ = Details.ShowAsync(reselected); + } + RaisePropertiesChanged(nameof(ListingSummary), nameof(EmptyText), nameof(HasEmptyText)); + RaiseSelectionChanged(); + UpdateStatus(); + } + + /// 上传行是不是该出现在当前这一页。 + private bool ShowsUploadRow(GridFsEntry row) => + !IsSearching && (_virtualDirs + ? GridFsPaths.DirectoryOf(row.Path) == _prefix + : row.Path.StartsWith(ListPrefix, StringComparison.Ordinal)); + + // ── 导航 ─────────────────────────────────────────────────────────────── + + /// 跳到一个虚拟目录(清空搜索与勾选)。 + public async Task NavigateAsync(string? prefix) + { + string target = GridFsPaths.NormalizePrefix(prefix); + foreach (GridFsEntry entry in Entries) + { + entry.IsChecked = false; + } + _prefix = target; + _searchDelay.Stop(); + if (_searchText.Length > 0) + { + _searchText = ""; + RaisePropertyChanged(nameof(SearchText)); + } + if (!_virtualDirs) + { + _virtualDirs = true; + RaisePropertiesChanged(nameof(VirtualDirs), nameof(ShowCrumbs)); + } + RaisePropertiesChanged(nameof(Prefix), nameof(PrefixText), nameof(UploadTarget), nameof(DropText)); + UpCommand.RaiseCanExecuteChanged(); + UpdateCrumbs(); + _listed = []; + ApplyEntries(); + await ReloadAsync().ConfigureAwait(true); + } + + private async Task OpenEntryAsync(GridFsEntry entry) + { + switch (entry.Kind) + { + case GridFsEntryKind.Parent: + await NavigateAsync(entry.Path).ConfigureAwait(true); + break; + case GridFsEntryKind.Folder: + await NavigateAsync(entry.Path).ConfigureAwait(true); + break; + case GridFsEntryKind.File when entry.File is { } file: + await OpenExternalAsync(file).ConfigureAwait(true); + break; + } + } + + private void UpdateCrumbs() + { + var crumbs = new List(); + if (_virtualDirs && _prefix.Length > 0) + { + string[] parts = _prefix.TrimEnd('/').Split('/'); + string path = ""; + for (int i = 0; i < parts.Length; i++) + { + path += parts[i] + "/"; + crumbs.Add(new(parts[i].Length == 0 ? "/" : parts[i], path, i == parts.Length - 1)); + } + } + Crumbs = crumbs; + } + + /// + /// 宿主状态栏那一行:shop.fs · 9 项 · products/SKU-7710/(设计稿 06 底部)。 + /// + private void UpdateStatus() => + StatusText = Loc.Format("Fs_Status", $"{Bucket.Database}.{Bucket.Name}", BsonText.Grouped(_listed.Count), + _virtualDirs ? PrefixText : Loc["Fs_Flat"]); + + /// + public override void Dispose() + { + _loadCts?.Cancel(); + _searchDelay.Stop(); + _transferTicker.Stop(); + _transferCts?.Cancel(); + _thumbCts?.Cancel(); + Details.Dispose(); + base.Dispose(); + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/GridFs/GridFsUploadDialogView.axaml b/plugins/VelaShell.Plugin.Mongo/Ui/GridFs/GridFsUploadDialogView.axaml new file mode 100644 index 0000000..e9a9d0c --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/GridFs/GridFsUploadDialogView.axaml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/GridFs/GridFsUploadDialogView.axaml.cs b/plugins/VelaShell.Plugin.Mongo/Ui/GridFs/GridFsUploadDialogView.axaml.cs new file mode 100644 index 0000000..8fcf320 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/GridFs/GridFsUploadDialogView.axaml.cs @@ -0,0 +1,30 @@ +using Avalonia.Controls; +using Avalonia.Input; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 「上传到 GridFS」对话框视图。Ctrl+Enter 等同于点上传(metadata 编辑框里 Enter 是换行)。 +public sealed partial class GridFsUploadDialogView : UserControl +{ + /// 用给定的视图模型初始化。 + internal GridFsUploadDialogView(GridFsUploadDialogViewModel viewModel) + { + InitializeComponent(); + DataContext = viewModel; + KeyDown += (_, e) => + { + if (e.Key == Key.Enter && (e.KeyModifiers.HasFlag(KeyModifiers.Control) || ReferenceEquals(e.Source, TargetBox)) + && viewModel.ConfirmCommand.CanExecute(null)) + { + viewModel.ConfirmCommand.Execute(null); + e.Handled = true; + } + }; + } + + /// 设计器 / headless 用的无参构造。 + public GridFsUploadDialogView() + { + InitializeComponent(); + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/GridFs/GridFsUploadDialogViewModel.cs b/plugins/VelaShell.Plugin.Mongo/Ui/GridFs/GridFsUploadDialogViewModel.cs new file mode 100644 index 0000000..cfac3d6 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/GridFs/GridFsUploadDialogViewModel.cs @@ -0,0 +1,129 @@ +using Avalonia.Controls; +using MongoDB.Bson; +using VelaShell.Plugin.Mongo.Bson; +using VelaShell.Plugin.Mongo.Core; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 上传对话框的结果:落到哪个目录、用什么 metadata 模板。 +/// 目标前缀(带结尾斜杠;根为空串)。 +/// metadata 模板(没写 contentType 的文件按扩展名补)。 +internal sealed record GridFsUploadPlan(string Prefix, BsonDocument Metadata); + +/// +/// 「上传到 GridFS」小对话框(设计稿 06 拖放区那句"可在上传前编辑 metadata")。 +/// +/// 一批文件共用一份 metadata 模板,默认带 contentType(全批同一种时)与 uploader; +/// 模板里不写 contentType,就按每个文件的扩展名逐个补 —— 一次拖进来图片和 PDF 混在一起是常态。 +/// +/// +internal sealed class GridFsUploadDialogViewModel : DialogViewModel, IViewFactory +{ + private readonly TaskCompletionSource _result = new(TaskCreationOptions.RunContinuationsAsynchronously); + private string _target; + private string _metadataText; + private string? _error; + + /// 构造。 + /// 外壳服务。 + /// 桶。 + /// 默认目标目录。 + /// 要上传的文件。 + /// 默认 metadata 模板。 + public GridFsUploadDialogViewModel(IMongoWorkspace workspace, GridFsBucketInfo bucket, string prefix, + IReadOnlyList items, BsonDocument metadata) + : base(workspace) + { + Items = items; + Title = Loc["Fs_UploadTitle"]; + Subtitle = $"{bucket.Database}.{bucket.Name}"; + _target = prefix; + _metadataText = BsonText.Pretty(metadata); + ConfirmCommand = new RelayCommand(Confirm, () => _error is null); + } + + /// + public override string IconKey => "Mongo.upload"; + + /// + public override double Width => 480; + + /// 要上传的文件。 + public IReadOnlyList Items { get; } + + /// 「3 个文件 · 4.2 MB」。 + public string Summary => Loc.Format("Fs_UploadFiles", Items.Count, BsonText.Bytes(Items.Sum(static i => i.Size))); + + /// 列出的前几个文件名。 + public IReadOnlyList Preview => [.. Items.Take(5).Select(static i => i.Relative)]; + + /// 「…还有 12 个」;不足 6 个为空。 + public string More => Items.Count > 5 ? Loc.Format("Fs_UploadMore", Items.Count - 5) : ""; + + /// 目标目录(可改;空即根)。 + public string Target + { + get => _target; + set => SetProperty(ref _target, value ?? ""); + } + + /// metadata 模板文本(mongosh 写法)。 + public string MetadataText + { + get => _metadataText; + set + { + if (SetProperty(ref _metadataText, value ?? "")) + { + Error = ShellJson.TryParseDocument(_metadataText, out _, out string? error) ? null : Loc.Format("Fs_MetadataInvalid", error); + } + } + } + + /// metadata 解析错误。 + public string? Error + { + get => _error; + private set + { + if (SetProperty(ref _error, value)) + { + RaisePropertyChanged(nameof(HasError)); + ConfirmCommand.RaiseCanExecuteChanged(); + } + } + } + + /// 有错误。 + public bool HasError => _error is not null; + + /// 确认按钮文字「上传 3 个文件」。 + public string ConfirmLabel => Loc.Format("Fs_UploadConfirm", Items.Count); + + /// 上传。 + public RelayCommand ConfirmCommand { get; } + + /// 结果;取消为 。 + public Task Result => _result.Task; + + private void Confirm() + { + if (!ShellJson.TryParseDocument(_metadataText, out BsonDocument metadata, out string? error)) + { + Error = Loc.Format("Fs_MetadataInvalid", error); + return; + } + _result.TrySetResult(new(GridFsPaths.NormalizePrefix(_target), metadata)); + Close(); + } + + /// + internal override void OnClosed() + { + _result.TrySetResult(null); + base.OnClosed(); + } + + /// + public Control CreateView() => new GridFsUploadDialogView(this); +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/MenuKit.cs b/plugins/VelaShell.Plugin.Mongo/Ui/MenuKit.cs new file mode 100644 index 0000000..005a1c9 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/MenuKit.cs @@ -0,0 +1,48 @@ +using System.Windows.Input; +using Avalonia.Controls; +using Avalonia.Media; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 代码里现建的右键菜单共用的一项:图标 + 文字 + 命令。 +/// +/// +/// 普通项**不碰** :哪怕写一个 , +/// 本地值也会压过宿主 ContextMenu MenuItem 样式给的前景色,文字就成了透明的, +/// 只有指针悬停时 Fluent 模板改了内部 presenter 的颜色才露出来。 +/// 危险项直接设本地前景色:弹出层是独立的顶层,本插件的样式类够不着它。 +/// +internal static class MenuKit +{ + /// 一项绑命令的菜单。 + /// 文字。 + /// 图标键。 + /// 命令。 + /// 命令参数。 + /// 危险操作(红字红图标)。 + public static MenuItem Command(string label, string icon, ICommand command, object? parameter = null, bool danger = false) + { + var item = new MenuItem + { + Header = label, + Icon = new Glyph { Key = icon, Size = 13, Brush = ThemeBrushes.Get(danger ? "VelaError" : "VelaTextTertiary", Brushes.Gray) }, + Command = command, + CommandParameter = parameter + }; + if (danger) + { + item.Foreground = ThemeBrushes.Get("VelaError", Brushes.Red); + } + return item; + } + + /// 在 末尾补一条分隔线(开头与连续两条都跳过)。 + public static void Separator(List items) + { + if (items.Count > 0 && items[^1] is not Avalonia.Controls.Separator) + { + items.Add(new Avalonia.Controls.Separator()); + } + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/MongoStyles.axaml b/plugins/VelaShell.Plugin.Mongo/Ui/MongoStyles.axaml new file mode 100644 index 0000000..d3d3860 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/MongoStyles.axaml @@ -0,0 +1,763 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/MongoTheme.axaml b/plugins/VelaShell.Plugin.Mongo/Ui/MongoTheme.axaml new file mode 100644 index 0000000..21ad2d7 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/MongoTheme.axaml @@ -0,0 +1,189 @@ + + + + + + + + + + + + + + + + + + + + + M22 12 h-2.48 a2 2 0 0 0 -1.93 1.46 l-2.35 8.36 a.25 .25 0 0 1 -.48 0 L9.24 2.18 a.25 .25 0 0 0 -.48 0 l-2.35 8.36 A2 2 0 0 1 4.49 12 H2 + M3 3 h18 a1 1 0 0 1 1 1 v3 a1 1 0 0 1 -1 1 h-18 a1 1 0 0 1 -1 -1 v-3 a1 1 0 0 1 1 -1 z M4 8 v11 a2 2 0 0 0 2 2 h12 a2 2 0 0 0 2 -2 V8 M10 12 h4 + M3 3 h18 a1 1 0 0 1 1 1 v3 a1 1 0 0 1 -1 1 h-18 a1 1 0 0 1 -1 -1 v-3 a1 1 0 0 1 1 -1 z M4 8 v11 a2 2 0 0 0 2 2 h12 a2 2 0 0 0 2 -2 V8 M9.5 17 l5 -5 M9.5 12 l5 5 + M12 5 v14 M19 12 l-7 7 l-7 -7 + M3 16 l4 4 l4 -4 M7 20 V4 M21 8 l-4 -4 l-4 4 M17 4 v16 + M8 3 L4 7 l4 4 M4 7 h16 M16 21 l4 -4 l-4 -4 M20 17 H4 + M5 12 h14 M12 5 l7 7 l-7 7 + M5 12 l7 -7 l7 7 M12 19 V5 + M18 9 l-6 -6 l-6 6 M12 3 v14 M5 21 h14 + M17 3 a2 2 0 0 1 2 2 v15 a1 1 0 0 1 -1.496 .868 l-4.512 -2.578 a2 2 0 0 0 -1.984 0 l-4.512 2.578 A1 1 0 0 1 5 20 V5 a2 2 0 0 1 2 -2 z + M8 3 H7 a2 2 0 0 0 -2 2 v5 a2 2 0 0 1 -2 2 a2 2 0 0 1 2 2 v5 c0 1.1 .9 2 2 2 h1 M16 21 h1 a2 2 0 0 0 2 -2 v-5 c0 -1.1 .9 -2 2 -2 a2 2 0 0 1 -2 -2 V5 a2 2 0 0 0 -2 -2 h-1 + M16 3 h3 a1 1 0 0 1 1 1 v16 a1 1 0 0 1 -1 1 h-3 M8 21 H5 a1 1 0 0 1 -1 -1 V4 a1 1 0 0 1 1 -1 h3 + M8 2 v3 M16 2 v3 M5 3 h14 a2 2 0 0 1 2 2 v14 a2 2 0 0 1 -2 2 h-14 a2 2 0 0 1 -2 -2 v-14 a2 2 0 0 1 2 -2 z M3 9 h18 + M13.997 4 a2 2 0 0 1 1.76 1.05 l.486 .9 A2 2 0 0 0 18.003 7 H20 a2 2 0 0 1 2 2 v9 a2 2 0 0 1 -2 2 H4 a2 2 0 0 1 -2 -2 V9 a2 2 0 0 1 2 -2 h1.997 a2 2 0 0 0 1.759 -1.048 l.489 -.904 A2 2 0 0 1 10.004 4 z M9 13 a3 3 0 1 0 6 0 a3 3 0 1 0 -6 0 z + M3 3 v16 a2 2 0 0 0 2 2 h16 M18 17 V9 M13 17 V5 M8 17 v-3 + M5 21 v-6 M12 21 V3 M19 21 V9 + M20 6 L9 17 l-5 -5 + M6 9 l6 6 l6 -6 + M15 18 l-6 -6 l6 -6 + M9 18 l6 -6 l-6 -6 + M18 15 l-6 -6 l-6 6 + M11 17 l-5 -5 l5 -5 M18 17 l-5 -5 l5 -5 + M6 17 l5 -5 l-5 -5 M13 17 l5 -5 l-5 -5 + M2 12 a10 10 0 1 0 20 0 a10 10 0 1 0 -20 0 z M16 9 l-5.5 5.5 L8 12 + M10.1 2.182 a10 10 0 0 1 3.8 0 M13.9 21.818 a10 10 0 0 1 -3.8 0 M17.609 3.721 a10 10 0 0 1 2.69 2.7 M2.182 13.9 a10 10 0 0 1 0 -3.8 M20.279 17.609 a10 10 0 0 1 -2.7 2.69 M21.818 10.1 a10 10 0 0 1 0 3.8 M3.721 6.391 a10 10 0 0 1 2.7 -2.69 M6.391 20.279 a10 10 0 0 1 -2.69 -2.7 + M2 12 a10 10 0 1 0 20 0 a10 10 0 1 0 -20 0 z M8 12 h8 + M2 12 a10 10 0 1 0 20 0 a10 10 0 1 0 -20 0 z M15 9 l-6 6 M9 9 l6 6 + M11 14 h10 M16 4 h2 a2 2 0 0 1 2 2 v1.344 M17 18 l4 -4 l-4 -4 M8 4 H6 a2 2 0 0 0 -2 2 v14 a2 2 0 0 0 2 2 h12 a2 2 0 0 0 1.793 -1.113 M9 2 h6 a1 1 0 0 1 1 1 v2 a1 1 0 0 1 -1 1 h-6 a1 1 0 0 1 -1 -1 v-2 a1 1 0 0 1 1 -1 z + M12 13 v8 M4 14.899 A7 7 0 1 1 15.71 8 h1.79 a4.5 4.5 0 0 1 2.5 8.242 M8 17 l4 -4 l4 4 + M16 18 l6 -6 l-6 -6 M8 6 l-6 6 l6 6 + M18 16 l4 -4 l-4 -4 M6 8 l-4 4 l4 4 M14.5 4 l-5 16 + M5 3 h14 a2 2 0 0 1 2 2 v14 a2 2 0 0 1 -2 2 h-14 a2 2 0 0 1 -2 -2 v-14 a2 2 0 0 1 2 -2 z M9 3 v18 M15 3 v18 + M10 8 h10 a2 2 0 0 1 2 2 v10 a2 2 0 0 1 -2 2 h-10 a2 2 0 0 1 -2 -2 v-10 a2 2 0 0 1 2 -2 z M4 16 c-1.1 0 -2 -.9 -2 -2 V4 c0 -1.1 .9 -2 2 -2 h10 c1.1 0 2 .9 2 2 + M12 15 L18 15 M10 8 h10 a2 2 0 0 1 2 2 v10 a2 2 0 0 1 -2 2 h-10 a2 2 0 0 1 -2 -2 v-10 a2 2 0 0 1 2 -2 z M4 16 c-1.1 0 -2 -.9 -2 -2 V4 c0 -1.1 .9 -2 2 -2 h10 c1.1 0 2 .9 2 2 + M15 12 L15 18 M12 15 L18 15 M10 8 h10 a2 2 0 0 1 2 2 v10 a2 2 0 0 1 -2 2 h-10 a2 2 0 0 1 -2 -2 v-10 a2 2 0 0 1 2 -2 z M4 16 c-1.1 0 -2 -.9 -2 -2 V4 c0 -1.1 .9 -2 2 -2 h10 c1.1 0 2 .9 2 2 + M14 9 L9 4 L4 9 M20 20 h-7 a4 4 0 0 1 -4 -4 V4 + M3 5 a9 3 0 1 0 18 0 a9 3 0 1 0 -18 0 z M3 5 V19 A9 3 0 0 0 21 19 V5 M3 12 A9 3 0 0 0 21 12 + M12 15 V3 M21 15 v4 a2 2 0 0 1 -2 2 H5 a2 2 0 0 1 -2 -2 v-4 M7 10 l5 5 l5 -5 + M21 21 H8 a2 2 0 0 1 -1.42 -.587 l-3.994 -3.999 a2 2 0 0 1 0 -2.828 l10 -10 a2 2 0 0 1 2.829 0 l5.999 6 a2 2 0 0 1 0 2.828 L12.834 21 M5.082 11.09 l8.828 8.828 + M15 3 h6 v6 M10 14 L21 3 M18 13 v6 a2 2 0 0 1 -2 2 H5 a2 2 0 0 1 -2 -2 V8 a2 2 0 0 1 2 -2 h6 + M2.062 12.348 a1 1 0 0 1 0 -.696 a10.75 10.75 0 0 1 19.876 0 a1 1 0 0 1 0 .696 a10.75 10.75 0 0 1 -19.876 0 M9 12 a3 3 0 1 0 6 0 a3 3 0 1 0 -6 0 z + M10.733 5.076 a10.744 10.744 0 0 1 11.205 6.575 a1 1 0 0 1 0 .696 a10.747 10.747 0 0 1 -1.444 2.49 M14.084 14.158 a3 3 0 0 1 -4.242 -4.242 M17.479 17.499 a10.75 10.75 0 0 1 -15.417 -5.151 a1 1 0 0 1 0 -.696 a10.75 10.75 0 0 1 4.446 -5.143 M2 2 l20 20 + M6 22 a2 2 0 0 1 -2 -2 V4 a2 2 0 0 1 2 -2 h8 a2.4 2.4 0 0 1 1.704 .706 l3.588 3.588 A2.4 2.4 0 0 1 20 8 v12 a2 2 0 0 1 -2 2 z M14 2 v5 a1 1 0 0 0 1 1 h5 M10 12.5 L8 15 l2 2.5 M14 12.5 l2 2.5 l-2 2.5 + M6 22 a2 2 0 0 1 -2 -2 V4 a2 2 0 0 1 2 -2 h8 a2.4 2.4 0 0 1 1.704 .706 l3.588 3.588 A2.4 2.4 0 0 1 20 8 v12 a2 2 0 0 1 -2 2 z M14 2 v5 a1 1 0 0 0 1 1 h5 M8 12 a2 2 0 1 0 4 0 a2 2 0 1 0 -4 0 z M20 17 l-1.296 -1.296 a2.41 2.41 0 0 0 -3.408 0 L9 22 + M14.364 13.634 a2 2 0 0 0 -.506 .854 l-.837 2.87 a.5 .5 0 0 0 .62 .62 l2.87 -.837 a2 2 0 0 0 .854 -.506 l4.013 -4.009 a1 1 0 0 0 -3.004 -3.004 z M14.487 7.858 A1 1 0 0 1 14 7 V2 M20 19.645 V20 a2 2 0 0 1 -2 2 H6 a2 2 0 0 1 -2 -2 V4 a2 2 0 0 1 2 -2 h8 a2.4 2.4 0 0 1 1.704 .706 l2.516 2.516 M8 18 h1 + M6 22 a2 2 0 0 1 -2 -2 V4 a2 2 0 0 1 2 -2 h8 a2.4 2.4 0 0 1 1.704 .706 l3.588 3.588 A2.4 2.4 0 0 1 20 8 v12 a2 2 0 0 1 -2 2 z M14 2 v5 a1 1 0 0 0 1 1 h5 M9 14.5 a2.5 2.5 0 1 0 5 0 a2.5 2.5 0 1 0 -5 0 z M13.3 16.3 L15 18 + M6 22 a2 2 0 0 1 -2 -2 V4 a2 2 0 0 1 2 -2 h8 a2.4 2.4 0 0 1 1.704 .706 l3.588 3.588 A2.4 2.4 0 0 1 20 8 v12 a2 2 0 0 1 -2 2 z M14 2 v5 a1 1 0 0 0 1 1 h5 M8 13 h2 M14 13 h2 M8 17 h2 M14 17 h2 + M6 22 a2 2 0 0 1 -2 -2 V4 a2 2 0 0 1 2 -2 h8 a2.4 2.4 0 0 1 1.704 .706 l3.588 3.588 A2.4 2.4 0 0 1 20 8 v12 a2 2 0 0 1 -2 2 z M14 2 v5 a1 1 0 0 0 1 1 h5 M10 9 H8 M16 13 H8 M16 17 H8 + M6 22 a2 2 0 0 1 -2 -2 V4 a2 2 0 0 1 2 -2 h8 a2.4 2.4 0 0 1 1.704 .706 l3.588 3.588 A2.4 2.4 0 0 1 20 8 v12 a2 2 0 0 1 -2 2 z M14 2 v5 a1 1 0 0 0 1 1 h5 M12 12 v6 M15 15 l-3 -3 l-3 3 + M5 3 h14 a2 2 0 0 1 2 2 v14 a2 2 0 0 1 -2 2 h-14 a2 2 0 0 1 -2 -2 v-14 a2 2 0 0 1 2 -2 z M7 3 v18 M3 7.5 h4 M3 12 h18 M3 16.5 h4 M17 3 v18 M17 7.5 h4 M17 16.5 h4 + M4 22 V4 a1 1 0 0 1 .4 -.8 A6 6 0 0 1 8 2 c3 0 5 2 7.333 2 q2 0 3.067 -.8 A1 1 0 0 1 20 4 v10 a1 1 0 0 1 -.4 .8 A6 6 0 0 1 16 16 c-3 0 -5 -2 -8 -2 a6 6 0 0 0 -4 1.528 + M14 2 v6 a2 2 0 0 0 .245 .96 l5.51 10.08 A2 2 0 0 1 18 22 H6 a2 2 0 0 1 -1.755 -2.96 l5.51 -10.08 A2 2 0 0 0 10 8 V2 M6.453 15 h11.094 M8.5 2 h7 + M20 20 a2 2 0 0 0 2 -2 V8 a2 2 0 0 0 -2 -2 h-7.9 a2 2 0 0 1 -1.69 -.9 L9.6 3.9 A2 2 0 0 0 7.93 3 H4 a2 2 0 0 0 -2 2 v13 a2 2 0 0 0 2 2 Z + M6 14 l1.5 -2.9 A2 2 0 0 1 9.24 10 H20 a2 2 0 0 1 1.94 2.5 l-1.54 6 a2 2 0 0 1 -1.95 1.5 H4 a2 2 0 0 1 -2 -2 V5 a2 2 0 0 1 2 -2 h3.9 a2 2 0 0 1 1.69 .9 l.81 1.2 a2 2 0 0 0 1.67 .9 H18 a2 2 0 0 1 2 2 v2 + M12 10 v6 M9 13 h6 M20 20 a2 2 0 0 0 2 -2 V8 a2 2 0 0 0 -2 -2 h-7.9 a2 2 0 0 1 -1.69 -.9 L9.6 3.9 A2 2 0 0 0 7.93 3 H4 a2 2 0 0 0 -2 2 v13 a2 2 0 0 0 2 2 Z + M20 20 a2 2 0 0 0 2 -2 V8 a2 2 0 0 0 -2 -2 h-7.9 a2 2 0 0 1 -1.69 -.9 L9.6 3.9 A2 2 0 0 0 7.93 3 H4 a2 2 0 0 0 -2 2 v13 a2 2 0 0 0 2 2 Z M12 10 v6 M9 13 l3 -3 l3 3 + M10 20 a1 1 0 0 0 .553 .895 l2 1 A1 1 0 0 0 14 21 v-7 a2 2 0 0 1 .517 -1.341 L21.74 4.67 A1 1 0 0 0 21 3 H3 a1 1 0 0 0 -.742 1.67 l7.225 7.989 A2 2 0 0 1 10 14 z + M12 14 l4 -4 M3.34 19 a10 10 0 1 1 17.32 0 + M15 18 a3 3 0 1 0 6 0 a3 3 0 1 0 -6 0 z M3 6 a3 3 0 1 0 6 0 a3 3 0 1 0 -6 0 z M13 6 h3 a2 2 0 0 1 2 2 v7 M11 18 H8 a2 2 0 0 1 -2 -2 V9 + M15 18 a3 3 0 1 0 6 0 a3 3 0 1 0 -6 0 z M3 6 a3 3 0 1 0 6 0 a3 3 0 1 0 -6 0 z M6 21 V9 a9 9 0 0 0 9 9 + M15 18 a3 3 0 1 0 6 0 a3 3 0 1 0 -6 0 z M3 6 a3 3 0 1 0 6 0 a3 3 0 1 0 -6 0 z M18 6 V5 M18 11 v-1 M6 9 L6 21 + M2 12 a10 10 0 1 0 20 0 a10 10 0 1 0 -20 0 z M12 2 a14.5 14.5 0 0 0 0 20 a14.5 14.5 0 0 0 0 -20 M2 12 h20 + M8 12 a1 1 0 1 0 2 0 a1 1 0 1 0 -2 0 z M8 5 a1 1 0 1 0 2 0 a1 1 0 1 0 -2 0 z M8 19 a1 1 0 1 0 2 0 a1 1 0 1 0 -2 0 z M14 12 a1 1 0 1 0 2 0 a1 1 0 1 0 -2 0 z M14 5 a1 1 0 1 0 2 0 a1 1 0 1 0 -2 0 z M14 19 a1 1 0 1 0 2 0 a1 1 0 1 0 -2 0 z + M10 16 h.01 M2.212 11.577 a2 2 0 0 0 -.212 .896 V18 a2 2 0 0 0 2 2 h16 a2 2 0 0 0 2 -2 v-5.527 a2 2 0 0 0 -.212 -.896 L18.55 5.11 A2 2 0 0 0 16.76 4 H7.24 a2 2 0 0 0 -1.79 1.11 z M21.946 12.013 H2.054 M6 16 h.01 + M3 12 a9 9 0 1 0 9 -9 a9.75 9.75 0 0 0 -6.74 2.74 L3 8 M3 3 v5 h5 M12 7 v5 l4 2 + M22 12 L16 12 L14 15 L10 15 L8 12 L2 12 M5.45 5.11 L2 12 v6 a2 2 0 0 0 2 2 h16 a2 2 0 0 0 2 -2 v-6 l-3.45 -6.89 A2 2 0 0 0 16.76 4 H7.24 a2 2 0 0 0 -1.79 1.11 z + M2 12 a10 10 0 1 0 20 0 a10 10 0 1 0 -20 0 z M12 16 v-4 M12 8 h.01 + M2.586 17.414 A2 2 0 0 0 2 18.828 V21 a1 1 0 0 0 1 1 h3 a1 1 0 0 0 1 -1 v-1 a1 1 0 0 1 1 -1 h1 a1 1 0 0 0 1 -1 v-1 a1 1 0 0 1 1 -1 h.172 a2 2 0 0 0 1.414 -.586 l.814 -.814 a6.5 6.5 0 1 0 -4 -4 z M16 7.5 a0.5 0.5 0 1 0 1 0 a0.5 0.5 0 1 0 -1 0 z + M12.83 2.18 a2 2 0 0 0 -1.66 0 L2.6 6.08 a1 1 0 0 0 0 1.83 l8.58 3.91 a2 2 0 0 0 1.66 0 l8.58 -3.9 a1 1 0 0 0 0 -1.83 z M2 12 a1 1 0 0 0 .58 .91 l8.6 3.91 a2 2 0 0 0 1.65 0 l8.58 -3.9 A1 1 0 0 0 22 12 M2 17 a1 1 0 0 0 .58 .91 l8.6 3.91 a2 2 0 0 0 1.65 0 l8.58 -3.9 A1 1 0 0 0 22 17 + M4 3 h5 a1 1 0 0 1 1 1 v5 a1 1 0 0 1 -1 1 h-5 a1 1 0 0 1 -1 -1 v-5 a1 1 0 0 1 1 -1 z M15 3 h5 a1 1 0 0 1 1 1 v5 a1 1 0 0 1 -1 1 h-5 a1 1 0 0 1 -1 -1 v-5 a1 1 0 0 1 1 -1 z M15 14 h5 a1 1 0 0 1 1 1 v5 a1 1 0 0 1 -1 1 h-5 a1 1 0 0 1 -1 -1 v-5 a1 1 0 0 1 1 -1 z M4 14 h5 a1 1 0 0 1 1 1 v5 a1 1 0 0 1 -1 1 h-5 a1 1 0 0 1 -1 -1 v-5 a1 1 0 0 1 1 -1 z + M4 3 h16 a1 1 0 0 1 1 1 v5 a1 1 0 0 1 -1 1 h-16 a1 1 0 0 1 -1 -1 v-5 a1 1 0 0 1 1 -1 z M4 14 h7 a1 1 0 0 1 1 1 v5 a1 1 0 0 1 -1 1 h-7 a1 1 0 0 1 -1 -1 v-5 a1 1 0 0 1 1 -1 z M17 14 h3 a1 1 0 0 1 1 1 v5 a1 1 0 0 1 -1 1 h-3 a1 1 0 0 1 -1 -1 v-5 a1 1 0 0 1 1 -1 z + M11 20 a10 10 0 0 0 10 -10 a25.9 25.9 0 0 0 -1.04 -7.281 a1 1 0 0 0 -1.755 -.325 C15.833 5.5 13 5.5 9.8 6.1 A7 7 0 0 0 11 20 M2 21 a5 5 0 0 1 2.911 -4.544 C7.613 15.212 8.351 15.24 11 13 + M15 14 c.2 -1 .7 -1.7 1.5 -2.5 c1 -.9 1.5 -2.2 1.5 -3.5 A6 6 0 0 0 6 8 c0 1 .2 2.2 1.5 3.5 c.7 .7 1.3 1.5 1.5 2.5 M9 18 h6 M10 22 h4 + M10 13 a5 5 0 0 0 7.54 .54 l3 -3 a5 5 0 0 0 -7.07 -7.07 l-1.72 1.71 M14 11 a5 5 0 0 0 -7.54 -.54 l-3 3 a5 5 0 0 0 7.07 7.07 l1.71 -1.71 + M3 5 h.01 M3 12 h.01 M3 19 h.01 M8 5 h13 M8 12 h13 M8 19 h13 + M13 5 h8 M13 12 h8 M13 19 h8 M3 17 l2 2 l4 -4 M3 7 l2 2 l4 -4 + M10 5 h11 M10 12 h11 M10 19 h11 M3 10 l3 -3 l-3 -3 M3 20 l3 -3 l-3 -3 + M16 5 H3 M16 12 H3 M9 19 H3 M16 16 l-3 3 l3 3 M21 5 v12 a2 2 0 0 1 -2 2 h-6 + M16 5 H3 M11 12 H3 M16 19 H3 M18 9 v6 M21 12 h-6 + M8 5 h13 M13 12 h8 M13 19 h8 M3 10 a2 2 0 0 0 2 2 h3 M3 5 v12 a2 2 0 0 0 2 2 h3 + M21 12 a9 9 0 1 1 -6.219 -8.56 + M5 11 h14 a2 2 0 0 1 2 2 v7 a2 2 0 0 1 -2 2 h-14 a2 2 0 0 1 -2 -2 v-7 a2 2 0 0 1 2 -2 z M7 11 V7 a5 5 0 0 1 10 0 v4 + M5 11 h14 a2 2 0 0 1 2 2 v7 a2 2 0 0 1 -2 2 h-14 a2 2 0 0 1 -2 -2 v-7 a2 2 0 0 1 2 -2 z M7 11 V7 a5 5 0 0 1 9.9 -1 + M15 3 h6 v6 M21 3 l-7 7 M3 21 l7 -7 M9 21 H3 v-6 + M12 12 v-2 M12 18 v-2 M16 12 v-2 M16 18 v-2 M2 11 h1.5 M20 18 v-2 M20.5 11 H22 M4 18 v-2 M8 12 v-2 M8 18 v-2 M4 6 h16 a2 2 0 0 1 2 2 v6 a2 2 0 0 1 -2 2 h-16 a2 2 0 0 1 -2 -2 v-6 a2 2 0 0 1 2 -2 z + M22 17 a2 2 0 0 1 -2 2 H6.828 a2 2 0 0 0 -1.414 .586 l-2.202 2.202 A.71 .71 0 0 1 2 21.286 V5 a2 2 0 0 1 2 -2 h16 a2 2 0 0 1 2 2 z M7 11 h10 M7 15 h6 M7 7 h8 + M14 10 l7 -7 M20 10 h-6 V4 M3 21 l7 -7 M4 14 h6 v6 + M5 12 h14 + M14 4.1 L12 6 M5.1 8 l-2.9 -.8 M6 12 l-1.9 2 M7.2 2.2 L8 5.1 M9.037 9.69 a.498 .498 0 0 1 .653 -.653 l11 4.5 a.5 .5 0 0 1 -.074 .949 l-4.349 1.041 a1 1 0 0 0 -.74 .739 l-1.04 4.35 a.5 .5 0 0 1 -.95 .074 z + M17 16 h4 a1 1 0 0 1 1 1 v4 a1 1 0 0 1 -1 1 h-4 a1 1 0 0 1 -1 -1 v-4 a1 1 0 0 1 1 -1 z M3 16 h4 a1 1 0 0 1 1 1 v4 a1 1 0 0 1 -1 1 h-4 a1 1 0 0 1 -1 -1 v-4 a1 1 0 0 1 1 -1 z M10 2 h4 a1 1 0 0 1 1 1 v4 a1 1 0 0 1 -1 1 h-4 a1 1 0 0 1 -1 -1 v-4 a1 1 0 0 1 1 -1 z M5 16 v-3 a1 1 0 0 1 1 -1 h12 a1 1 0 0 1 1 1 v3 M12 12 V8 + M15 9 l-6 6 M2.586 16.726 A2 2 0 0 1 2 15.312 V8.688 a2 2 0 0 1 .586 -1.414 l4.688 -4.688 A2 2 0 0 1 8.688 2 h6.624 a2 2 0 0 1 1.414 .586 l4.688 4.688 A2 2 0 0 1 22 8.688 v6.624 a2 2 0 0 1 -.586 1.414 l-4.688 4.688 a2 2 0 0 1 -1.414 .586 H8.688 a2 2 0 0 1 -1.414 -.586 z M9 9 l6 6 + M5 3 h14 a2 2 0 0 1 2 2 v14 a2 2 0 0 1 -2 2 h-14 a2 2 0 0 1 -2 -2 v-14 a2 2 0 0 1 2 -2 z M15 3 v18 + M5 3 h14 a2 2 0 0 1 2 2 v14 a2 2 0 0 1 -2 2 h-14 a2 2 0 0 1 -2 -2 v-14 a2 2 0 0 1 2 -2 z M15 3 v18 M8 9 l3 3 l-3 3 + M5 3 h14 a2 2 0 0 1 2 2 v14 a2 2 0 0 1 -2 2 h-14 a2 2 0 0 1 -2 -2 v-14 a2 2 0 0 1 2 -2 z M15 3 v18 M10 15 l-3 -3 l3 -3 + M8 21 s-4 -3 -4 -9 s4 -9 4 -9 M16 3 s4 3 4 9 s-4 9 -4 9 + M15 3 h3 a1 1 0 0 1 1 1 v16 a1 1 0 0 1 -1 1 h-3 a1 1 0 0 1 -1 -1 v-16 a1 1 0 0 1 1 -1 z M6 3 h3 a1 1 0 0 1 1 1 v16 a1 1 0 0 1 -1 1 h-3 a1 1 0 0 1 -1 -1 v-16 a1 1 0 0 1 1 -1 z + M21.174 6.812 a1 1 0 0 0 -3.986 -3.987 L3.842 16.174 a2 2 0 0 0 -.5 .83 l-1.321 4.352 a.5 .5 0 0 0 .623 .622 l4.353 -1.32 a2 2 0 0 0 .83 -.497 z M15 5 l4 4 + M13 21 h8 M15 5 l4 4 M21.174 6.812 a1 1 0 0 0 -3.986 -3.987 L3.842 16.174 a2 2 0 0 0 -.5 .83 l-1.321 4.352 a.5 .5 0 0 0 .623 .622 l4.353 -1.32 a2 2 0 0 0 .83 -.497 z + M13 7 L8.7 2.7 a2.41 2.41 0 0 0 -3.4 0 L2.7 5.3 a2.41 2.41 0 0 0 0 3.4 L7 13 M8 6 l2 -2 M18 16 l2 -2 M17 11 l4.3 4.3 c.94 .94 .94 2.46 0 3.4 l-2.6 2.6 c-.94 .94 -2.46 .94 -3.4 0 L11 17 M21.174 6.812 a1 1 0 0 0 -3.986 -3.987 L3.842 16.174 a2 2 0 0 0 -.5 .83 l-1.321 4.352 a.5 .5 0 0 0 .623 .622 l4.353 -1.32 a2 2 0 0 0 .83 -.497 z M15 5 l4 4 + M12 17 v5 M9 10.76 a2 2 0 0 1 -1.11 1.79 l-1.78 .9 A2 2 0 0 0 5 15.24 V16 a1 1 0 0 0 1 1 h12 a1 1 0 0 0 1 -1 v-.76 a2 2 0 0 0 -1.11 -1.79 l-1.78 -.9 A2 2 0 0 1 15 10.76 V7 a1 1 0 0 1 1 -1 a2 2 0 0 0 0 -4 H8 a2 2 0 0 0 0 4 a1 1 0 0 1 1 1 z + M5 5 a2 2 0 0 1 3.008 -1.728 l11.997 6.998 a2 2 0 0 1 .003 3.458 l-12 7 A2 2 0 0 1 5 19 z + M12 22 v-5 M15 8 V2 M17 8 a1 1 0 0 1 1 1 v4 a4 4 0 0 1 -4 4 h-4 a4 4 0 0 1 -4 -4 V9 a1 1 0 0 1 1 -1 z M9 8 V2 + M6.3 20.3 a2.4 2.4 0 0 0 3.4 0 L12 18 l-6 -6 l-2.3 2.3 a2.4 2.4 0 0 0 0 3.4 Z M2 22 l3 -3 M7.5 13.5 L10 11 M10.5 16.5 L13 14 M18 3 l-4 4 h6 l-4 4 + M5 12 h14 M12 5 v14 + M3 12 a9 9 0 0 1 9 -9 a9.75 9.75 0 0 1 6.74 2.74 L21 8 M21 3 v5 h-5 M21 12 a9 9 0 0 1 -9 9 a9.75 9.75 0 0 1 -6.74 -2.74 L3 16 M8 16 H3 v5 + M21 12 a9 9 0 1 1 -9 -9 c2.52 0 4.93 1 6.74 2.74 L21 8 M21 3 v5 h-5 + M3 19 a3 3 0 1 0 6 0 a3 3 0 1 0 -6 0 z M9 19 h8.5 a3.5 3.5 0 0 0 0 -7 h-11 a3.5 3.5 0 0 1 0 -7 H15 M15 5 a3 3 0 1 0 6 0 a3 3 0 1 0 -6 0 z + M5 3 h14 a2 2 0 0 1 2 2 v14 a2 2 0 0 1 -2 2 h-14 a2 2 0 0 1 -2 -2 v-14 a2 2 0 0 1 2 -2 z M21 9 H3 M21 15 H3 + M15.2 3 a2 2 0 0 1 1.4 .6 l3.8 3.8 a2 2 0 0 1 .6 1.4 V19 a2 2 0 0 1 -2 2 H5 a2 2 0 0 1 -2 -2 V5 a2 2 0 0 1 2 -2 z M17 21 v-7 a1 1 0 0 0 -1 -1 H8 a1 1 0 0 0 -1 1 v7 M7 3 v4 a1 1 0 0 0 1 1 h7 + M15 12 h-5 M15 8 h-5 M19 17 V5 a2 2 0 0 0 -2 -2 H4 M8 21 h12 a2 2 0 0 0 2 -2 v-1 a1 1 0 0 0 -1 -1 H11 a1 1 0 0 0 -1 1 v1 a2 2 0 1 1 -4 0 V5 a2 2 0 1 0 -4 0 v2 a1 1 0 0 0 1 1 h3 + M21 21 l-4.34 -4.34 M3 11 a8 8 0 1 0 16 0 a8 8 0 1 0 -16 0 z + M13.5 8.5 l-5 5 M8.5 8.5 l5 5 M3 11 a8 8 0 1 0 16 0 a8 8 0 1 0 -16 0 z M21 21 l-4.3 -4.3 + M4 2 h16 a2 2 0 0 1 2 2 v4 a2 2 0 0 1 -2 2 h-16 a2 2 0 0 1 -2 -2 v-4 a2 2 0 0 1 2 -2 z M4 14 h16 a2 2 0 0 1 2 2 v4 a2 2 0 0 1 -2 2 h-16 a2 2 0 0 1 -2 -2 v-4 a2 2 0 0 1 2 -2 z M6 6 L6.01 6 M6 18 L6.01 18 + M14 17 H5 M19 7 h-9 M14 17 a3 3 0 1 0 6 0 a3 3 0 1 0 -6 0 z M4 7 a3 3 0 1 0 6 0 a3 3 0 1 0 -6 0 z + M8.3 10 a.7 .7 0 0 1 -.626 -1.079 L11.4 3 a.7 .7 0 0 1 1.198 -.043 L16.3 8.9 a.7 .7 0 0 1 -.572 1.1 Z M4 14 h5 a1 1 0 0 1 1 1 v5 a1 1 0 0 1 -1 1 h-5 a1 1 0 0 1 -1 -1 v-5 a1 1 0 0 1 1 -1 z M14 17.5 a3.5 3.5 0 1 0 7 0 a3.5 3.5 0 1 0 -7 0 z + M5 3 h14 a2 2 0 0 1 2 2 v14 a2 2 0 0 1 -2 2 h-14 a2 2 0 0 1 -2 -2 v-14 a2 2 0 0 1 2 -2 z M3 9 L21 9 M3 15 L21 15 M9 9 L9 21 M15 9 L15 21 + M20 13 c0 5 -3.5 7.5 -7.66 8.95 a1 1 0 0 1 -.67 -.01 C7.5 20.5 4 18 4 13 V6 a1 1 0 0 1 1 -1 c2 0 4.5 -1.2 6.24 -2.72 a1.17 1.17 0 0 1 1.52 0 C14.51 3.81 17 5 19 5 a1 1 0 0 1 1 1 z + M20 13 c0 5 -3.5 7.5 -7.66 8.95 a1 1 0 0 1 -.67 -.01 C7.5 20.5 4 18 4 13 V6 a1 1 0 0 1 1 -1 c2 0 4.5 -1.2 6.24 -2.72 a1.17 1.17 0 0 1 1.52 0 C14.51 3.81 17 5 19 5 a1 1 0 0 1 1 1 z M12 8 v4 M12 16 h.01 + M20 13 c0 5 -3.5 7.5 -7.66 8.95 a1 1 0 0 1 -.67 -.01 C7.5 20.5 4 18 4 13 V6 a1 1 0 0 1 1 -1 c2 0 4.5 -1.2 6.24 -2.72 a1.17 1.17 0 0 1 1.52 0 C14.51 3.81 17 5 19 5 a1 1 0 0 1 1 1 z M9 12 l2 2 l4 -4 + M20 13 c0 5 -3.5 7.5 -7.66 8.95 a1 1 0 0 1 -.67 -.01 C7.5 20.5 4 18 4 13 V6 a1 1 0 0 1 1 -1 c2 0 4.5 -1.2 6.24 -2.72 a1.17 1.17 0 0 1 1.52 0 C14.51 3.81 17 5 19 5 a1 1 0 0 1 1 1 z M9 12 h6 M12 9 v6 + M18 7 V5 a1 1 0 0 0 -1 -1 H6.5 a.5 .5 0 0 0 -.4 .8 l4.5 6 a2 2 0 0 1 0 2.4 l-4.5 6 a.5 .5 0 0 0 .4 .8 H17 a1 1 0 0 0 1 -1 v-2 + M11.017 2.814 a1 1 0 0 1 1.966 0 l1.051 5.558 a2 2 0 0 0 1.594 1.594 l5.558 1.051 a1 1 0 0 1 0 1.966 l-5.558 1.051 a2 2 0 0 0 -1.594 1.594 l-1.051 5.558 a1 1 0 0 1 -1.966 0 l-1.051 -5.558 a2 2 0 0 0 -1.594 -1.594 l-5.558 -1.051 a1 1 0 0 1 0 -1.966 l5.558 -1.051 a2 2 0 0 0 1.594 -1.594 z M20 2 v4 M22 4 h-4 M2 20 a2 2 0 1 0 4 0 a2 2 0 1 0 -4 0 z + M5 3 h14 a2 2 0 0 1 2 2 v14 a2 2 0 0 1 -2 2 h-14 a2 2 0 0 1 -2 -2 v-14 a2 2 0 0 1 2 -2 z + M10 9 l-3 3 l3 3 M14 15 l3 -3 l-3 -3 M5 3 h14 a2 2 0 0 1 2 2 v14 a2 2 0 0 1 -2 2 h-14 a2 2 0 0 1 -2 -2 v-14 a2 2 0 0 1 2 -2 z + M7 11 l2 -2 l-2 -2 M11 13 h4 M5 3 h14 a2 2 0 0 1 2 2 v14 a2 2 0 0 1 -2 2 h-14 a2 2 0 0 1 -2 -2 v-14 a2 2 0 0 1 2 -2 z + M11.525 2.295 a.53 .53 0 0 1 .95 0 l2.31 4.679 a2.123 2.123 0 0 0 1.595 1.16 l5.166 .756 a.53 .53 0 0 1 .294 .904 l-3.736 3.638 a2.123 2.123 0 0 0 -.611 1.878 l.882 5.14 a.53 .53 0 0 1 -.771 .56 l-4.618 -2.428 a2.122 2.122 0 0 0 -1.973 0 L6.396 21.01 a.53 .53 0 0 1 -.77 -.56 l.881 -5.139 a2.122 2.122 0 0 0 -.611 -1.879 L2.16 9.795 a.53 .53 0 0 1 .294 -.906 l5.165 -.755 a2.122 2.122 0 0 0 1.597 -1.16 z + M12 3 v18 M5 3 h14 a2 2 0 0 1 2 2 v14 a2 2 0 0 1 -2 2 h-14 a2 2 0 0 1 -2 -2 v-14 a2 2 0 0 1 2 -2 z M3 9 h18 M3 15 h18 + M3 9 h18 M9 3 v18 M5 3 h14 a2 2 0 0 1 2 2 v14 a2 2 0 0 1 -2 2 h-14 a2 2 0 0 1 -2 -2 v-14 a2 2 0 0 1 2 -2 z + M15 3 v18 M5 3 h14 a2 2 0 0 1 2 2 v14 a2 2 0 0 1 -2 2 h-14 a2 2 0 0 1 -2 -2 v-14 a2 2 0 0 1 2 -2 z M21 9 H3 M21 15 H3 + M12 19 h8 M4 17 l6 -6 l-6 -6 + M12 20 h-1 a2 2 0 0 1 -2 -2 a2 2 0 0 1 -2 2 H6 M13 8 h7 a2 2 0 0 1 2 2 v4 a2 2 0 0 1 -2 2 h-7 M5 16 H4 a2 2 0 0 1 -2 -2 v-4 a2 2 0 0 1 2 -2 h1 M6 4 h1 a2 2 0 0 1 2 2 a2 2 0 0 1 2 -2 h1 M9 6 v12 + M10 2 L14 2 M12 14 L15 11 M4 14 a8 8 0 1 0 16 0 a8 8 0 1 0 -16 0 z + M10 11 v6 M14 11 v6 M19 6 v14 a2 2 0 0 1 -2 2 H7 a2 2 0 0 1 -2 -2 V6 M3 6 h18 M8 6 V4 a2 2 0 0 1 2 -2 h4 a2 2 0 0 1 2 2 v2 + M21.73 18 l-8 -14 a2 2 0 0 0 -3.48 0 l-8 14 A2 2 0 0 0 4 21 h16 a2 2 0 0 0 1.73 -3 M12 9 v4 M12 17 h.01 + M12 4 v16 M4 7 V5 a1 1 0 0 1 1 -1 h14 a1 1 0 0 1 1 1 v2 M9 20 h6 + M9 14 L4 9 l5 -5 M4 9 h10.5 a5.5 5.5 0 0 1 5.5 5.5 a5.5 5.5 0 0 1 -5.5 5.5 H11 + M19 5 l3 -3 M2 22 l3 -3 M6.3 20.3 a2.4 2.4 0 0 0 3.4 0 L12 18 l-6 -6 l-2.3 2.3 a2.4 2.4 0 0 0 0 3.4 Z M7.5 13.5 L10 11 M10.5 16.5 L13 14 M12 6 l6 6 l2.3 -2.3 a2.4 2.4 0 0 0 0 -3.4 l-2.6 -2.6 a2.4 2.4 0 0 0 -3.4 0 Z + M12 3 v12 M17 8 l-5 -5 l-5 5 M21 15 v4 a2 2 0 0 1 -2 2 H5 a2 2 0 0 1 -2 -2 v-4 + M19 21 v-2 a4 4 0 0 0 -4 -4 H9 a4 4 0 0 0 -4 4 v2 M8 7 a4 4 0 1 0 8 0 a4 4 0 1 0 -8 0 z + M16 11 l2 2 l4 -4 M16 21 v-2 a4 4 0 0 0 -4 -4 H6 a4 4 0 0 0 -4 4 v2 M5 7 a4 4 0 1 0 8 0 a4 4 0 1 0 -8 0 z + M16 21 v-2 a4 4 0 0 0 -4 -4 H6 a4 4 0 0 0 -4 4 v2 M5 7 a4 4 0 1 0 8 0 a4 4 0 1 0 -8 0 z M19 8 L19 14 M22 11 L16 11 + M16 21 v-2 a4 4 0 0 0 -4 -4 H6 a4 4 0 0 0 -4 4 v2 M5 7 a4 4 0 1 0 8 0 a4 4 0 1 0 -8 0 z M17 8 L22 13 M22 8 L17 13 + M16 21 v-2 a4 4 0 0 0 -4 -4 H6 a4 4 0 0 0 -4 4 v2 M16 3.128 a4 4 0 0 1 0 7.744 M22 21 v-2 a4 4 0 0 0 -3 -3.87 M5 7 a4 4 0 1 0 8 0 a4 4 0 1 0 -8 0 z + M8 21 s-4 -3 -4 -9 s4 -9 4 -9 M16 3 s4 3 4 9 s-4 9 -4 9 M15 9 L9 15 M9 9 L15 15 + M21.64 3.64 l-1.28 -1.28 a1.21 1.21 0 0 0 -1.72 0 L2.36 18.64 a1.21 1.21 0 0 0 0 1.72 l1.28 1.28 a1.2 1.2 0 0 0 1.72 0 L21.64 5.36 a1.2 1.2 0 0 0 0 -1.72 M14 7 l3 3 M5 6 v4 M19 14 v4 M10 2 v2 M7 8 H3 M21 16 h-4 M11 3 H9 + M5 3 h4 a2 2 0 0 1 2 2 v4 a2 2 0 0 1 -2 2 h-4 a2 2 0 0 1 -2 -2 v-4 a2 2 0 0 1 2 -2 z M7 11 v4 a2 2 0 0 0 2 2 h4 M15 13 h4 a2 2 0 0 1 2 2 v4 a2 2 0 0 1 -2 2 h-4 a2 2 0 0 1 -2 -2 v-4 a2 2 0 0 1 2 -2 z + M18 6 L6 18 M6 6 l12 12 + M15.914 4 a1.5 1.5 0 0 0 -2.474 -1.561 l-9 9 A1.5 1.5 0 0 0 5.5 14 h4.002 a.5 .5 0 0 1 .471 .666 L8.086 20 a1.5 1.5 0 0 0 2.475 1.56 l9 -9 A1.5 1.5 0 0 0 18.5 10 h-3.997 a.5 .5 0 0 1 -.472 -.667 z + M5 3 h14 a2 2 0 0 1 2 2 v14 a2 2 0 0 1 -2 2 h-14 a2 2 0 0 1 -2 -2 v-14 a2 2 0 0 1 2 -2 z M16 9 l-5.5 5.5 L8 12 + M6 22 a2 2 0 0 1 -2 -2 V4 a2 2 0 0 1 2 -2 h8 a2.4 2.4 0 0 1 1.704 .706 l3.588 3.588 A2.4 2.4 0 0 1 20 8 v12 a2 2 0 0 1 -2 2 z M14 2 v5 a1 1 0 0 0 1 1 h5 M10 12 a1 1 0 0 0 -1 1 v1 a1 1 0 0 1 -1 1 a1 1 0 0 1 1 1 v1 a1 1 0 0 0 1 1 M14 18 a1 1 0 0 0 1 -1 v-1 a1 1 0 0 1 1 -1 a1 1 0 0 1 -1 -1 v-1 a1 1 0 0 0 -1 -1 + diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Monitor/MonitorBar.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Monitor/MonitorBar.cs new file mode 100644 index 0000000..677a063 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Monitor/MonitorBar.cs @@ -0,0 +1,129 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 一根细横条:一段或两段按比例填充(设计稿 11 的复制延迟条、存储 Top 的 数据 + 索引 堆叠条; +/// 15 的"已运行"进度条)。 +/// +/// 不用 ProgressBar:它只有一段,而且换色得改模板;两段堆叠在 Grid 里用星号列做的话, +/// 列宽要绑到 ColumnDefinition 上 —— 它不在逻辑树里,拿不到数据上下文。自绘十几行就够。 +/// +/// +public sealed class MonitorBar : Control +{ + /// 第一段比例(0–1)。 + public static readonly StyledProperty RatioProperty = + AvaloniaProperty.Register(nameof(Ratio)); + + /// 第二段比例(0–1,接在第一段之后;0 = 没有第二段)。 + public static readonly StyledProperty Ratio2Property = + AvaloniaProperty.Register(nameof(Ratio2)); + + /// 第一段颜色令牌。 + public static readonly StyledProperty TokenProperty = + AvaloniaProperty.Register(nameof(Token), "VelaAccent"); + + /// 第二段颜色令牌。 + public static readonly StyledProperty Token2Property = + AvaloniaProperty.Register(nameof(Token2), "MongoChart2"); + + /// 底轨颜色令牌;空 = 不画底轨。 + public static readonly StyledProperty TrackTokenProperty = + AvaloniaProperty.Register(nameof(TrackToken), "VelaBgActive"); + + /// 两段之间的缝(像素)。 + public static readonly StyledProperty GapProperty = + AvaloniaProperty.Register(nameof(Gap)); + + /// 圆角。 + public static readonly StyledProperty RadiusProperty = + AvaloniaProperty.Register(nameof(Radius), 2); + + static MonitorBar() + { + AffectsRender(RatioProperty, Ratio2Property, TokenProperty, Token2Property, TrackTokenProperty, GapProperty, RadiusProperty); + } + + /// 第一段比例。 + public double Ratio + { + get => GetValue(RatioProperty); + set => SetValue(RatioProperty, value); + } + + /// 第二段比例。 + public double Ratio2 + { + get => GetValue(Ratio2Property); + set => SetValue(Ratio2Property, value); + } + + /// 第一段颜色令牌。 + public string Token + { + get => GetValue(TokenProperty); + set => SetValue(TokenProperty, value); + } + + /// 第二段颜色令牌。 + public string Token2 + { + get => GetValue(Token2Property); + set => SetValue(Token2Property, value); + } + + /// 底轨颜色令牌。 + public string? TrackToken + { + get => GetValue(TrackTokenProperty); + set => SetValue(TrackTokenProperty, value); + } + + /// 两段之间的缝。 + public double Gap + { + get => GetValue(GapProperty); + set => SetValue(GapProperty, value); + } + + /// 圆角。 + public double Radius + { + get => GetValue(RadiusProperty); + set => SetValue(RadiusProperty, value); + } + + /// + public override void Render(DrawingContext context) + { + double width = Bounds.Width; + double height = Bounds.Height; + if (width <= 0 || height <= 0) + { + return; + } + double r = Math.Min(Radius, height / 2); + if (TrackToken is { Length: > 0 } track) + { + context.DrawRectangle(ThemeBrushes.Get(track, Brushes.DimGray), null, new Rect(0, 0, width, height), r, r); + } + double first = width * Math.Clamp(Ratio, 0, 1); + if (first > 0) + { + // 比例很小也至少画 2px:"有,但很少"与"没有"在这里是两回事(存储 Top 的索引段)。 + first = Math.Max(first, 2); + context.DrawRectangle(ThemeBrushes.Get(Token, Brushes.SteelBlue), null, new Rect(0, 0, first, height), r, r); + } + double second = width * Math.Clamp(Ratio2, 0, 1); + if (second > 0) + { + second = Math.Max(second, 2); + double x = first > 0 ? first + Gap : 0; + context.DrawRectangle(ThemeBrushes.Get(Token2, Brushes.OrangeRed), null, + new Rect(x, 0, Math.Min(second, Math.Max(0, width - x)), height), r, r); + } + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Monitor/MonitorChartColors.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Monitor/MonitorChartColors.cs new file mode 100644 index 0000000..c48f064 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Monitor/MonitorChartColors.cs @@ -0,0 +1,37 @@ +using Avalonia.Controls; +using Avalonia.Media; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 让 MongoChart1..4 这四支图表色在 里取到正确的值。 +/// +/// 只查**应用级**资源,而这四支色写在插件自己的 MongoTheme.axaml 里 +/// (经视图的 StyleInclude 装进来,宿主的 Application 里没有它们)—— 于是 、 +/// 图例色块与 拿到的都是各自的兜底色(钢蓝、灰、橙红)。 +/// 这里从视图自己的资源链上查出真值,写进 那支长期有效的画刷: +/// 那支画刷对象本来就是"就地改色"的设计,所有已经用上它的地方跟着变,不用重绑。 +/// +/// +/// 这是外壳层的缺口在本分区里的绕行(根治应在 的解析里补上插件资源), +/// 已写进分区报告。 +/// +/// +internal static class MonitorChartColors +{ + private static readonly string[] Tokens = ["MongoChart1", "MongoChart2", "MongoChart3", "MongoChart4"]; + + /// 按 当前的明暗,把四支图表色同步进共享画刷。 + /// 已挂进可视树、资源链上有 MongoTheme 的控件。 + public static void Sync(Control host) + { + foreach (string token in Tokens) + { + if (host.TryFindResource(token, host.ActualThemeVariant, out object? value) && value is ISolidColorBrush resolved + && ThemeBrushes.Get(token, resolved) is SolidColorBrush shared && shared.Color != resolved.Color) + { + shared.Color = resolved.Color; + } + } + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Monitor/MonitorModels.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Monitor/MonitorModels.cs new file mode 100644 index 0000000..96f8ebf --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Monitor/MonitorModels.cs @@ -0,0 +1,226 @@ +using System.Globalization; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 顶部六张指标卡之一(设计稿 11:连接 / 操作每秒 / 网络 / 缓存 / 复制延迟 / Oplog 窗口)。 +internal sealed class MonitorKpi : ObservableObject +{ + private string _value = "—"; + private string _unit = ""; + private string _foot = ""; + private bool _isAlert; + private bool _footWarn; + + /// 构造。 + /// 标题(连接)。 + /// 图标。 + public MonitorKpi(string title, string iconKey) + { + Title = title; + IconKey = iconKey; + } + + /// 标题。 + public string Title { get; } + + /// 图标。 + public string IconKey { get; } + + /// "偏高"之类的角标文字。 + public string AlertText { get; init; } = ""; + + /// 大号数字(128、1,236)。 + public string Value + { + get => _value; + set => SetProperty(ref _value, value); + } + + /// 数字后面的小字(/ 512、MB/s)。 + public string Unit + { + get => _unit; + set => SetProperty(ref _unit, value); + } + + /// 底行(当前 · 可用 384)。 + public string Foot + { + get => _foot; + set => SetProperty(ref _foot, value); + } + + /// 告警(橙色描边 + 角标;复制延迟超过阈值时)。 + public bool IsAlert + { + get => _isAlert; + set + { + if (SetProperty(ref _isAlert, value)) + { + RaisePropertyChanged(nameof(IconToken)); + } + } + } + + /// 底行要不要用警告色(Oplog 窗口偏短)。 + public bool FootWarn + { + get => _footWarn; + set => SetProperty(ref _footWarn, value); + } + + /// 图标颜色:告警时跟着变橙。 + public string IconToken => _isAlert ? "VelaWarning" : "VelaTextTertiary"; + + /// 一次把三段文字都换掉。 + public void Set(string value, string unit, string foot) + { + Value = value; + Unit = unit; + Foot = foot; + } +} + +/// 图例里的一项(■ query 982)。 +/// 名字。 +/// 颜色令牌。 +/// 当前值。 +internal sealed record MonitorLegend(string Name, string Token, string Value); + +/// 副本集成员卡的一行。 +/// 地址。 +/// 状态。 +/// 状态文字颜色。 +/// 状态点颜色。 +/// 延迟条比例。 +/// 延迟条颜色。 +/// 延迟文字(主节点为 —)。 +/// 延迟文字颜色。 +/// 底行(优先级 2 · 投票 1 · 心跳 2 s 前)。 +/// 「复制延迟」字样(随语言)。 +internal sealed record MonitorMember( + string Name, + string State, + string StateToken, + string DotToken, + double LagRatio, + string LagToken, + string LagText, + string LagTextToken, + string Meta, + string LagLabel); + +/// 存储 Top 的一行。 +/// 集合名(跨库时 db.coll)。 +/// 数据段比例(相对最大的那一行)。 +/// 索引段比例。 +/// 合计(44.0 GB)。 +/// 悬停提示(数据与索引各多少)。 +internal sealed record MonitorStorageRow(string Name, double DataRatio, double IndexRatio, string SizeText, string Tip); + +/// 事件栏的一条(由采样推断:复制延迟超阈值、操作量尖峰、慢查询、索引构建完成…)。 +internal sealed class MonitorEvent : ObservableObject +{ + private string _detail; + + /// 构造。 + public MonitorEvent(DateTime time, string kind, string iconKey, string iconToken, string title, string detail) + { + Time = time; + Kind = kind; + IconKey = iconKey; + IconToken = iconToken; + Title = title; + _detail = detail; + } + + /// 发生时刻(UTC)。 + public DateTime Time { get; } + + /// 右侧时间(本地 HH:mm)。 + public string TimeText => Time.ToLocalTime().ToString("HH:mm", CultureInfo.InvariantCulture); + + /// 种类(去重与"持续中"更新用)。 + public string Kind { get; } + + /// 图标。 + public string IconKey { get; } + + /// 图标颜色令牌。 + public string IconToken { get; } + + /// 标题。 + public string Title { get; } + + /// 说明(持续中的事件会更新,如复制延迟的"持续 3 分钟")。 + public string Detail + { + get => _detail; + set => SetProperty(ref _detail, value); + } +} + +/// 监控页的数字格式(与设计稿一致:千分位、等宽、单位单独一段小字)。 +internal static class MonitorFormat +{ + /// 每秒次数:≥ 10 取整加千分位,小于 10 留一位小数(空闲库上 0.5 次 / 秒也看得见)。 + public static string Rate(double value) => value >= 10 || value == 0 + ? Math.Round(value).ToString("N0", CultureInfo.InvariantCulture) + : value.ToString("0.0", CultureInfo.InvariantCulture); + + /// 选一个让数值落在 1–1024 之间的字节单位(按 选,两个数共用一个单位)。 + public static (double Divisor, string Unit) ByteUnit(double reference) + { + string[] units = ["B", "KB", "MB", "GB", "TB"]; + double divisor = 1; + int unit = 0; + while (reference / divisor >= 1024 && unit < units.Length - 1) + { + divisor *= 1024; + unit++; + } + return (divisor, units[unit]); + } + + /// 一位小数(18.4);整数不带小数点。 + public static string One(double value) => + Math.Abs(value - Math.Round(value)) < 0.05 && value >= 10 + ? Math.Round(value).ToString("0", CultureInfo.InvariantCulture) + : value.ToString("0.0", CultureInfo.InvariantCulture); + + /// 秒数:0.8 s、41.2 s、3 min。 + public static string Seconds(double seconds) => seconds < 100 + ? seconds.ToString("0.0", CultureInfo.InvariantCulture) + " s" + : Math.Round(seconds / 60).ToString("0", CultureInfo.InvariantCulture) + " min"; + + /// + /// 纵轴上限:取能被 3 整除成"好读刻度"的值( 画 1/3、2/3、满格三条网格线)。 + /// 按图表自己的取整,上限 200 时刻度是 66.7 / 133.3;这里先把每格取整到 1 / 1.5 / 2 / 2.5 / 3 / 4 / 5 / 6 / 8 × 10ⁿ, + /// 上限就是 1.5k → 500 / 1k / 1.5k(设计稿 11 的纵轴)。 + /// + public static double NiceThirds(double max) + { + if (max <= 0) + { + return 3; + } + double step = max / 3; + double magnitude = Math.Pow(10, Math.Floor(Math.Log10(step))); + foreach (double m in new[] { 1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10 }) + { + if (m * magnitude >= step - 1e-9) + { + return m * magnitude * 3; + } + } + return 30 * magnitude; + } + + /// + /// 去掉默认端口(10.20.3.21:27017 → 10.20.3.21):成员卡与指标卡上一排 :27017 只是噪声, + /// 非默认端口照留 —— 那时端口就是区分成员的信息。 + /// + public static string Host(string address) => + address.EndsWith(":27017", StringComparison.Ordinal) ? address[..^6] : address; +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Monitor/MonitorOps.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Monitor/MonitorOps.cs new file mode 100644 index 0000000..6dfb8cb --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Monitor/MonitorOps.cs @@ -0,0 +1,63 @@ +using MongoDB.Bson; +using MongoDB.Driver; +using VelaShell.Plugin.Mongo.Core; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// $currentOp 的一层薄封装(监控页认索引构建、慢查询页列当前操作共用)。 +/// +/// 一律打上 标记,再在 $match 里把带这个标记的操作排除掉 —— +/// 否则"当前操作"列表里永远挂着我们自己这条 $currentOp,监控在观察自己。 +/// +/// +internal static class MonitorOps +{ + /// 本插件发起的监控类操作统一带的 comment。 + public const string Comment = "velashell:monitor"; + + /// + /// 列出当前操作。先按 allUsers: true 要全部;没有 inprog 权限(错误码 13)时 + /// 退回只看自己的 —— 少看一部分好过整块报错。 + /// + /// 连接。 + /// 附加的 $match 条件;null = 不加。 + /// 取消令牌。 + /// 操作文档,以及是不是只看到了自己的操作。 + public static async Task<(IReadOnlyList Ops, bool OwnOnly)> ListAsync( + MongoConnection connection, BsonDocument? match, CancellationToken cancellationToken) + { + try + { + return (await RunAsync(connection, match, allUsers: true, cancellationToken).ConfigureAwait(true), false); + } + catch (MongoCommandException ex) when (ex.Code == 13) + { + return (await RunAsync(connection, match, allUsers: false, cancellationToken).ConfigureAwait(true), true); + } + } + + private static async Task> RunAsync( + MongoConnection connection, BsonDocument? match, bool allUsers, CancellationToken cancellationToken) + { + var filter = new BsonDocument("command.comment", new BsonDocument("$ne", Comment)); + if (match is { ElementCount: > 0 }) + { + filter = new BsonDocument("$and", new BsonArray { filter, match }); + } + BsonDocument[] stages = + [ + new("$currentOp", new BsonDocument { { "allUsers", allUsers }, { "idleConnections", false } }), + new("$match", filter) + ]; + PipelineDefinition pipeline = PipelineDefinition.Create(stages); + using IAsyncCursor cursor = await connection.Database("admin") + .AggregateAsync(pipeline, new AggregateOptions { Comment = Comment }, cancellationToken) + .ConfigureAwait(true); + return await cursor.ToListAsync(cancellationToken).ConfigureAwait(true); + } + + /// opid 的字符串形式(mongos 上是 shard0:12345,单机是整数)。 + public static string OpId(BsonDocument op) => + op.TryGetValue("opid", out BsonValue v) ? (v.IsString ? v.AsString : v.ToString() ?? "") : ""; +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Monitor/MonitorPoller.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Monitor/MonitorPoller.cs new file mode 100644 index 0000000..befe2f0 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Monitor/MonitorPoller.cs @@ -0,0 +1,90 @@ +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// "每隔 N 秒做一次"的循环(服务器监控的采样、慢查询页的 $currentOp 刷新)。 +/// +/// 不用 DispatcherTimer:定时器到点就触发,上一轮还没回来(网络慢、服务器忙)时会叠出第二轮请求; +/// 这里是"做完一轮 → 等一个间隔 → 再做",天然不会叠加,慢连接上也不会越积越多。 +/// 循环在 UI 线程的同步上下文里续跑,改集合不必再手动封送;真正耗时的是驱动的网络往返,不占 UI 线程。 +/// +/// +/// 每一轮要做的事。 +/// 间隔。 +internal sealed class MonitorPoller(Func tick, TimeSpan interval) +{ + private CancellationTokenSource? _cts; + + /// 间隔。改了之后下一轮生效;要立刻生效就 。 + public TimeSpan Interval { get; set; } = interval; + + /// 正在跑。 + public bool IsRunning => _cts is not null; + + /// 开始(已在跑则什么都不做)。 + /// 先等一个间隔再做第一轮(刚手动采过一次时,紧接着再采一次只会得到一段几毫秒的噪声率值)。 + public void Start(bool delayFirst = false) + { + if (_cts is not null) + { + return; + } + _cts = new CancellationTokenSource(); + _ = RunAsync(delayFirst, _cts.Token); + } + + /// + /// 停下。不 Dispose 取消源:正在路上的那一轮还拿着它的令牌, + /// 释放之后再往令牌上注册回调会抛 —— 没有计时器的取消源不释放也不漏资源。 + /// + public void Stop() + { + _cts?.Cancel(); + _cts = null; + } + + /// 停下再开(改了间隔、要立刻刷新一轮时)。 + /// 先等一个间隔再做第一轮。 + public void Restart(bool delayFirst = false) + { + Stop(); + Start(delayFirst); + } + + private async Task RunAsync(bool delayFirst, CancellationToken token) + { + if (delayFirst) + { + try + { + await Task.Delay(Interval, token).ConfigureAwait(true); + } + catch (OperationCanceledException) + { + return; + } + } + while (!token.IsCancellationRequested) + { + try + { + await tick(token).ConfigureAwait(true); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + return; + } + catch (Exception) + { + // 每一轮自己负责呈现失败(提示 / 空态);这里只保证一次失败不让循环就此停摆。 + } + try + { + await Task.Delay(Interval, token).ConfigureAwait(true); + } + catch (OperationCanceledException) + { + return; + } + } + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Monitor/MonitorTabView.axaml b/plugins/VelaShell.Plugin.Mongo/Ui/Monitor/MonitorTabView.axaml new file mode 100644 index 0000000..54702c7 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Monitor/MonitorTabView.axaml @@ -0,0 +1,359 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + M10 2 h4 M12 14 l3 -3 M4 14 a8 8 0 1 0 16 0 a8 8 0 1 0 -16 0 z + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Monitor/MonitorTabView.axaml.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Monitor/MonitorTabView.axaml.cs new file mode 100644 index 0000000..e72b9d2 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Monitor/MonitorTabView.axaml.cs @@ -0,0 +1,57 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Media; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 服务器监控视图(设计稿 11)。 +public sealed partial class MonitorTabView : UserControl +{ + /// 用给定的视图模型初始化。 + internal MonitorTabView(MonitorTabViewModel viewModel) + : this() + { + DataContext = viewModel; + } + + /// 设计器 / headless 用的无参构造。 + public MonitorTabView() + { + InitializeComponent(); + ActualThemeVariantChanged += (_, _) => MonitorChartColors.Sync(this); + } + + /// + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + MonitorChartColors.Sync(this); + } + + /// + /// 采样间隔的下拉在代码里现建菜单:弹出层是独立的可视树,写在 AXAML 里的菜单项 + /// 要绕回本视图的数据上下文才能改间隔;这里直接拿着视图模型改。 + /// + private void OnIntervalClick(object? sender, RoutedEventArgs e) + { + if (DataContext is not MonitorTabViewModel vm || sender is not Control anchor) + { + return; + } + var items = new List(); + foreach (int seconds in MonitorTabViewModel.IntervalChoices) + { + items.Add(new MenuItem + { + Header = vm.Loc.Format("Mon_Every", seconds), + Icon = seconds == vm.IntervalSeconds + ? new Glyph { Key = "Mongo.check", Size = 12, Brush = ThemeBrushes.Get("VelaAccent", Brushes.SteelBlue) } + : null, + Command = new RelayCommand(() => vm.IntervalSeconds = seconds) + }); + } + new ContextMenu { ItemsSource = items }.Open(anchor); + e.Handled = true; + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Monitor/MonitorTabViewModel.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Monitor/MonitorTabViewModel.cs new file mode 100644 index 0000000..4cc34da --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Monitor/MonitorTabViewModel.cs @@ -0,0 +1,1261 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Globalization; +using MongoDB.Bson; +using MongoDB.Bson.IO; +using MongoDB.Driver; +using VelaShell.Plugin.Mongo.Analysis; +using VelaShell.Plugin.Mongo.Bson; +using VelaShell.Plugin.Mongo.Core; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 服务器监控(设计稿 11):六张指标卡、操作 / 秒堆叠柱、副本集成员、连接数、存储 Top 与由采样推断的事件。 +/// +/// 数据全部来自周期性的 serverStatus + replSetGetStatus(率值 = 相邻两次计数器之差 / 时间差); +/// 历史只在内存里,按时间窗裁剪与分桶。**只在标签可见时采样**:切到别的标签就停, +/// 一个没人看的监控页每 2 秒打一次服务器,是在给被监控的对象添负载。 +/// +/// +internal sealed class MonitorTabViewModel : WorkspaceTab +{ + /// 柱状图的柱数(15 分钟窗口 → 每柱 30 秒,与设计稿一致)。 + private const int Bars = 30; + + /// 复制延迟告警阈值(秒)。 + internal const double LagThreshold = 1; + + /// 事件只留近 1 小时(设计稿事件栏的口径)。 + private static readonly TimeSpan EventRetention = TimeSpan.FromHours(1); + + private readonly SampleHistory _history = new(); + private readonly MonitorPoller _poller; + private readonly Dictionary _indexBuilds = new(StringComparer.Ordinal); + private readonly SemaphoreSlim _tickGate = new(1, 1); + private ServerSample? _last; + private BsonDocument? _lastStatus; + private BsonDocument? _lastReplStatus; + private BsonDocument? _replConfig; + private ReplicaSnapshot? _replica; + private DateTime _replConfigAt; + private DateTime _oplogAt; + private DateTime _storageAt; + private DateTime _profileLevelsAt; + private DateTime _slowCheckedAt; + private List<(string Database, int SlowMs)> _profiled = []; + private bool _loaded; + private bool _paused; + private bool _disposed; + private bool _failureShown; + private bool _currentOpDenied; + private string? _fcv; + private MonitorEvent? _lagEvent; + private DateTime _lastOpsSpike = DateTime.MinValue; + private DateTime _lastConnSpike = DateTime.MinValue; + private string _window = "15m"; + private int _intervalSeconds = 2; + private TimeSpan? _intervalOverride; + private string _serverLine = ""; + private string _errorText = ""; + private IReadOnlyList _opsSeries = []; + private IReadOnlyList _opsLabels = []; + private string _opsSubtitle = ""; + private IReadOnlyList _legend = []; + private IReadOnlyList _connSeries = []; + private double _opsMax = 3; + private double _connMax = 3; + private IReadOnlyList _connLabels = []; + private string _connSubtitle = ""; + private string _replicaTitle = ""; + private string _replicaSubtitle = ""; + private bool _majorityLost; + private IReadOnlyList _members = []; + private string _replicaEmptyTitle = ""; + private string _replicaEmptyHint = ""; + private IReadOnlyList _storage = []; + private string _storageSubtitle = ""; + private bool _storageLoaded; + private bool _showAllEvents; + + /// 构造。不在这里发任何请求:同键标签已开着时,外壳会把新建的这个直接丢掉。 + /// 外壳服务。 + public MonitorTabViewModel(IMongoWorkspace workspace) + : base(workspace) + { + Title = workspace.Loc["Toolbar_Monitor"]; + _poller = new MonitorPoller(TickAsync, Interval); + Connections = new(Loc["Mon_KpiConnections"], "Mongo.plug"); + Ops = new(Loc["Mon_KpiOps"], "Mongo.zap"); + Network = new(Loc["Mon_KpiNetwork"], "Mongo.arrow-down-up"); + Cache = new(Loc["Mon_KpiCache"], "Mongo.memory-stick"); + Lag = new(Loc["Mon_KpiLag"], "Mongo.timer") { AlertText = Loc["Mon_High"] }; + Oplog = new(Loc["Mon_KpiOplog"], "Mongo.history"); + PauseCommand = new RelayCommand(TogglePause); + ExportCommand = new AsyncCommand(ExportAsync); + ToggleEventsCommand = new RelayCommand(() => ShowAllEvents = !ShowAllEvents); + Events.CollectionChanged += (_, _) => RaisePropertiesChanged(nameof(VisibleEvents), nameof(HasEvents)); + PropertyChanged += OnOwnPropertyChanged; + } + + /// + public override TabKind Kind => TabKind.Monitor; + + /// + public override string Key => "monitor"; + + /// + public override string IconKey => "Mongo.activity"; + + /// + public override string IconToken => "VelaStatusConnected"; + + // ── 工具行 ──────────────────────────────────────────────────────────── + + /// 暂停中(「● 实时」变灰,按钮变「继续」)。 + public bool IsPaused + { + get => _paused; + private set + { + if (SetProperty(ref _paused, value)) + { + RaisePropertiesChanged(nameof(PauseLabel), nameof(PauseIcon)); + UpdatePolling(); + UpdateStatus(); + } + } + } + + /// 暂停按钮文字。 + public string PauseLabel => _paused ? Loc["Mon_Resume"] : Loc["Mon_Pause"]; + + /// 暂停按钮图标。 + public string PauseIcon => _paused ? "Mongo.play" : "Mongo.pause"; + + /// 时间窗(5m / 15m / 1h / 6h;分段按钮直接绑字符串)。 + public string Window + { + get => _window; + set + { + if (value is "5m" or "15m" or "1h" or "6h" && SetProperty(ref _window, value)) + { + UpdateCharts(); + } + } + } + + /// 采样间隔(秒)。 + public int IntervalSeconds + { + get => _intervalSeconds; + set + { + value = Math.Clamp(value, 1, 60); + if (SetProperty(ref _intervalSeconds, value)) + { + RaisePropertyChanged(nameof(IntervalText)); + _poller.Interval = Interval; + if (_poller.IsRunning) + { + _poller.Restart(delayFirst: true); + } + UpdateStatus(); + } + } + } + + /// 可选的采样间隔。 + public static IReadOnlyList IntervalChoices { get; } = [1, 2, 5, 10]; + + /// 下拉上的字(每 2 s)。 + public string IntervalText => Loc.Format("Mon_Every", _intervalSeconds); + + /// + /// 测试用:把采样间隔压到 1 秒以下(截图要先攒 15 个以上的采样点,按 2 秒一次就得干等半分钟)。 + /// + internal TimeSpan? IntervalOverride + { + get => _intervalOverride; + set + { + _intervalOverride = value; + _poller.Interval = Interval; + } + } + + private TimeSpan Interval => _intervalOverride ?? TimeSpan.FromSeconds(_intervalSeconds); + + /// 右侧灰字(uptime 14 天 6 小时 · WiredTiger · featureCompatibilityVersion 7.0)。 + public string ServerLine + { + get => _serverLine; + private set => SetProperty(ref _serverLine, value); + } + + /// 采样失败的说明(权限不够、连接断了);空 = 正常。 + public string ErrorText + { + get => _errorText; + private set + { + if (SetProperty(ref _errorText, value)) + { + RaisePropertyChanged(nameof(HasError)); + } + } + } + + /// 有没有失败说明。 + public bool HasError => _errorText.Length > 0; + + /// 暂停 / 继续。 + public RelayCommand PauseCommand { get; } + + /// 导出快照(serverStatus + replSetGetStatus + 当前序列,存成 JSON)。 + public AsyncCommand ExportCommand { get; } + + // ── 指标卡 ──────────────────────────────────────────────────────────── + + /// 连接。 + public MonitorKpi Connections { get; } + + /// 操作 / 秒。 + public MonitorKpi Ops { get; } + + /// 网络。 + public MonitorKpi Network { get; } + + /// 缓存使用。 + public MonitorKpi Cache { get; } + + /// 复制延迟。 + public MonitorKpi Lag { get; } + + /// Oplog 窗口。 + public MonitorKpi Oplog { get; } + + // ── 图表 ────────────────────────────────────────────────────────────── + + /// 操作 / 秒 的四组堆叠数据。 + public IReadOnlyList OpsSeries + { + get => _opsSeries; + private set => SetProperty(ref _opsSeries, value); + } + + /// 操作 / 秒 的横轴标签。 + public IReadOnlyList OpsLabels + { + get => _opsLabels; + private set => SetProperty(ref _opsLabels, value); + } + + /// 操作 / 秒 的副标题(opcounters · 近 15 分钟 · 每柱 30 s)。 + public string OpsSubtitle + { + get => _opsSubtitle; + private set => SetProperty(ref _opsSubtitle, value); + } + + /// 右上图例(带当前值)。 + public IReadOnlyList Legend + { + get => _legend; + private set => SetProperty(ref _legend, value); + } + + /// 操作 / 秒 的纵轴上限(三等分后刻度好读)。 + public double OpsMax + { + get => _opsMax; + private set => SetProperty(ref _opsMax, value); + } + + /// 连接数的纵轴上限。 + public double ConnMax + { + get => _connMax; + private set => SetProperty(ref _connMax, value); + } + + /// 连接数柱状图。 + public IReadOnlyList ConnSeries + { + get => _connSeries; + private set => SetProperty(ref _connSeries, value); + } + + /// 连接数横轴标签。 + public IReadOnlyList ConnLabels + { + get => _connLabels; + private set => SetProperty(ref _connLabels, value); + } + + /// 连接数副标题(当前 128 · 峰值 164)。 + public string ConnSubtitle + { + get => _connSubtitle; + private set => SetProperty(ref _connSubtitle, value); + } + + // ── 副本集 ──────────────────────────────────────────────────────────── + + /// 成员卡标题(副本集 rs0)。 + public string ReplicaTitle + { + get => _replicaTitle; + private set => SetProperty(ref _replicaTitle, value); + } + + /// 成员卡副标题(3 成员 · 多数派健康)。 + public string ReplicaSubtitle + { + get => _replicaSubtitle; + private set => SetProperty(ref _replicaSubtitle, value); + } + + /// 多数派不可用(副标题转红)。 + public bool MajorityLost + { + get => _majorityLost; + private set => SetProperty(ref _majorityLost, value); + } + + /// 成员。 + public IReadOnlyList Members + { + get => _members; + private set + { + if (SetProperty(ref _members, value)) + { + RaisePropertyChanged(nameof(HasMembers)); + } + } + } + + /// 有没有成员可列(单机 / mongos 时显示空态)。 + public bool HasMembers => _members.Count > 0; + + /// 空态标题(单机部署)。 + public string ReplicaEmptyTitle + { + get => _replicaEmptyTitle; + private set => SetProperty(ref _replicaEmptyTitle, value); + } + + /// 空态说明。 + public string ReplicaEmptyHint + { + get => _replicaEmptyHint; + private set => SetProperty(ref _replicaEmptyHint, value); + } + + // ── 存储 Top ────────────────────────────────────────────────────────── + + /// 存储 Top 的行(前 6)。 + public IReadOnlyList Storage + { + get => _storage; + private set + { + if (SetProperty(ref _storage, value)) + { + RaisePropertyChanged(nameof(HasStorage)); + RaisePropertyChanged(nameof(StorageEmpty)); + } + } + } + + /// 有没有行。 + public bool HasStorage => _storage.Count > 0; + + /// 加载过但一行都没有(空库)。 + public bool StorageEmpty => _storageLoaded && _storage.Count == 0; + + /// 存储 Top 副标题(shop · 数据 + 索引)。 + public string StorageSubtitle + { + get => _storageSubtitle; + private set => SetProperty(ref _storageSubtitle, value); + } + + // ── 事件 ────────────────────────────────────────────────────────────── + + /// 近 1 小时的事件(新的在前)。 + public ObservableCollection Events { get; } = []; + + /// 展开「全部」。 + public bool ShowAllEvents + { + get => _showAllEvents; + set + { + if (SetProperty(ref _showAllEvents, value)) + { + RaisePropertiesChanged(nameof(VisibleEvents), nameof(EventsToggleText)); + } + } + } + + /// 列出来的事件(收起时前 5 条)。 + public IReadOnlyList VisibleEvents => _showAllEvents ? Events.ToList() : Events.Take(5).ToList(); + + /// 有没有事件。 + public bool HasEvents => Events.Count > 0; + + /// 「全部」/「收起」。 + public string EventsToggleText => _showAllEvents ? Loc["Mon_EventsLess"] : Loc["Mon_EventsAll"]; + + /// 展开 / 收起事件。 + public RelayCommand ToggleEventsCommand { get; } + + /// 已攒下的率值个数(测试等采样点用)。 + internal int SampleCount => _history.Rates.Count; + + // ── 生命周期 ────────────────────────────────────────────────────────── + + /// + public override async Task LoadAsync() + { + UpdateStatus(); + await TickAsync(CancellationToken.None).ConfigureAwait(true); + _loaded = true; + UpdatePolling(delayFirst: true); + } + + /// + protected override void OnActivated() => UpdatePolling(); + + /// + public override async Task RefreshAsync() + { + // F5:低频项(存储 Top、Oplog、成员配置)也一并重拉。 + _storageAt = _oplogAt = _replConfigAt = _profileLevelsAt = DateTime.MinValue; + await TickAsync(CancellationToken.None).ConfigureAwait(true); + } + + /// + public override void Dispose() + { + _disposed = true; + _poller.Stop(); + PropertyChanged -= OnOwnPropertyChanged; + base.Dispose(); + } + + private void OnOwnPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(IsActive)) + { + UpdatePolling(); + } + } + + /// 只在"加载过、可见、没暂停、没关掉"时采样。 + private void UpdatePolling(bool delayFirst = false) + { + if (_loaded && IsActive && !_paused && !_disposed) + { + _poller.Start(delayFirst); + } + else + { + _poller.Stop(); + } + } + + private void TogglePause() => IsPaused = !IsPaused; + + // ── 一轮采样 ────────────────────────────────────────────────────────── + + /// 采一次(测试也直接调它)。 + internal async Task TickAsync(CancellationToken token) + { + if (_disposed) + { + return; + } + // 手动刷新与定时采样可能撞在一起:同一时刻只跑一轮,另一轮直接让路。 + if (!await _tickGate.WaitAsync(0, CancellationToken.None).ConfigureAwait(true)) + { + return; + } + try + { + await SampleAsync(token).ConfigureAwait(true); + } + finally + { + _tickGate.Release(); + } + } + + private async Task SampleAsync(CancellationToken token) + { + MongoConnection connection = Workspace.Connection; + DateTime now = DateTime.UtcNow; + BsonDocument status; + try + { + status = await connection.RunCommandAsync("admin", new BsonDocument("serverStatus", 1), token).ConfigureAwait(true); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Fail(ex); + return; + } + if (token.IsCancellationRequested || _disposed) + { + return; + } + ErrorText = ""; + _failureShown = false; + _lastStatus = status; + ServerSample sample = ServerSample.Parse(status, now); + ServerSample? previous = _last; + RateSample? rate = previous is null ? null : RateSample.Between(previous, sample); + if (previous is not null && sample.Uptime < previous.Uptime) + { + // 服务器重启过:旧计数器作废,历史从头攒。 + _history.Clear(); + } + if (rate is not null) + { + _history.Add(rate); + } + _last = sample; + + await SampleReplicaAsync(now, token).ConfigureAwait(true); + await TrackIndexBuildsAsync(previous, sample, now, token).ConfigureAwait(true); + if (now - _oplogAt > TimeSpan.FromSeconds(60)) + { + _oplogAt = now; + await LoadOplogAsync(token).ConfigureAwait(true); + } + if (now - _storageAt > TimeSpan.FromSeconds(120)) + { + _storageAt = now; + await LoadStorageAsync(token).ConfigureAwait(true); + } + if (now - _profileLevelsAt > TimeSpan.FromMinutes(5)) + { + _profileLevelsAt = now; + await LoadProfileLevelsAsync(token).ConfigureAwait(true); + } + if (now - _slowCheckedAt > TimeSpan.FromSeconds(60)) + { + await CheckSlowQueriesAsync(now, token).ConfigureAwait(true); + } + if (token.IsCancellationRequested || _disposed) + { + return; + } + + if (rate is not null) + { + DetectSpikes(rate); + } + await EnsureFcvAsync(sample, token).ConfigureAwait(true); + UpdateKpis(sample, _history.Latest); + UpdateCharts(); + PruneEvents(now); + UpdateStatus(); + } + + private void Fail(Exception ex) + { + string reason = MongoConnector.Describe(ex); + ErrorText = ex is MongoCommandException { Code: 13 } + ? Loc.Format("Mon_FailedPrivilege", reason) + : Loc.Format("Mon_Failed", reason); + if (!_failureShown) + { + // 每 2 秒失败一次就弹一次提示,那是骚扰;只在由好转坏的那一下提示,之后看页面上的横条。 + _failureShown = true; + Workspace.Toast(new() { Title = Loc.Format("Common_Failed", reason), Kind = ToastKind.Error }); + } + } + + private async Task EnsureFcvAsync(ServerSample sample, CancellationToken token) + { + if (sample.Fcv is { } fcv) + { + _fcv = fcv; + } + else if (_fcv is null) + { + // 老版本的 serverStatus 不带 FCV:问一次 getParameter,之后缓存。 + _fcv = ""; + try + { + BsonDocument p = await Workspace.Connection.RunCommandAsync("admin", new BsonDocument + { + { "getParameter", 1 }, + { "featureCompatibilityVersion", 1 } + }, token).ConfigureAwait(true); + if (p.TryGetValue("featureCompatibilityVersion", out BsonValue v) && v is BsonDocument d && d.TryGetValue("version", out BsonValue ver)) + { + _fcv = ver.ToString() ?? ""; + } + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + } + } + string engine = sample.Engine.Length > 0 ? sample.Engine : Workspace.Connection.Server.StorageEngine; + string line = Loc.Format("Mon_Uptime", Duration(TimeSpan.FromSeconds(sample.Uptime))) + " · " + engine; + if (_fcv is { Length: > 0 }) + { + line += " · featureCompatibilityVersion " + _fcv; + } + ServerLine = line; + } + + // ── 副本集 ──────────────────────────────────────────────────────────── + + private bool IsReplicaSet => Workspace.Connection.Server is { SetName.Length: > 0, Role: not "MONGOS" }; + + private async Task SampleReplicaAsync(DateTime now, CancellationToken token) + { + ServerInfo server = Workspace.Connection.Server; + if (!IsReplicaSet) + { + _replica = null; + Members = []; + ReplicaTitle = Loc["Mon_ReplicaPlain"]; + ReplicaSubtitle = server.Role; + bool mongos = server.Role == "MONGOS"; + ReplicaEmptyTitle = mongos ? Loc["Mon_MongosTitle"] : Loc["Mon_StandaloneTitle"]; + ReplicaEmptyHint = mongos ? Loc["Mon_MongosHint"] : Loc["Mon_StandaloneHint"]; + return; + } + try + { + BsonDocument status = await Workspace.Connection.RunCommandAsync("admin", new BsonDocument("replSetGetStatus", 1), token) + .ConfigureAwait(true); + _lastReplStatus = status; + if (now - _replConfigAt > TimeSpan.FromMinutes(5) || _replConfig is null) + { + _replConfigAt = now; + try + { + _replConfig = await Workspace.Connection.RunCommandAsync("admin", new BsonDocument("replSetGetConfig", 1), token) + .ConfigureAwait(true); + } + catch (MongoCommandException) + { + // 没有 replSetGetConfig 权限:优先级与票数按默认值显示,不影响别的。 + } + } + ReplicaSnapshot snapshot = ReplicaSnapshot.Parse(status, _replConfig); + DetectReplicaEvents(_replica, snapshot, now); + _replica = snapshot; + ApplyReplica(snapshot, now); + } + catch (MongoCommandException ex) + { + _replica = null; + Members = []; + ReplicaTitle = Loc["Mon_ReplicaPlain"]; + ReplicaSubtitle = ""; + ReplicaEmptyTitle = Loc["Mon_ReplicaDenied"]; + ReplicaEmptyHint = MongoConnector.Describe(ex); + } + } + + private void ApplyReplica(ReplicaSnapshot snapshot, DateTime now) + { + ReplicaTitle = Loc.Format("Mon_ReplicaTitle", snapshot.SetName); + ReplicaSubtitle = Loc.Format("Mon_ReplicaSubtitle", snapshot.Members.Count, + snapshot.MajorityHealthy ? Loc["Mon_MajorityOk"] : Loc["Mon_MajorityLost"]); + MajorityLost = !snapshot.MajorityHealthy; + // 延迟条的满格:至少 4 秒 —— 正常的从节点(0.x 秒)只占一小截,一眼看出谁在掉队。 + double scale = Math.Max(4, snapshot.Members.Max(static m => m.LagSeconds ?? 0)); + Members = + [ + .. snapshot.Members + .OrderBy(static m => m.State == "PRIMARY" ? 0 : 1) + .ThenBy(static m => m.Name, StringComparer.Ordinal) + .Select(m => ToRow(m, scale, now)) + ]; + } + + private MonitorMember ToRow(ReplicaMember m, double scale, DateTime now) + { + bool lagging = m.LagSeconds > LagThreshold; + string stateToken = !m.Healthy ? "VelaError" : m.State switch + { + "PRIMARY" => "VelaStatusConnected", + "SECONDARY" => "VelaTextSecondary", + "ARBITER" => "VelaTextTertiary", + _ => "VelaWarning" + }; + string dot = !m.Healthy ? "VelaError" : lagging ? "VelaStatusConnecting" : "VelaStatusConnected"; + string heartbeat = m.IsSelf + ? Loc["Mon_MemberSelf"] + : m.LastHeartbeat is { Year: > 2000 } beat + ? Loc.Format("Mon_Heartbeat", Math.Max(0, (int)Math.Round((now - beat).TotalSeconds))) + : "—"; + string meta = Loc.Format("Mon_MemberMeta", m.Priority.ToString("0.#", CultureInfo.InvariantCulture), m.Votes, heartbeat); + return new( + MonitorFormat.Host(m.Name), + m.State, + stateToken, + dot, + m.LagSeconds is { } lag && m.State != "PRIMARY" ? lag / scale : 0, + lagging ? "VelaWarning" : "VelaTextTertiary", + m.LagSeconds is { } l && m.State != "PRIMARY" ? MonitorFormat.Seconds(l) : "—", + lagging ? "VelaWarning" : "VelaTextSecondary", + meta, + Loc["Mon_ReplLag"]); + } + + // ── Oplog / 存储 Top / 慢查询 ───────────────────────────────────────── + + private async Task LoadOplogAsync(CancellationToken token) + { + if (!IsReplicaSet) + { + Oplog.Set("—", "", Workspace.Connection.Server.Role == "MONGOS" ? Loc["Mon_LagMongos"] : Loc["Mon_OplogNone"]); + Oplog.FootWarn = false; + return; + } + try + { + IMongoCollection oplog = Workspace.Connection.Collection("local", "oplog.rs"); + var projection = new BsonDocument("ts", 1); + BsonDocument? first = await oplog.Find(FilterDefinition.Empty, new FindOptions { Comment = MonitorOps.Comment }) + .Sort(new BsonDocument("$natural", 1)).Project(projection).Limit(1) + .FirstOrDefaultAsync(token).ConfigureAwait(true); + BsonDocument? last = await oplog.Find(FilterDefinition.Empty, new FindOptions { Comment = MonitorOps.Comment }) + .Sort(new BsonDocument("$natural", -1)).Project(projection).Limit(1) + .FirstOrDefaultAsync(token).ConfigureAwait(true); + if (first?.GetValue("ts", BsonNull.Value) is not BsonTimestamp from || last?.GetValue("ts", BsonNull.Value) is not BsonTimestamp to) + { + Oplog.Set("—", "", Loc["Mon_OplogNone"]); + return; + } + CollectionStats stats = await Workspace.Connection.GetStatsAsync("local", "oplog.rs", token).ConfigureAwait(true); + long size = stats.Raw.TryGetValue("maxSize", out BsonValue max) && max.IsNumeric ? max.ToInt64() : stats.Size; + TimeSpan window = TimeSpan.FromSeconds(Math.Max(0, to.Timestamp - from.Timestamp)); + (string value, string unit) = OplogSpan(window); + // 24 小时是常见的底线:够一次周末维护或一次大批量导入期间从节点掉线后追平。 + bool shortWindow = window < TimeSpan.FromHours(24); + Oplog.Set(value, unit, Loc.Format(shortWindow ? "Mon_OplogShort" : "Mon_OplogEnough", BsonText.Bytes(size))); + Oplog.FootWarn = shortWindow; + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Oplog.Set("—", "", Loc["Mon_OplogDenied"]); + Oplog.FootWarn = false; + } + } + + private (string Value, string Unit) OplogSpan(TimeSpan window) => window.TotalHours switch + { + < 1 => (Math.Max(0, Math.Round(window.TotalMinutes)).ToString("0", CultureInfo.InvariantCulture), Loc["Mon_UnitMinutes"]), + < 10 => (window.TotalHours.ToString("0.0", CultureInfo.InvariantCulture), Loc["Mon_UnitHours"]), + < 72 => (Math.Round(window.TotalHours).ToString("0", CultureInfo.InvariantCulture), Loc["Mon_UnitHours"]), + _ => (window.TotalDays.ToString("0.0", CultureInfo.InvariantCulture), Loc["Mon_UnitDays"]) + }; + + /// + /// 存储 Top:用户库里按磁盘占用(数据 + 索引)排前 6 的集合。 + /// 只看占用最大的 4 个库、每库至多 25 个集合 —— 每个集合一次 $collStats, + /// 一个有上千集合的库全扫一遍,本身就是一次不小的负载。 + /// + private async Task LoadStorageAsync(CancellationToken token) + { + try + { + IReadOnlyList databases = await Workspace.Connection.ListDatabasesAsync(token).ConfigureAwait(true); + var items = new List<(string Database, string Collection, long Data, long Index)>(); + foreach (DatabaseInfo db in databases.Where(static d => !d.IsSystem && !d.Empty) + .OrderByDescending(static d => d.SizeOnDisk).Take(4)) + { + IReadOnlyList collections = await Workspace.Connection.ListCollectionsAsync(db.Name, token).ConfigureAwait(true); + foreach (CollectionInfo c in collections.Where(static x => x.Kind is not (CollectionKind.View or CollectionKind.System)).Take(25)) + { + CollectionStats stats = await Workspace.Connection.GetStatsAsync(db.Name, c.Name, token).ConfigureAwait(true); + items.Add((db.Name, c.Name, stats.StorageSize, stats.TotalIndexSize)); + } + } + if (token.IsCancellationRequested) + { + return; + } + var top = items.OrderByDescending(static i => i.Data + i.Index).Take(6).ToList(); + bool oneDb = top.Select(static i => i.Database).Distinct(StringComparer.Ordinal).Count() <= 1; + double max = Math.Max(1, top.Count == 0 ? 1 : top.Max(static i => i.Data + i.Index)); + Storage = + [ + .. top.Select(i => new MonitorStorageRow( + oneDb ? i.Collection : $"{i.Database}.{i.Collection}", + i.Data / max, + i.Index / max, + BsonText.Bytes(i.Data + i.Index), + Loc.Format("Mon_StorageTip", BsonText.Bytes(i.Data), BsonText.Bytes(i.Index)))) + ]; + StorageSubtitle = Loc.Format("Mon_StorageSubtitle", oneDb && top.Count > 0 ? top[0].Database : Loc["Mon_StorageAllDbs"]); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + StorageSubtitle = MongoConnector.Describe(ex); + } + _storageLoaded = true; + RaisePropertyChanged(nameof(StorageEmpty)); + } + + /// 哪些库开着 profiler(事件栏的"慢查询 N 条"只能从这些库的 system.profile 里数)。 + private async Task LoadProfileLevelsAsync(CancellationToken token) + { + var profiled = new List<(string, int)>(); + foreach (string db in Workspace.Databases.Where(static d => d is not ("admin" or "config" or "local")).Take(20)) + { + try + { + // 带上监控标记:level 2 的库会把这条命令也记进 system.profile,慢查询页按标记把它滤掉。 + BsonDocument r = await Workspace.Connection.RunCommandAsync(db, new BsonDocument + { + { "profile", -1 }, + { "comment", MonitorOps.Comment } + }, token).ConfigureAwait(true); + if (r.GetValue("was", 0).ToInt32() > 0) + { + profiled.Add((db, r.GetValue("slowms", 100).ToInt32())); + } + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + } + } + _profiled = profiled; + } + + /// + /// 数一数上次检查以来各库 system.profile 里新增的慢操作(只算 ≥ slowms 的,level 2 会把所有操作都记下来)。 + /// 第一次看近 1 小时 —— 事件栏的口径。 + /// + private async Task CheckSlowQueriesAsync(DateTime now, CancellationToken token) + { + DateTime since = _slowCheckedAt == default ? now - EventRetention : _slowCheckedAt; + _slowCheckedAt = now; + foreach ((string db, int slowMs) in _profiled) + { + try + { + BsonDocument[] stages = + [ + new("$match", new BsonDocument + { + { "ts", new BsonDocument("$gt", since) }, + { "millis", new BsonDocument("$gte", slowMs) }, + { "op", new BsonDocument("$ne", "getmore") } + }), + new("$group", new BsonDocument + { + { "_id", new BsonDocument { { "ns", "$ns" }, { "op", "$op" }, { "plan", "$planSummary" } } }, + { "n", new BsonDocument("$sum", 1) }, + { "last", new BsonDocument("$max", "$ts") } + }), + new("$sort", new BsonDocument("n", -1)) + ]; + using IAsyncCursor cursor = await Workspace.Connection.Collection(db, "system.profile") + .AggregateAsync(stages, new AggregateOptions { Comment = MonitorOps.Comment }, token) + .ConfigureAwait(true); + List groups = await cursor.ToListAsync(token).ConfigureAwait(true); + if (groups.Count == 0) + { + continue; + } + int total = groups.Sum(static g => g["n"].ToInt32()); + BsonDocument top = groups[0]; + BsonDocument id = top["_id"].AsBsonDocument; + string ns = id.GetValue("ns", "").ToString() ?? ""; + string coll = ns.Contains('.') ? ns[(ns.IndexOf('.') + 1)..] : ns; + string verb = (id.GetValue("op", "").ToString() ?? "") switch + { + "query" => "find", + "remove" => "delete", + "command" => "command", + var other => other + }; + string plan = id.TryGetValue("plan", out BsonValue p) && p.IsString ? " " + p.AsString : ""; + DateTime at = groups.Max(static g => g["last"].ToUniversalTime()); + AddEvent(new MonitorEvent(at, "slow", "Mongo.timer", "VelaInfo", + Loc.Format("Mon_EventSlow", total), + $"{coll}.{verb}{plan} × {top["n"].ToInt32()}")); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + } + } + } + + // ── 事件推断 ────────────────────────────────────────────────────────── + + /// + /// 索引构建:跟踪 $currentOp 里的 createIndexes,它从列表里消失、且 indexBuilds.phases.commit + /// 涨了,就是构建完成;消失了计数没涨,是被终止或失败了。构建快到两次采样之间就做完的, + /// 只能从计数器的增量知道"有几个",叫不出名字。 + /// + private async Task TrackIndexBuildsAsync(ServerSample? previous, ServerSample sample, DateTime now, CancellationToken token) + { + var finished = new List(); + if (!_currentOpDenied) + { + try + { + // 没有 inprog 权限时只看得到自己的操作:别人发起的构建认不出,只剩计数器那条路。 + (IReadOnlyList ops, _) = await MonitorOps.ListAsync(Workspace.Connection, + new BsonDocument("command.createIndexes", new BsonDocument("$exists", true)), token).ConfigureAwait(true); + var seen = new HashSet(StringComparer.Ordinal); + foreach (BsonDocument op in ops) + { + string opid = MonitorOps.OpId(op); + seen.Add(opid); + if (!_indexBuilds.ContainsKey(opid) && op.GetValue("command", new BsonDocument()) is BsonDocument cmd) + { + string coll = cmd.GetValue("createIndexes", "").ToString() ?? ""; + string names = cmd.GetValue("indexes", new BsonArray()) is BsonArray specs + ? string.Join(", ", specs.OfType().Select(static s => s.GetValue("name", "").ToString())) + : ""; + _indexBuilds[opid] = names.Length > 0 ? $"{coll}.{names}" : coll; + } + } + foreach (string opid in _indexBuilds.Keys.Where(k => !seen.Contains(k)).ToList()) + { + finished.Add(_indexBuilds[opid]); + _indexBuilds.Remove(opid); + } + } + catch (MongoCommandException) + { + _currentOpDenied = true; + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + } + } + long committed = previous is { IndexBuilds: >= 0 } && sample.IndexBuilds >= previous.IndexBuilds + ? sample.IndexBuilds - previous.IndexBuilds + : finished.Count; + int named = (int)Math.Min(committed, finished.Count); + foreach (string name in finished.Take(named)) + { + AddEvent(new MonitorEvent(now, "index", "Mongo.key-round", "VelaStatusConnected", Loc["Mon_EventIndexBuilt"], name)); + } + foreach (string name in finished.Skip(named)) + { + AddEvent(new MonitorEvent(now, "index", "Mongo.circle-x", "VelaWarning", Loc["Mon_EventIndexAborted"], name)); + } + if (committed > named) + { + AddEvent(new MonitorEvent(now, "index", "Mongo.key-round", "VelaStatusConnected", Loc["Mon_EventIndexBuilt"], + Loc.Format("Mon_EventIndexQuick", committed - named))); + } + } + + private void DetectSpikes(RateSample rate) + { + if (_history.Spike(static r => r.Total, minimumDelta: 20) is { } jump && rate.Time - _lastOpsSpike > TimeSpan.FromSeconds(60)) + { + _lastOpsSpike = rate.Time; + (string name, double value) = new[] + { + ("query", rate.Query), ("insert", rate.Insert), ("update", rate.Update), ("delete", rate.Delete) + }.MaxBy(static x => x.Item2); + AddEvent(new MonitorEvent(rate.Time, "spike", "Mongo.zap", "VelaWarning", + Loc.Format("Mon_EventSpike", Math.Round(jump * 100)), + Loc.Format("Mon_EventSpikeDetail", name, MonitorFormat.Rate(value)))); + } + if (_history.Spike(static r => r.Connections, minimumDelta: 10) is { } connJump && rate.Time - _lastConnSpike > TimeSpan.FromSeconds(60)) + { + _lastConnSpike = rate.Time; + double mean = _history.Within(TimeSpan.FromMinutes(5), rate.Time).Average(static r => (double)r.Connections); + AddEvent(new MonitorEvent(rate.Time, "conn", "Mongo.plug-zap", "VelaWarning", + Loc.Format("Mon_EventConnSpike", Math.Round(connJump * 100)), + Loc.Format("Mon_EventConnSpikeDetail", BsonText.Grouped(rate.Connections), Math.Round(mean).ToString("N0", CultureInfo.InvariantCulture)))); + } + } + + private void DetectReplicaEvents(ReplicaSnapshot? before, ReplicaSnapshot after, DateTime now) + { + // 复制延迟超阈值:开始时记一条,持续期间更新"持续 N 分钟",回落即结束。 + ReplicaMember? slowest = after.Slowest; + if (slowest is { LagSeconds: > LagThreshold }) + { + if (_lagEvent is null || !Events.Contains(_lagEvent)) + { + _lagEvent = new MonitorEvent(now, "lag", "Mongo.triangle-alert", "VelaWarning", + Loc.Format("Mon_EventLag", LagThreshold.ToString("0.#", CultureInfo.InvariantCulture) + " s"), ""); + AddEvent(_lagEvent); + } + _lagEvent.Detail = Loc.Format("Mon_EventLagDetail", MonitorFormat.Host(slowest.Name), Duration(now - _lagEvent.Time)); + } + else + { + _lagEvent = null; + } + if (before is null) + { + return; + } + if (before.Primary is { } oldPrimary && after.Primary is { } newPrimary && oldPrimary != newPrimary) + { + AddEvent(new MonitorEvent(now, "primary", "Mongo.flag", "VelaInfo", Loc["Mon_EventPrimary"], + $"{MonitorFormat.Host(oldPrimary)} → {MonitorFormat.Host(newPrimary)}")); + } + foreach (ReplicaMember member in after.Members) + { + ReplicaMember? old = before.Members.FirstOrDefault(m => m.Name == member.Name); + if (old is null || old.Healthy == member.Healthy) + { + continue; + } + AddEvent(member.Healthy + ? new MonitorEvent(now, "member", "Mongo.circle-check", "VelaStatusConnected", Loc["Mon_EventRecovered"], MonitorFormat.Host(member.Name)) + : new MonitorEvent(now, "member", "Mongo.circle-x", "VelaError", Loc["Mon_EventUnhealthy"], MonitorFormat.Host(member.Name))); + } + } + + /// 按时间倒序插入(慢查询事件的时刻可能早于刚加的那条)。 + private void AddEvent(MonitorEvent e) + { + int index = 0; + while (index < Events.Count && Events[index].Time >= e.Time) + { + index++; + } + Events.Insert(index, e); + } + + private void PruneEvents(DateTime now) + { + for (int i = Events.Count - 1; i >= 0; i--) + { + if (now - Events[i].Time > EventRetention && !ReferenceEquals(Events[i], _lagEvent)) + { + Events.RemoveAt(i); + } + } + } + + // ── 指标卡与图表 ────────────────────────────────────────────────────── + + private void UpdateKpis(ServerSample s, RateSample? rate) + { + Connections.Set(BsonText.Grouped(s.Connections), "/ " + BsonText.Grouped(s.Connections + s.ConnectionsAvailable), + Loc.Format("Mon_ConnFoot", BsonText.Grouped(s.ConnectionsAvailable))); + + if (rate is null) + { + Ops.Set("—", "", Loc["Mon_Warming"]); + Network.Set("—", "", Loc["Mon_Warming"]); + } + else + { + Ops.Set(MonitorFormat.Rate(rate.Total), "", Loc.Format("Mon_OpsFoot", MonitorFormat.Rate(rate.Reads), MonitorFormat.Rate(rate.Writes))); + (double div, string unit) = MonitorFormat.ByteUnit(rate.BytesIn + rate.BytesOut); + Network.Set(MonitorFormat.One((rate.BytesIn + rate.BytesOut) / div), unit + "/s", + Loc.Format("Mon_NetFoot", MonitorFormat.One(rate.BytesIn / div), MonitorFormat.One(rate.BytesOut / div))); + } + + if (s.CacheMax > 0) + { + (double div, string unit) = MonitorFormat.ByteUnit(s.CacheUsed); + (double maxDiv, string maxUnit) = MonitorFormat.ByteUnit(s.CacheMax); + string max = (s.CacheMax / maxDiv).ToString("0.#", CultureInfo.InvariantCulture); + string dirty = (100.0 * s.CacheDirty / s.CacheMax).ToString("0.0", CultureInfo.InvariantCulture) + "%"; + Cache.Set(MonitorFormat.One(s.CacheUsed / div), unit == maxUnit ? $"/ {max} {maxUnit}" : $"{unit} / {max} {maxUnit}", + Loc.Format("Mon_CacheFoot", dirty, rate is null ? "—" : MonitorFormat.Rate(rate.Evictions))); + } + else + { + Cache.Set("—", "", s.Engine); + } + + if (!IsReplicaSet) + { + Lag.Set("—", "", Workspace.Connection.Server.Role == "MONGOS" ? Loc["Mon_LagMongos"] : Loc["Mon_LagStandalone"]); + Lag.IsAlert = false; + } + else if (_replica?.Slowest is { LagSeconds: { } lag } slowest) + { + Lag.Set(lag.ToString("0.0", CultureInfo.InvariantCulture), "s", Loc.Format("Mon_LagFoot", MonitorFormat.Host(slowest.Name))); + Lag.IsAlert = lag > LagThreshold; + } + else + { + Lag.Set("—", "", _replica is null ? Loc["Mon_ReplicaDenied"] : Loc["Mon_LagNoSecondary"]); + Lag.IsAlert = false; + } + } + + private TimeSpan WindowSpan => _window switch + { + "5m" => TimeSpan.FromMinutes(5), + "1h" => TimeSpan.FromHours(1), + "6h" => TimeSpan.FromHours(6), + _ => TimeSpan.FromMinutes(15) + }; + + private string WindowText => _window switch + { + "5m" => Loc.Format("Mon_DurMinutes", 5), + "1h" => Loc.Format("Mon_DurHoursOnly", 1), + "6h" => Loc.Format("Mon_DurHoursOnly", 6), + _ => Loc.Format("Mon_DurMinutes", 15) + }; + + private void UpdateCharts() + { + DateTime now = DateTime.UtcNow; + TimeSpan window = WindowSpan; + BucketSet set = _history.Buckets(window, Bars, now, Interval); + IReadOnlyList buckets = set.Buckets; + string format = set.BarSeconds < 60 ? "HH:mm:ss" : "HH:mm"; + string[] labels = [.. buckets.Select(b => b.Start.ToLocalTime().ToString(format, CultureInfo.InvariantCulture))]; + if (labels.Length > 0) + { + // 最右一根是"正在进行"的那一段(设计稿横轴的「21:09 现在」)。 + labels[^1] += " " + Loc["Mon_Now"]; + } + OpsLabels = labels; + OpsSeries = + [ + new("query", "MongoChart1", [.. buckets.Select(static b => b.Query)]), + new("insert", "MongoChart2", [.. buckets.Select(static b => b.Insert)]), + new("update", "MongoChart3", [.. buckets.Select(static b => b.Update)]), + new("delete", "MongoChart4", [.. buckets.Select(static b => b.Delete)]) + ]; + string bar = BarText(set.BarSeconds); + OpsSubtitle = set.Covered >= window + ? Loc.Format("Mon_OpsSubtitle", WindowText, bar) + : Loc.Format("Mon_OpsSubtitleWarm", Duration(set.Covered), bar); + RateSample? latest = _history.Latest; + Legend = + [ + new("query", "MongoChart1", MonitorFormat.Rate(latest?.Query ?? 0)), + new("insert", "MongoChart2", MonitorFormat.Rate(latest?.Insert ?? 0)), + new("update", "MongoChart3", MonitorFormat.Rate(latest?.Update ?? 0)), + new("delete", "MongoChart4", MonitorFormat.Rate(latest?.Delete ?? 0)) + ]; + ConnLabels = labels; + ConnSeries = [new(Loc["Mon_ConnSeries"], "MongoChart1", [.. buckets.Select(static b => b.Connections)])]; + OpsMax = MonitorFormat.NiceThirds(buckets.Select(static b => b.Query + b.Insert + b.Update + b.Delete).DefaultIfEmpty(0).Max()); + ConnMax = MonitorFormat.NiceThirds(buckets.Select(static b => b.Connections).DefaultIfEmpty(0).Max()); + long current = _last?.Connections ?? 0; + long peak = Math.Max(current, _history.Within(window, now).Select(static r => r.Connections).DefaultIfEmpty(0).Max()); + ConnSubtitle = Loc.Format("Mon_ConnSubtitle", BsonText.Grouped(current), BsonText.Grouped(peak)); + } + + private static string BarText(double seconds) => seconds switch + { + < 1 => seconds.ToString("0.#", CultureInfo.InvariantCulture) + " s", + < 60 => Math.Round(seconds).ToString("0", CultureInfo.InvariantCulture) + " s", + _ => Math.Round(seconds / 60).ToString("0", CultureInfo.InvariantCulture) + " min" + }; + + /// 时长(14 天 6 小时、3 小时 12 分、5 分钟、40 秒)。 + internal string Duration(TimeSpan span) + { + if (span.TotalDays >= 1) + { + return Loc.Format("Mon_DurDays", (int)span.TotalDays, span.Hours); + } + if (span.TotalHours >= 1) + { + return Loc.Format("Mon_DurHours", (int)span.TotalHours, span.Minutes); + } + if (span.TotalMinutes >= 1) + { + return Loc.Format("Mon_DurMinutes", (int)span.TotalMinutes); + } + return Loc.Format("Mon_DurSeconds", Math.Max(0, (int)Math.Round(span.TotalSeconds))); + } + + private void UpdateStatus() + { + ServerInfo server = Workspace.Connection.Server; + string who = server.SetName is { Length: > 0 } set ? set : server.Role; + string commands = IsReplicaSet ? " · replSetGetStatus" : ""; + StatusText = _paused + ? Loc.Format("Mon_StatusPaused", who, commands) + : Loc.Format("Mon_Status", who, commands, _intervalOverride is { } o ? o.TotalSeconds.ToString("0.##", CultureInfo.InvariantCulture) : _intervalSeconds.ToString(CultureInfo.InvariantCulture)); + } + + // ── 导出快照 ────────────────────────────────────────────────────────── + + private async Task ExportAsync() + { + if (_lastStatus is null) + { + return; + } + string name = $"mongo-monitor-{DateTime.Now.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture)}.json"; + string? path = await Workspace.PickSaveFileAsync(Loc["Mon_ExportTitle"], name, [FileKind.Json]).ConfigureAwait(true); + if (path is null) + { + return; + } + try + { + await File.WriteAllTextAsync(path, BuildSnapshot().ToJson(new JsonWriterSettings + { + OutputMode = JsonOutputMode.RelaxedExtendedJson, + Indent = true, + NewLineChars = "\n" + })).ConfigureAwait(true); + Workspace.Toast(new() { Title = Loc["Mon_Exported"], Detail = path, Kind = ToastKind.Success }); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Workspace.Toast(new() { Title = Loc.Format("Common_Failed", ex.Message), Kind = ToastKind.Error }); + } + } + + /// + /// 快照:最近一次 serverStatus 与 replSetGetStatus 的原文 + 内存里的整段率值序列 + 事件。 + /// 原文照存不裁剪 —— 拿快照的人多半是要发给 DBA 或附在工单里,缺哪一段都得回头再要。 + /// + internal BsonDocument BuildSnapshot() + { + ServerInfo server = Workspace.Connection.Server; + var series = new BsonArray(_history.Rates.Select(static r => new BsonDocument + { + { "t", r.Time }, + { "query", r.Query }, + { "insert", r.Insert }, + { "update", r.Update }, + { "delete", r.Delete }, + { "bytesInPerSec", r.BytesIn }, + { "bytesOutPerSec", r.BytesOut }, + { "evictionsPerSec", r.Evictions }, + { "connections", r.Connections } + })); + var events = new BsonArray(Events.Select(static e => new BsonDocument + { + { "t", e.Time }, + { "kind", e.Kind }, + { "title", e.Title }, + { "detail", e.Detail } + })); + return new BsonDocument + { + { "capturedAt", DateTime.UtcNow }, + { "connection", Workspace.ConnectionName }, + { "endpoint", Workspace.Connection.Endpoint }, + { "server", new BsonDocument { { "version", server.Version }, { "setName", (BsonValue?)server.SetName ?? BsonNull.Value }, { "role", server.Role } } }, + { "intervalSeconds", Interval.TotalSeconds }, + { "serverStatus", (BsonValue?)_lastStatus ?? BsonNull.Value }, + { "replSetGetStatus", (BsonValue?)_lastReplStatus ?? BsonNull.Value }, + { "series", series }, + { "events", events } + }; + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Mvvm.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Mvvm.cs new file mode 100644 index 0000000..ac9a463 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Mvvm.cs @@ -0,0 +1,222 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Windows.Input; +using Avalonia.Threading; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 最小可观察基类。 +/// +/// 刻意**不引 ReactiveUI / CommunityToolkit**:前者会随插件目录分发一整套(ReactiveUI + Splat + +/// DynamicData),而且插件 ALC 里那份 RxApp 与宿主的是两个独立实例,调度器不会自动挂到 +/// Avalonia 上;这里要的只是 与几个命令类型(与 Redis 插件同一口径)。 +/// +/// +public abstract class ObservableObject : INotifyPropertyChanged +{ + /// + public event PropertyChangedEventHandler? PropertyChanged; + + /// 值变化时赋值并通知;返回是否真的变了。 + /// 属性类型。 + /// 后备字段。 + /// 新值。 + /// 属性名(自动填充)。 + /// 是否变了。 + protected bool SetProperty(ref T field, T value, [CallerMemberName] string? propertyName = null) + { + if (EqualityComparer.Default.Equals(field, value)) + { + return false; + } + field = value; + RaisePropertyChanged(propertyName); + return true; + } + + /// + /// 手动触发一次属性变更通知(派生属性用)。 + /// + /// 绑定只能在 UI 线程更新。视图模型里不少加载逻辑跑在后台线程(ConfigureAwait(false) 之后), + /// 统一在这里封送,免得每个调用点都记得 Dispatcher —— 漏掉一处就是一次崩溃而不是一次退化。 + /// + /// + /// 属性名。 + protected void RaisePropertyChanged([CallerMemberName] string? propertyName = null) + { + if (PropertyChanged is not { } handler) + { + return; + } + if (Dispatcher.UIThread.CheckAccess()) + { + handler(this, new(propertyName)); + } + else + { + Dispatcher.UIThread.Post(() => handler(this, new(propertyName))); + } + } + + /// 一次通知多个属性。 + /// 属性名。 + protected void RaisePropertiesChanged(params string[] names) + { + foreach (string name in names) + { + RaisePropertyChanged(name); + } + } +} + +/// 无参异步命令。执行期间自动禁用自己,避免重复点击叠加请求。 +/// 命令体。 +/// 可用性判定;为 null 即恒可用。 +public sealed class AsyncCommand(Func execute, Func? canExecute = null) : ICommand +{ + private bool _running; + + /// + public event EventHandler? CanExecuteChanged; + + /// 正在执行。 + public bool IsRunning => _running; + + /// + public bool CanExecute(object? parameter) => !_running && (canExecute?.Invoke() ?? true); + + /// + public async void Execute(object? parameter) => await ExecuteAsync().ConfigureAwait(true); + + /// 执行并等它做完(串起几步的代码与单测用)。不可用时直接返回。 + /// 任务。 + public async Task ExecuteAsync() + { + if (!CanExecute(null)) + { + return; + } + _running = true; + RaiseCanExecuteChanged(); + try + { + await execute().ConfigureAwait(true); + } + catch + { + // 命令体自己负责把失败呈现到界面上;这里只保证不把异常抛进 async void 的同步上下文 —— + // 那会直接崩掉宿主进程。 + } + finally + { + _running = false; + RaiseCanExecuteChanged(); + } + } + + /// 重新求值可用性。 + public void RaiseCanExecuteChanged() => Dispatcher.UIThread.Post(() => CanExecuteChanged?.Invoke(this, EventArgs.Empty)); +} + +/// 带参异步命令。 +/// 参数类型。 +/// 命令体。 +/// 可用性判定。 +public sealed class AsyncCommand(Func execute, Func? canExecute = null) : ICommand +{ + private bool _running; + + /// + public event EventHandler? CanExecuteChanged; + + /// + public bool CanExecute(object? parameter) => !_running && parameter is T typed && (canExecute?.Invoke(typed) ?? true); + + /// + public async void Execute(object? parameter) + { + if (parameter is T typed) + { + await ExecuteAsync(typed).ConfigureAwait(true); + } + } + + /// 执行并等它做完(串起几步的代码与单测用)。不可用时直接返回。 + /// 参数。 + /// 任务。 + public async Task ExecuteAsync(T parameter) + { + if (!CanExecute(parameter)) + { + return; + } + _running = true; + RaiseCanExecuteChanged(); + try + { + await execute(parameter).ConfigureAwait(true); + } + catch + { + // 同 AsyncCommand:命令体自己呈现失败;这里只保证异常不逃出 async void 的 Execute。 + } + finally + { + _running = false; + RaiseCanExecuteChanged(); + } + } + + /// 重新求值可用性。 + public void RaiseCanExecuteChanged() => Dispatcher.UIThread.Post(() => CanExecuteChanged?.Invoke(this, EventArgs.Empty)); +} + +/// 同步命令。 +/// 命令体。 +/// 可用性判定。 +public sealed class RelayCommand(Action execute, Func? canExecute = null) : ICommand +{ + /// + public event EventHandler? CanExecuteChanged; + + /// + public bool CanExecute(object? parameter) => canExecute?.Invoke() ?? true; + + /// + public void Execute(object? parameter) + { + if (CanExecute(parameter)) + { + execute(); + } + } + + /// 重新求值可用性。 + public void RaiseCanExecuteChanged() => Dispatcher.UIThread.Post(() => CanExecuteChanged?.Invoke(this, EventArgs.Empty)); +} + +/// 带参同步命令。 +/// 参数类型。 +/// 命令体。 +/// 可用性判定。 +public sealed class RelayCommand(Action execute, Func? canExecute = null) : ICommand +{ + /// + public event EventHandler? CanExecuteChanged; + + /// + public bool CanExecute(object? parameter) => parameter is T typed && (canExecute?.Invoke(typed) ?? true); + + /// + public void Execute(object? parameter) + { + if (parameter is T typed && CanExecute(parameter)) + { + execute(typed); + } + } + + /// 重新求值可用性。 + public void RaiseCanExecuteChanged() => Dispatcher.UIThread.Post(() => CanExecuteChanged?.Invoke(this, EventArgs.Empty)); +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Objects/ObjectItem.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Objects/ObjectItem.cs new file mode 100644 index 0000000..f1d653b --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Objects/ObjectItem.cs @@ -0,0 +1,401 @@ +using System.Globalization; +using MongoDB.Bson; +using VelaShell.Plugin.Mongo.Bson; +using VelaShell.Plugin.Mongo.Core; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 对象列表里一行的种类(比 多一个"GridFS 桶",少一个系统集合)。 +internal enum ObjectKind +{ + /// 普通集合。 + Collection, + + /// 时序集合。 + TimeSeries, + + /// 固定集合。 + Capped, + + /// 聚簇集合。 + Clustered, + + /// 视图。 + View, + + /// GridFS 桶(X.files + X.chunks 合成一行)。 + Bucket +} + +/// 对象列表的三种视图(工具行右侧那组分段)。 +internal enum ObjectViewMode +{ + /// 大图标网格。 + Grid, + + /// 列表。 + List, + + /// 详情表(默认,设计稿 12)。 + Details +} + +/// +/// 对象列表的一行。列表先出名字与种类,统计数字由视图模型并发取回后陆续补上 —— +/// 所以数字列都是可空的: = 还没取到(显示空白),视图 = 永远没有(显示「—」)。 +/// +internal sealed class ObjectItem : ObservableObject +{ + private readonly Loc _loc; + private bool _isSelected; + private bool _isTtl; + private CollectionStats? _stats; + private long? _count; + private long? _avgSize; + private long? _dataSize; + private long? _storageSize; + private int? _indexCount; + private long? _indexSize; + + /// 一个集合或视图。 + public ObjectItem(CollectionInfo info, Loc loc) + { + _loc = loc; + Info = info; + Name = info.Name; + Database = info.Database; + Kind = info.Kind switch + { + CollectionKind.TimeSeries => ObjectKind.TimeSeries, + CollectionKind.Capped => ObjectKind.Capped, + CollectionKind.Clustered => ObjectKind.Clustered, + CollectionKind.View => ObjectKind.View, + _ => ObjectKind.Collection + }; + } + + /// 一个 GridFS 桶。 + public ObjectItem(GridFsBucketInfo bucket, Loc loc) + { + _loc = loc; + Bucket = bucket; + Name = bucket.Name; + Database = bucket.Database; + Kind = ObjectKind.Bucket; + } + + /// 所在库。 + public string Database { get; } + + /// 集合名 / 视图名 / 桶名(操作用的那个名字)。 + public string Name { get; } + + /// + /// 显示名:桶写成 fs.files / chunks(设计稿把两个集合合成一行)。名称列只有 170px, + /// 写全放不下时只写桶名(avatars)—— 类型列的 GridFS 徽章已经说明了它是一对集合。 + /// + public string DisplayName => Bucket is { } b + ? b.Name.Length <= 6 ? $"{b.FilesCollection} / chunks" : b.Name + : Name; + + /// 种类。 + public ObjectKind Kind { get; } + + /// 集合信息;桶为 。 + public CollectionInfo? Info { get; } + + /// 桶信息;集合与视图为 。 + public GridFsBucketInfo? Bucket { get; } + + /// 是不是视图(数字列一律「—」)。 + public bool IsView => Kind == ObjectKind.View; + + /// 是不是能装文档、能导入 / 清空的那种集合(不是视图、不是桶)。 + public bool IsCollection => Kind is ObjectKind.Collection or ObjectKind.TimeSeries or ObjectKind.Capped or ObjectKind.Clustered; + + /// `db.x` 的命名空间。 + public string Namespace => $"{Database}.{Name}"; + + // ── 外观 ───────────────────────────────────────────────────────────── + + /// 图标(按种类,与对象树一致)。 + public string IconKey => Kind switch + { + ObjectKind.TimeSeries => "Mongo.chart-no-axes-column", + ObjectKind.View => "Mongo.eye", + ObjectKind.Bucket => "Mongo.hard-drive", + ObjectKind.Clustered => "Mongo.layers", + _ => "Mongo.table-2" + }; + + /// 图标本色(未选中时)。 + public string IconToken => Kind == ObjectKind.Bucket ? "VelaWarning" : "VelaInfo"; + + /// 实际画的图标色:选中行改用强调色(设计稿 12 的 events 行)。 + public string ShownIconToken => _isSelected ? "VelaAccent" : IconToken; + + /// 是否为当前选中行(由视图模型维护,用于强调色的名字与图标)。 + public bool IsSelected + { + get => _isSelected; + set + { + if (SetProperty(ref _isSelected, value)) + { + RaisePropertyChanged(nameof(ShownIconToken)); + } + } + } + + /// 带 TTL 索引(普通集合的类型列改成橙色 TTL 徽章)。 + public bool IsTtl + { + get => _isTtl; + set + { + if (SetProperty(ref _isTtl, value)) + { + RaisePropertiesChanged(nameof(TypeText), nameof(TagClass), nameof(HasTag)); + } + } + } + + /// 类型列的文字(集合 / 时序 / 固定 1 GB / GridFS / TTL / 视图)。 + public string TypeText => Kind switch + { + ObjectKind.TimeSeries => _loc["Tag_TimeSeries"], + ObjectKind.Capped => Info?.CappedSize is { } size ? _loc.Format("Obj_TagCappedSize", ShortBytes(size)) : _loc["Tag_Capped"], + ObjectKind.Clustered => _loc["Tag_Clustered"], + ObjectKind.View => _loc["Obj_TagView"], + ObjectKind.Bucket => "GridFS", + _ when _isTtl => _loc["Tag_Ttl"], + _ => _loc["Obj_KindCollectionShort"] + }; + + /// 类型徽章的配色类;空 = 不画徽章,只写一行灰字(普通集合)。 + public string TagClass => Kind switch + { + ObjectKind.TimeSeries => "info", + ObjectKind.Bucket => "warn", + ObjectKind.Capped or ObjectKind.Clustered or ObjectKind.View => "muted", + _ when _isTtl => "warn", + _ => "" + }; + + /// 类型列画不画徽章。 + public bool HasTag => TagClass.Length > 0; + + /// 完整的种类名(时序集合),详情面板副标题与状态栏用。 + public string KindName => _loc[Kind switch + { + ObjectKind.TimeSeries => "Obj_KindTimeSeries", + ObjectKind.Capped => "Obj_KindCapped", + ObjectKind.Clustered => "Obj_KindClustered", + ObjectKind.View => "Obj_KindView", + ObjectKind.Bucket => "Obj_KindBucket", + _ => "Obj_KindCollection" + }]; + + // ── 验证列 ─────────────────────────────────────────────────────────── + + /// 有验证规则。 + public bool HasValidator => Info?.Validator is not null; + + /// 验证级别(strict / moderate)。 + public string ValidationLevel => Info?.ValidationLevel ?? ""; + + /// 盾牌颜色:strict 绿、moderate 蓝、off 灰(与设计稿 12 一致)。 + public string ValidationToken => ValidationLevel switch + { + "strict" => "VelaStatusConnected", + "moderate" => "VelaInfo", + _ => "VelaTextMuted" + }; + + // ── 统计 ───────────────────────────────────────────────────────────── + + /// 索引(listIndexes 原文;取统计时顺带取回,DDL 与 TTL 徽章用)。 + public IReadOnlyList? Indexes { get; set; } + + /// 统计原文(集合;桶是 files 那一份)。 + public CollectionStats? Stats => _stats; + + /// 桶的 chunks 统计。 + public CollectionStats? ChunkStats { get; private set; } + + /// 统计取回来了没有。 + public bool HasStats => _stats is not null; + + /// 文档数(桶 = 文件数)。 + public long? Count => _count; + + /// 平均文档大小(桶 = 平均文件大小)。 + public long? AvgSize => _avgSize; + + /// 数据大小(未压缩)。 + public long? DataSize => _dataSize; + + /// 存储大小(压缩后)。 + public long? StorageSize => _storageSize; + + /// 索引个数。 + public int? IndexCount => _indexCount; + + /// 索引大小。 + public long? IndexSize => _indexSize; + + /// 时序集合的 bucket 数(storageStats.timeseries.bucketCount)。 + public long? BucketCount => + _stats?.Raw.TryGetValue("timeseries", out BsonValue ts) == true && ts.IsBsonDocument + && ts.AsBsonDocument.TryGetValue("bucketCount", out BsonValue n) && n.IsNumeric + ? n.ToInt64() + : null; + + /// 文档数列:一千万以下写全(1,284,902),再大就缩写(42.7M)—— 与设计稿同一口径。 + public string CountText => IsView ? "—" : _count is { } n ? (n >= 10_000_000 ? BsonText.Count(n) : BsonText.Grouped(n)) : ""; + + /// 平均大小列。 + public string AvgSizeText => Size(_avgSize); + + /// 数据大小列。 + public string DataSizeText => Size(_dataSize); + + /// 存储大小列。 + public string StorageSizeText => Size(_storageSize); + + /// 索引个数列。 + public string IndexCountText => IsView ? "—" : _indexCount?.ToString(CultureInfo.InvariantCulture) ?? ""; + + /// 索引大小列。 + public string IndexSizeText => Size(_indexSize); + + /// + /// 填上一个集合的统计。时序集合的 storageStats.count 是 0(文档在桶里), + /// 文档数改用 。 + /// + public void SetStats(CollectionStats stats, long? estimatedCount = null) + { + _stats = stats; + _count = stats.Count > 0 ? stats.Count : estimatedCount ?? stats.Count; + _dataSize = stats.Size; + _avgSize = stats.AvgObjSize > 0 ? stats.AvgObjSize : _count > 0 ? stats.Size / _count : 0; + _storageSize = stats.StorageSize; + _indexCount = stats.IndexCount; + _indexSize = stats.TotalIndexSize; + RaiseStats(); + } + + /// + /// 填上一个桶的统计:文件数取 files,数据量 = files + chunks,平均大小按 chunks / 文件数 + /// (一份"文件"的大小落在 chunks 里,files 只是元数据)。 + /// + public void SetBucketStats(CollectionStats files, CollectionStats chunks) + { + _stats = files; + ChunkStats = chunks; + _count = files.Count; + _dataSize = files.Size + chunks.Size; + _avgSize = files.Count > 0 ? chunks.Size / files.Count : 0; + _storageSize = files.StorageSize + chunks.StorageSize; + _indexCount = files.IndexCount + chunks.IndexCount; + _indexSize = files.TotalIndexSize + chunks.TotalIndexSize; + RaiseStats(); + } + + /// + /// 重拉列表时先沿用上一轮的数字(同名同种类的行),新统计到了再覆盖 —— + /// 否则每次切回标签,整列数字都会先清空再一格格冒出来。 + /// + public void CarryOver(ObjectItem previous) + { + if (previous.Kind != Kind || previous._stats is null) + { + return; + } + _stats = previous._stats; + ChunkStats = previous.ChunkStats; + _count = previous._count; + _avgSize = previous._avgSize; + _dataSize = previous._dataSize; + _storageSize = previous._storageSize; + _indexCount = previous._indexCount; + _indexSize = previous._indexSize; + _isTtl = previous._isTtl; + RaiseStats(); + } + + private void RaiseStats() => RaisePropertiesChanged( + nameof(Stats), nameof(HasStats), nameof(Count), nameof(AvgSize), nameof(DataSize), nameof(StorageSize), + nameof(IndexCount), nameof(IndexSize), nameof(CountText), nameof(AvgSizeText), nameof(DataSizeText), + nameof(StorageSizeText), nameof(IndexCountText), nameof(IndexSizeText), nameof(BucketCount)); + + private string Size(long? bytes) => IsView ? "—" : bytes is { } b ? BsonText.Bytes(b) : ""; + + /// 1.0 GB → 1 GB:徽章里那半格宽度很贵。 + internal static string ShortBytes(long bytes) + { + string text = BsonText.Bytes(bytes); + return text.Replace(".0 ", " ", StringComparison.Ordinal); + } + + /// 排序键(列名见 )。 + internal IComparable SortKey(string column) => column switch + { + "Type" => TypeText, + "Count" => _count ?? -1, + "AvgSize" => _avgSize ?? -1, + "DataSize" => _dataSize ?? -1, + "StorageSize" => _storageSize ?? -1, + "Indexes" => (long)(_indexCount ?? -1), + "IndexSize" => _indexSize ?? -1, + "Validation" => HasValidator ? ValidationLevel : "", + _ => DisplayName + }; +} + +/// 详情面板的一个小节(时序选项 / 统计 / 最近活动)。 +/// 小节标题。 +/// 键值行。 +internal sealed record ObjectFactSection(string Title, IReadOnlyList Facts); + +/// +/// 详情面板的一行键值。值可以后补(写入速率要两次采样、最近文档要再查一次),所以是可观察的。 +/// +internal sealed class ObjectFact : ObservableObject +{ + private string _value; + private string _token; + + /// 构造。 + /// 标签。 + /// 值。 + /// 值的颜色令牌。 + public ObjectFact(string label, string value, string token = "VelaTextSecondary") + { + Label = label; + _value = value; + _token = token; + } + + /// 标签。 + public string Label { get; } + + /// 值。 + public string Value + { + get => _value; + set => SetProperty(ref _value, value); + } + + /// 值的颜色令牌。 + public string Token + { + get => _token; + set => SetProperty(ref _token, value); + } +} + +/// 权限页的一个动作(find、insert…);写类动作单独上色。 +/// 动作名。 +/// 是不是写类动作。 +internal sealed record ObjectPrivilege(string Name, bool IsWrite); diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Objects/ObjectScripts.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Objects/ObjectScripts.cs new file mode 100644 index 0000000..903149b --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Objects/ObjectScripts.cs @@ -0,0 +1,183 @@ +using System.Text; +using MongoDB.Bson; +using VelaShell.Plugin.Mongo.Bson; +using VelaShell.Plugin.Mongo.Core; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 集合结构 → mongosh 脚本。对象列表的「复制结构」、详情面板的 DDL 页与新建集合的「将执行的命令」 +/// 共用这一份,于是三处写出来的是同一种形状 —— 用户从预览里学到的写法,复制出去也是那个样子。 +/// +/// 输出的是**能直接贴进 mongosh 重放**的脚本,而不是 listCollections 原文: +/// 服务器回显里有几样是派生值或内部字段(时序的 bucketMaxSpanSeconds、聚簇索引的 v、 +/// 索引的 v / ns),原样回放要么报错、要么和粒度互相冲突,这里统一剥掉。 +/// +/// +internal static class ObjectScripts +{ + /// db.orders 还是 db.getCollection("weird-name")。 + public static string CollectionRef(string collection) => "db." + MongoWorkspaceViewModel.ShellCollectionRef(collection); + + /// + /// db.createCollection("name", { … }),按设计稿 17 的排版:名字与选项各占一段,选项缩进两格。 + /// + /// 集合名。 + /// 建集合的选项;空文档即不带第二个参数。 + public static string CreateCollection(string name, BsonDocument options) + { + if (options.ElementCount == 0) + { + return $"db.createCollection({BsonText.Quote(name)})"; + } + var b = new StringBuilder(); + b.Append("db.createCollection(\n ").Append(BsonText.Quote(name)).Append(",\n"); + b.Append(Indent(BsonText.Pretty(Simplify(options)), " ")).Append("\n)"); + return b.ToString(); + } + + /// db.createView("name", "source", [ … ])(带排序规则时补第四个参数)。 + /// 视图名。 + /// 源集合。 + /// 管道。 + /// 排序规则;没有为 。 + public static string CreateView(string name, string viewOn, BsonArray pipeline, BsonDocument? collation = null) + { + var b = new StringBuilder(); + b.Append("db.createView(\n ").Append(BsonText.Quote(name)).Append(",\n ").Append(BsonText.Quote(viewOn)).Append(",\n"); + b.Append(Indent(BsonText.Pretty(Simplify(pipeline)), " ")); + if (collation is { ElementCount: > 0 }) + { + b.Append(",\n").Append(Indent(BsonText.Pretty(new BsonDocument("collation", Simplify(collation))), " ")); + } + b.Append("\n)"); + return b.ToString(); + } + + /// 一条 createIndex:键模式 + 除 v / key / ns 以外的全部选项(名字保留,重放后索引名不变)。 + /// 集合名。 + /// listIndexes 回来的一条。 + public static string CreateIndex(string collection, BsonDocument index) + { + BsonDocument key = index.GetValue("key", new BsonDocument()).AsBsonDocument; + BsonDocument options = IndexOptions(index); + string call = CollectionRef(collection) + ".createIndex(" + BsonText.Literal(Simplify(key)); + return options.ElementCount == 0 + ? call + ")" + : call + ", " + BsonText.Literal(Simplify(options)) + ")"; + } + + /// 索引的建索引选项(剥掉服务器回显的内部字段)。 + public static BsonDocument IndexOptions(BsonDocument index) + { + var options = new BsonDocument(); + foreach (BsonElement element in index) + { + if (element.Name is "v" or "key" or "ns" or "clustered") + { + continue; + } + options.Add(element); + } + return options; + } + + /// 这条索引要不要出现在脚本里:_id 索引与聚簇索引是建集合时自带的,写出来重放会报"已存在"。 + public static bool IsReplayable(BsonDocument index) => + index.GetValue("name", "").AsString != "_id_" && !index.GetValue("clustered", false).ToBoolean(); + + /// 验证规则:db.runCommand({ collMod: …, validator, validationLevel, validationAction })。 + public static string CollMod(CollectionInfo info) + { + var command = new BsonDocument + { + { "collMod", info.Name }, + { "validator", info.Validator ?? [] }, + { "validationLevel", info.ValidationLevel }, + { "validationAction", info.ValidationAction } + }; + return "db.runCommand(" + BsonText.Pretty(Simplify(command)) + ")"; + } + + /// + /// listCollections 的 options → 能回放的 createCollection 选项。 + /// 验证规则拆出去单独走 collMod(规则往往很长,和建集合选项搅在一起没法读)。 + /// + public static BsonDocument CreationOptions(BsonDocument options) + { + var result = new BsonDocument(); + foreach (BsonElement element in options) + { + switch (element.Name) + { + case "validator" or "validationLevel" or "validationAction" or "viewOn" or "pipeline": + continue; + case "timeseries" when element.Value.IsBsonDocument: + { + // 给了粒度时 bucketMaxSpanSeconds 是它的派生值;两个都写,6.3 起服务器会拒绝。 + BsonDocument ts = element.Value.AsBsonDocument.DeepClone().AsBsonDocument; + if (ts.Contains("granularity")) + { + ts.Remove("bucketMaxSpanSeconds"); + ts.Remove("bucketRoundingSeconds"); + } + result.Add("timeseries", ts); + continue; + } + case "clusteredIndex" when element.Value.IsBsonDocument: + { + BsonDocument clustered = element.Value.AsBsonDocument.DeepClone().AsBsonDocument; + clustered.Remove("v"); + result.Add("clusteredIndex", clustered); + continue; + } + default: + result.Add(element); + continue; + } + } + return result; + } + + /// + /// 一个集合(或视图)的完整结构脚本:建集合 + 每个二级索引 + 验证规则。 + /// + /// 集合信息。 + /// 它的索引(视图传空)。 + public static string Ddl(CollectionInfo info, IReadOnlyList indexes) + { + if (info.Kind == CollectionKind.View) + { + BsonDocument? collation = info.Options.TryGetValue("collation", out BsonValue c) && c.IsBsonDocument ? c.AsBsonDocument : null; + return CreateView(info.Name, info.ViewOn ?? "", info.Pipeline ?? [], collation); + } + var lines = new List { CreateCollection(info.Name, CreationOptions(info.Options)) }; + lines.AddRange(indexes.Where(IsReplayable).Select(i => CreateIndex(info.Name, i))); + if (info.Validator is not null) + { + lines.Add(CollMod(info)); + } + return string.Join("\n", lines); + } + + /// + /// 把能装进 32 位的 Int64 收窄成 Int32:服务器回显的 expireAfterSeconds、 + /// size 是 long,原样写出来是 NumberLong("7776000") —— 能跑,但没人这么写。 + /// + public static BsonValue Simplify(BsonValue value) => value switch + { + BsonInt64 l when l.Value is >= int.MinValue and <= int.MaxValue => new BsonInt32((int)l.Value), + BsonDocument doc => new BsonDocument(doc.Select(e => new BsonElement(e.Name, Simplify(e.Value)))), + BsonArray array => new BsonArray(array.Select(Simplify)), + _ => value + }; + + /// + public static BsonDocument Simplify(BsonDocument value) => (BsonDocument)Simplify((BsonValue)value); + + /// + public static BsonArray Simplify(BsonArray value) => (BsonArray)Simplify((BsonValue)value); + + private static string Indent(string text, string prefix) => + string.Join("\n", text.Split('\n').Select(line => prefix + line)); +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Objects/ObjectsConverters.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Objects/ObjectsConverters.cs new file mode 100644 index 0000000..742bbe3 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Objects/ObjectsConverters.cs @@ -0,0 +1,17 @@ +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 对象列表 / 新建集合两块界面自用的转换器(共享的 里没有的那几个)。 +/// +public static class ObjectsConverters +{ + /// + /// 令牌名 → 12% 淡底画刷。详情面板头的 32px 图标方块、大图标视图的图标底 —— 颜色随对象种类变, + /// 所以不能像 {ui:Dim VelaInfo, 0.12} 那样在 XAML 里写死令牌。 + /// + public static IValueConverter DimToken { get; } = + new FuncValueConverter(static token => ThemeBrushes.GetDim(token ?? "VelaTextTertiary", 0.12)); +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Objects/ObjectsTabView.axaml b/plugins/VelaShell.Plugin.Mongo/Ui/Objects/ObjectsTabView.axaml new file mode 100644 index 0000000..bc6cfb2 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Objects/ObjectsTabView.axaml @@ -0,0 +1,541 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Objects/ObjectsTabView.axaml.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Objects/ObjectsTabView.axaml.cs new file mode 100644 index 0000000..301a8eb --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Objects/ObjectsTabView.axaml.cs @@ -0,0 +1,121 @@ +using System.Windows.Input; +using Avalonia.Controls; +using Avalonia.Input; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 对象列表视图。代码只做视图该做的事:双击打开、右键菜单(按选中对象的种类现建)、 +/// Enter 打开 / Delete 删除。 +/// +public sealed partial class ObjectsTabView : UserControl +{ + private readonly ObjectsTabViewModel? _viewModel; + + /// 用给定的视图模型初始化。 + internal ObjectsTabView(ObjectsTabViewModel viewModel) + { + _viewModel = viewModel; + InitializeComponent(); + DataContext = viewModel; + foreach (ListBox list in new[] { DetailsList, GridList, CompactList }) + { + list.DoubleTapped += OnDoubleTapped; + list.ContextRequested += OnContextRequested; + list.KeyDown += OnListKeyDown; + } + } + + /// 设计器 / headless 用的无参构造。 + public ObjectsTabView() + { + InitializeComponent(); + } + + private void OnDoubleTapped(object? sender, TappedEventArgs e) + { + if (_viewModel is not null && (e.Source as Control)?.DataContext is ObjectItem item) + { + _viewModel.SelectedItem = item; + _viewModel.OpenSelected(); + } + } + + private void OnListKeyDown(object? sender, KeyEventArgs e) + { + if (_viewModel?.SelectedItem is null || e.KeyModifiers != KeyModifiers.None) + { + return; + } + switch (e.Key) + { + case Key.Enter: + _viewModel.OpenSelected(); + e.Handled = true; + break; + case Key.Delete: + _viewModel.DropCommand.Execute(null); + e.Handled = true; + break; + } + } + + /// + /// 右键菜单现建:集合、视图、桶能做的事各不相同,能不能做由命令自己的 CanExecute 说了算 —— + /// 不能做的项直接不出现,而不是一排灰掉的菜单。 + /// + private void OnContextRequested(object? sender, ContextRequestedEventArgs e) + { + if (_viewModel is null || (e.Source as Control)?.DataContext is not ObjectItem item) + { + return; + } + _viewModel.SelectedItem = item; + Loc loc = _viewModel.Loc; + var items = new List(); + + void Add(string label, string icon, ICommand command, bool danger = false) + { + if (!command.CanExecute(null)) + { + return; + } + items.Add(MenuKit.Command(label, icon, command, danger: danger)); + } + + void Separator() + { + if (items.Count > 0 && items[^1] is not Avalonia.Controls.Separator) + { + items.Add(new Avalonia.Controls.Separator()); + } + } + + Add(_viewModel.OpenLabel, item.Kind == ObjectKind.Bucket ? "Mongo.hard-drive" : "Mongo.folder-open", _viewModel.OpenCommand); + Add(loc["Tree_Design"], "Mongo.pencil-ruler", _viewModel.DesignCommand); + Add(loc["Tree_Pipeline"], "Mongo.workflow", _viewModel.PipelineCommand); + if (item.Kind != ObjectKind.Bucket) + { + Add(loc["Tree_NewQuery"], "Mongo.file-code", _viewModel.QueryCommand); + } + Separator(); + Add(loc["Tree_Import"], "Mongo.download", _viewModel.ImportCommand); + Add(loc["Tree_Export"], "Mongo.upload", _viewModel.ExportCommand); + Add(loc["Tree_CopyName"], "Mongo.copy", _viewModel.CopyNameCommand); + Add(loc["Obj_CopyStructure"], "Mongo.code", _viewModel.CopyStructureCommand); + Separator(); + Add(loc["Tree_Empty"], "Mongo.eraser", _viewModel.EmptyCommand, danger: true); + Add(loc["Tree_Drop"], "Mongo.trash-2", _viewModel.DropCommand, danger: true); + if (items.Count > 0 && items[^1] is Avalonia.Controls.Separator) + { + items.RemoveAt(items.Count - 1); + } + if (items.Count == 0) + { + return; + } + var menu = new ContextMenu { ItemsSource = items }; + menu.Open(e.Source as Control ?? this); + e.Handled = true; + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Objects/ObjectsTabViewModel.Details.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Objects/ObjectsTabViewModel.Details.cs new file mode 100644 index 0000000..6dae8f9 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Objects/ObjectsTabViewModel.Details.cs @@ -0,0 +1,802 @@ +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Globalization; +using MongoDB.Bson; +using MongoDB.Driver; +using VelaShell.Plugin.Mongo.Bson; +using VelaShell.Plugin.Mongo.Core; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 详情面板的页签。 +internal enum ObjectDetailTab +{ + /// 常规:类型选项、统计、最近活动。 + General, + + /// DDL:能重放的 mongosh 脚本。 + Ddl, + + /// 权限:当前用户对这个集合的有效动作。 + Privileges +} + +/// 对象列表:右侧详情面板与工具行动作。 +internal sealed partial class ObjectsTabViewModel +{ + /// 算"写"的那些动作(权限页上单独上色;只读用户一眼看出自己少了哪一块)。 + private static readonly HashSet WriteActionNames = new(StringComparer.Ordinal) + { + "insert", "update", "remove", "createCollection", "dropCollection", "createIndex", "dropIndex", "collMod", + "convertToCapped", "renameCollectionSameDB", "compact", "reIndex", "bypassDocumentValidation", + "createSearchIndexes", "dropSearchIndex", "updateSearchIndex", "enableProfiler", "dropDatabase" + }; + + private CancellationTokenSource? _detail; + private ObjectDetailTab _detailTab; + private string _ddlText = ""; + private string _privilegeUser = ""; + private string _privilegeNotice = ""; + private BsonDocument? _connectionStatus; + + /// 当前页签。 + public ObjectDetailTab DetailTab + { + get => _detailTab; + set + { + if (SetProperty(ref _detailTab, value)) + { + RaisePropertiesChanged(nameof(IsGeneralTab), nameof(IsDdlTab), nameof(IsPrivilegesTab)); + } + } + } + + /// 页签:常规。 + public bool IsGeneralTab { get => _detailTab == ObjectDetailTab.General; set { if (value) { DetailTab = ObjectDetailTab.General; } } } + + /// 页签:DDL。 + public bool IsDdlTab { get => _detailTab == ObjectDetailTab.Ddl; set { if (value) { DetailTab = ObjectDetailTab.Ddl; } } } + + /// 页签:权限。 + public bool IsPrivilegesTab { get => _detailTab == ObjectDetailTab.Privileges; set { if (value) { DetailTab = ObjectDetailTab.Privileges; } } } + + /// 面板头的图标。 + public string DetailIconKey => _selected?.IconKey ?? "Mongo.layout-grid"; + + /// 面板头的图标颜色。 + public string DetailIconToken => _selected?.IconToken ?? "VelaTextTertiary"; + + /// 面板头的名字。 + public string DetailTitle => _selected?.DisplayName ?? ""; + + /// 面板头的副标题(时序集合 · shop)。 + public string DetailSubtitle => _selected is { } s ? $"{s.KindName} · {Database}" : ""; + + /// 底部主按钮的文字(集合 / 视图 / 存储桶各说各的)。 + public string OpenLabel => _selected?.Kind switch + { + ObjectKind.Bucket => Loc["Obj_OpenBucket"], + ObjectKind.View => Loc["Obj_OpenView"], + _ => Loc["Obj_OpenCollection"] + }; + + /// 「设计集合」可用(视图与桶没有可设计的结构)。 + public bool CanDesign => _selected?.IsCollection == true; + + /// 「在管道构建器中打开」可用。 + public bool CanPipeline => _selected is { Kind: not ObjectKind.Bucket }; + + /// 常规页的小节。 + public ObservableCollection Sections { get; } = []; + + /// DDL 页的脚本。 + public string DdlText + { + get => _ddlText; + private set + { + if (SetProperty(ref _ddlText, value)) + { + CopyDdlCommand.RaiseCanExecuteChanged(); + } + } + } + + /// 权限页:当前用户(ops_reader@admin)。 + public string PrivilegeUser + { + get => _privilegeUser; + private set => SetProperty(ref _privilegeUser, value); + } + + /// 权限页的提示(未开认证 / 没有任何动作 / 取不到)。 + public string PrivilegeNotice + { + get => _privilegeNotice; + private set => SetProperty(ref _privilegeNotice, value); + } + + /// 权限页「对 shop.events 的有效动作」那一行标题。 + public string PrivilegeActionsTitle => _selected is { } s + ? Loc.Format("Obj_PrivActions", s.Bucket is { } b ? $"{Database}.{b.FilesCollection}" : s.Namespace) + : ""; + + /// 权限页:角色。 + public ObservableCollection PrivilegeRoles { get; } = []; + + /// 权限页:对选中集合的有效动作。 + public ObservableCollection PrivilegeActions { get; } = []; + + // ── 详情加载 ───────────────────────────────────────────────────────── + + private async Task LoadDetailAsync(ObjectItem? item) + { + // 只取消不释放:上一轮的驱动调用可能还挂在这个令牌上,释放掉会让它们在注册回调时抛。 + _detail?.Cancel(); + var cts = new CancellationTokenSource(); + _detail = cts; + CancellationToken cancellationToken = cts.Token; + Sections.Clear(); + DdlText = ""; + PrivilegeUser = ""; + PrivilegeNotice = ""; + PrivilegeRoles.Clear(); + PrivilegeActions.Clear(); + if (item is null) + { + return; + } + ObjectFact writes = new(Loc["Obj_FactWrites"], "…"); + ObjectFact last = new(Loc["Obj_FactLastDoc"], "…", "VelaShellMagenta"); + ObjectFact created = new(Loc["Obj_FactCreated"], "…", "VelaShellMagenta"); + ObjectFact[] activity = [writes, last, created]; + ShowSections(item, activity); + try + { + await EnsureStatsAsync(item).WaitAsync(cancellationToken).ConfigureAwait(true); + if (cancellationToken.IsCancellationRequested) + { + return; + } + ShowSections(item, activity); + DdlText = await BuildDdlAsync(item, cancellationToken).ConfigureAwait(true); + await LoadPrivilegesAsync(item, cancellationToken).ConfigureAwait(true); + if (!item.IsView) + { + await FillActivityAsync(item, writes, last, created, cancellationToken).ConfigureAwait(true); + } + } + catch (OperationCanceledException) + { + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + // 详情是锦上添花:某一项取不到就让它停在「—」,不打断用户。 + foreach (ObjectFact fact in activity.Where(static f => f.Value == "…")) + { + fact.Value = "—"; + fact.Token = "VelaTextMuted"; + } + } + } + + private void ShowSections(ObjectItem item, ObjectFact[] activity) + { + Sections.Clear(); + foreach (ObjectFactSection section in BuildSections(item, activity)) + { + Sections.Add(section); + } + } + + /// 常规页的小节:类型专属选项 → 统计 → 最近活动(视图只有第一节)。 + internal IReadOnlyList BuildSections(ObjectItem item, IReadOnlyList activity) + { + var sections = new List { KindSection(item) }; + if (item.IsView) + { + return sections; + } + sections.Add(StatsSection(item)); + sections.Add(new(Loc["Obj_SecActivity"], activity)); + return sections; + } + + private ObjectFactSection KindSection(ObjectItem item) + { + BsonDocument options = item.Info?.Options ?? []; + var facts = new List(); + switch (item.Kind) + { + case ObjectKind.TimeSeries: + { + BsonDocument ts = options.TryGetValue("timeseries", out BsonValue t) && t.IsBsonDocument ? t.AsBsonDocument : []; + facts.Add(new("timeField", ts.GetValue("timeField", "—").ToString() ?? "—", "VelaShellCyan")); + facts.Add(ts.TryGetValue("metaField", out BsonValue meta) + ? new("metaField", meta.ToString() ?? "", "VelaShellCyan") + : new("metaField", "—", "VelaTextMuted")); + facts.Add(ts.TryGetValue("granularity", out BsonValue granularity) + ? new("granularity", granularity.ToString() ?? "") + : new("bucketMaxSpanSeconds", Seconds(ts.GetValue("bucketMaxSpanSeconds", 0).ToInt64()))); + facts.Add(Expire(options)); + return new(Loc["Obj_SecTimeSeries"], facts); + } + case ObjectKind.Capped: + { + long size = options.GetValue("size", 0).ToInt64(); + facts.Add(new("size", $"{BsonText.Grouped(size)} ({ObjectItem.ShortBytes(size)})")); + facts.Add(options.TryGetValue("max", out BsonValue max) && max.IsNumeric + ? new("max", BsonText.Grouped(max.ToInt64())) + : new("max", "—", "VelaTextMuted")); + AddValidation(item, facts); + return new(Loc["Obj_SecCapped"], facts); + } + case ObjectKind.Clustered: + { + BsonDocument clustered = options.TryGetValue("clusteredIndex", out BsonValue c) && c.IsBsonDocument ? c.AsBsonDocument : []; + facts.Add(new("clusteredIndex", BsonText.Literal(clustered.GetValue("key", new BsonDocument("_id", 1))), "VelaShellCyan")); + facts.Add(new("unique", clustered.GetValue("unique", true).ToBoolean() ? "true" : "false")); + if (options.Contains("expireAfterSeconds")) + { + facts.Add(Expire(options)); + } + AddValidation(item, facts); + return new(Loc["Obj_SecClustered"], facts); + } + case ObjectKind.View: + { + BsonArray pipeline = item.Info?.Pipeline ?? []; + facts.Add(new(Loc["Obj_FactViewOn"], item.Info?.ViewOn ?? "—", "VelaShellCyan")); + facts.Add(new(Loc["Obj_FactStages"], pipeline.Count.ToString(CultureInfo.InvariantCulture))); + if (pipeline.Count > 0) + { + facts.Add(new(Loc["Obj_FactPipeline"], string.Join(" → ", pipeline + .Where(static s => s.IsBsonDocument && s.AsBsonDocument.ElementCount > 0) + .Select(static s => s.AsBsonDocument.GetElement(0).Name)))); + } + return new(Loc["Obj_SecView"], facts); + } + case ObjectKind.Bucket: + { + GridFsBucketInfo bucket = item.Bucket!; + facts.Add(new(Loc["Obj_FactFilesCollection"], bucket.FilesCollection, "VelaShellCyan")); + facts.Add(new(Loc["Obj_FactChunksCollection"], bucket.ChunksCollection, "VelaShellCyan")); + if (item.ChunkStats is { } chunks) + { + facts.Add(new(Loc["Obj_FactChunks"], BsonText.Grouped(chunks.Count))); + } + return new(Loc["Obj_SecBucket"], facts); + } + default: + { + AddValidation(item, facts); + if (item.Info?.Validator is null) + { + facts.Add(new(Loc["Obj_FactValidation"], Loc["Obj_None"], "VelaTextMuted")); + } + if (item.Indexes?.FirstOrDefault(static i => i.Contains("expireAfterSeconds")) is { } ttl) + { + facts.Add(new(Loc["Obj_FactTtl"], + $"{ttl.GetValue("name", "").AsString} · {Seconds(ttl["expireAfterSeconds"].ToInt64())}", "VelaWarning")); + } + if (options.TryGetValue("collation", out BsonValue collation) && collation.IsBsonDocument) + { + facts.Add(new(Loc["Obj_FactCollation"], collation.AsBsonDocument.GetValue("locale", "simple").ToString() ?? "")); + } + return new(Loc["Obj_SecCollection"], facts); + } + } + } + + private void AddValidation(ObjectItem item, List facts) + { + if (item.Info?.Validator is not { } validator) + { + return; + } + facts.Add(new("validationLevel", item.Info.ValidationLevel, item.ValidationToken)); + facts.Add(new("validationAction", item.Info.ValidationAction, item.Info.ValidationAction == "error" ? "VelaWarning" : "VelaTextSecondary")); + facts.Add(new(Loc["Obj_FactRules"], string.Join(", ", validator.Names), "VelaShellCyan")); + } + + private ObjectFact Expire(BsonDocument options) => + options.TryGetValue("expireAfterSeconds", out BsonValue expire) && expire.IsNumeric + ? new("expireAfterSeconds", $"{BsonText.Grouped(expire.ToInt64())} ({Duration(expire.ToInt64())})", "VelaWarning") + : new("expireAfterSeconds", "—", "VelaTextMuted"); + + private ObjectFactSection StatsSection(ObjectItem item) + { + const string pending = "…"; + bool ready = item.HasStats; + var facts = new List + { + new(Loc[item.Kind == ObjectKind.Bucket ? "Obj_FactFiles" : "Detail_Documents"], + ready ? BsonText.Grouped(item.Count ?? 0) : pending), + new(Loc["Obj_FactDataStorage"], ready ? $"{BsonText.Bytes(item.DataSize ?? 0)} / {BsonText.Bytes(item.StorageSize ?? 0)}" : pending) + }; + if (ready && item.StorageSize is > 0 && item.DataSize is { } data) + { + double ratio = (double)data / item.StorageSize.Value; + facts.Add(new(Loc["Obj_FactCompression"], ratio.ToString("0.0", CultureInfo.InvariantCulture) + " ×", + ratio >= 1 ? "VelaStatusConnected" : "VelaTextSecondary")); + } + if (item.Kind == ObjectKind.TimeSeries) + { + facts.Add(new(Loc["Obj_FactBuckets"], item.BucketCount is { } n ? BsonText.Grouped(n) : ready ? "—" : pending)); + } + facts.Add(new(Loc["Obj_FactIndexes"], ready ? $"{item.IndexCount ?? 0} · {BsonText.Bytes(item.IndexSize ?? 0)}" : pending)); + return new(Loc["Obj_SecStats"], facts); + } + + /// 7776000 → 90 天(整天 / 整时 / 整分优先)。 + internal string Duration(long seconds) => seconds switch + { + > 0 when seconds % 86_400 == 0 => Loc.Format("Obj_Days", BsonText.Grouped(seconds / 86_400)), + > 0 when seconds % 3_600 == 0 => Loc.Format("Obj_Hours", BsonText.Grouped(seconds / 3_600)), + > 0 when seconds % 60 == 0 => Loc.Format("Obj_Minutes", BsonText.Grouped(seconds / 60)), + _ => Loc.Format("Obj_Seconds", BsonText.Grouped(seconds)) + }; + + private string Seconds(long seconds) => $"{BsonText.Grouped(seconds)} s · {Duration(seconds)}"; + + // ── DDL ────────────────────────────────────────────────────────────── + + /// 选中对象的结构脚本(桶 = files 与 chunks 两个集合的)。 + internal async Task BuildDdlAsync(ObjectItem item, CancellationToken cancellationToken) + { + MongoConnection connection = Workspace.Connection; + switch (item.Kind) + { + case ObjectKind.View: + return ObjectScripts.Ddl(item.Info!, []); + case ObjectKind.Bucket: + { + GridFsBucketInfo bucket = item.Bucket!; + var parts = new List(); + foreach (string name in new[] { bucket.FilesCollection, bucket.ChunksCollection }) + { + IReadOnlyList indexes = await connection.ListIndexesAsync(Database, name, cancellationToken).ConfigureAwait(true); + parts.Add(ObjectScripts.Ddl(new CollectionInfo(Database, name, CollectionKind.Collection, []), indexes)); + } + return string.Join("\n", parts); + } + default: + { + item.Indexes ??= await connection.ListIndexesAsync(Database, item.Name, cancellationToken).ConfigureAwait(true); + return ObjectScripts.Ddl(item.Info!, item.Indexes); + } + } + } + + // ── 权限 ───────────────────────────────────────────────────────────── + + private async Task LoadPrivilegesAsync(ObjectItem item, CancellationToken cancellationToken) + { + try + { + _connectionStatus ??= await Workspace.Connection.RunCommandAsync("admin", new BsonDocument + { + { "connectionStatus", 1 }, + { "showPrivileges", true } + }, cancellationToken).ConfigureAwait(true); + } + catch (MongoCommandException) + { + PrivilegeNotice = Loc["Obj_PrivUnavailable"]; + return; + } + BsonDocument auth = _connectionStatus.GetValue("authInfo", new BsonDocument()).AsBsonDocument; + BsonArray users = auth.GetValue("authenticatedUsers", new BsonArray()).AsBsonArray; + if (users.Count == 0) + { + PrivilegeNotice = Loc["Obj_PrivNoAuth"]; + return; + } + PrivilegeUser = string.Join(", ", users.Select(static u => $"{u["user"].AsString}@{u["db"].AsString}")); + foreach (BsonValue role in auth.GetValue("authenticatedUserRoles", new BsonArray()).AsBsonArray) + { + PrivilegeRoles.Add($"{role["role"].AsString}@{role["db"].AsString}"); + } + string collection = item.Bucket?.FilesCollection ?? item.Name; + IReadOnlyCollection actions = EffectiveActions(auth.GetValue("authenticatedUserPrivileges", new BsonArray()).AsBsonArray, Database, collection); + foreach (ObjectPrivilege action in actions + .Select(static a => new ObjectPrivilege(a, WriteActionNames.Contains(a))) + .OrderBy(static a => a.IsWrite) + .ThenBy(static a => a.Name, StringComparer.Ordinal)) + { + PrivilegeActions.Add(action); + } + if (actions.Count == 0) + { + PrivilegeNotice = Loc["Obj_PrivNone"]; + } + } + + /// + /// authenticatedUserPrivileges 里作用在 db.collection 上的动作并集。 + /// 资源匹配规则与服务器一致:anyResource 全中;db: "" 是任意库; + /// collection: "" 是任意**非系统**集合;cluster 资源与集合无关,不算。 + /// + internal static IReadOnlyCollection EffectiveActions(BsonArray privileges, string database, string collection) + { + var actions = new SortedSet(StringComparer.Ordinal); + foreach (BsonValue entry in privileges) + { + if (!entry.IsBsonDocument) + { + continue; + } + BsonDocument privilege = entry.AsBsonDocument; + BsonDocument resource = privilege.GetValue("resource", new BsonDocument()).AsBsonDocument; + bool matches; + if (resource.GetValue("anyResource", false).ToBoolean()) + { + matches = true; + } + else if (resource.TryGetValue("db", out BsonValue db) && db.IsString + && resource.TryGetValue("collection", out BsonValue coll) && coll.IsString) + { + bool dbMatches = db.AsString.Length == 0 || db.AsString == database; + bool collMatches = coll.AsString.Length == 0 + ? !collection.StartsWith("system.", StringComparison.Ordinal) + : coll.AsString == collection; + matches = dbMatches && collMatches; + } + else + { + matches = false; + } + if (!matches) + { + continue; + } + foreach (BsonValue action in privilege.GetValue("actions", new BsonArray()).AsBsonArray) + { + if (action.IsString) + { + actions.Add(action.AsString); + } + } + } + return actions; + } + + // ── 最近活动 ───────────────────────────────────────────────────────── + + private async Task FillActivityAsync(ObjectItem item, ObjectFact writes, ObjectFact last, ObjectFact created, CancellationToken cancellationToken) + { + try + { + (DateTime? newest, DateTime? oldest) = await ProbeTimesAsync(item, cancellationToken).ConfigureAwait(true); + SetTime(last, newest, recent: true); + SetTime(created, oldest, recent: false); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + SetTime(last, null, recent: true); + SetTime(created, null, recent: false); + } + try + { + double rate = await SampleWritesAsync(item, cancellationToken).ConfigureAwait(true); + writes.Value = Loc.Format("Obj_PerSecond", rate >= 10 + ? BsonText.Grouped((long)Math.Round(rate)) + : rate.ToString("0.#", CultureInfo.InvariantCulture)); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + // top 要 clusterMonitor 权限;业务账号多半没有 —— 那就不显示,而不是报错。 + writes.Value = "—"; + writes.Token = "VelaTextMuted"; + } + } + + private static void SetTime(ObjectFact fact, DateTime? utc, bool recent) + { + if (utc is not { } value) + { + fact.Value = "—"; + fact.Token = "VelaTextMuted"; + return; + } + DateTime local = value.ToLocalTime(); + fact.Value = recent && local.Date == DateTime.Now.Date + ? local.ToString("HH:mm:ss", CultureInfo.InvariantCulture) + : local.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture); + fact.Token = "VelaShellMagenta"; + } + + /// + /// 最近一份文档与最早一份文档的时间。全部走有索引的路径: + /// 普通集合按 _id(ObjectId 自带创建时间);时序集合读它的桶集合(按 _id 聚簇, + /// 桶的 control.min / max 就是时间范围)—— 在时序视图上按时间字段排序会把整个集合解包一遍。 + /// 一律带 3 秒 maxTimeMS:这是面板上的一行小字,不值得在大集合上拖住服务器。 + /// + private async Task<(DateTime? Newest, DateTime? Oldest)> ProbeTimesAsync(ObjectItem item, CancellationToken cancellationToken) + { + MongoConnection connection = Workspace.Connection; + var options = new FindOptions { MaxTime = TimeSpan.FromSeconds(3) }; + var newestFirst = new BsonDocument("_id", -1); + var oldestFirst = new BsonDocument("_id", 1); + switch (item.Kind) + { + case ObjectKind.TimeSeries: + { + string timeField = item.Info!.Options.GetValue("timeseries", new BsonDocument()).AsBsonDocument.GetValue("timeField", "ts").AsString; + try + { + IMongoCollection buckets = connection.Collection(Database, "system.buckets." + item.Name); + BsonDocument? newest = await First(buckets, newestFirst).ConfigureAwait(true); + BsonDocument? oldest = await First(buckets, oldestFirst).ConfigureAwait(true); + return (TimeAt(newest, "control", "max", timeField), TimeAt(oldest, "control", "min", timeField)); + } + catch (MongoCommandException) + { + // 读不了桶集合(权限只授到视图上):退回在时序视图上按时间字段排序,有超时兜底。 + IMongoCollection view = connection.Collection(Database, item.Name); + BsonDocument? newest = await First(view, new BsonDocument(timeField, -1)).ConfigureAwait(true); + BsonDocument? oldest = await First(view, new BsonDocument(timeField, 1)).ConfigureAwait(true); + return (TimeAt(newest, timeField), TimeAt(oldest, timeField)); + } + } + case ObjectKind.Bucket: + { + IMongoCollection files = connection.Collection(Database, item.Bucket!.FilesCollection); + BsonDocument? newest = await First(files, newestFirst).ConfigureAwait(true); + BsonDocument? oldest = await First(files, oldestFirst).ConfigureAwait(true); + return (TimeAt(newest, "uploadDate") ?? IdTime(newest), IdTime(oldest) ?? TimeAt(oldest, "uploadDate")); + } + default: + { + IMongoCollection collection = connection.Collection(Database, item.Name); + // 固定集合按插入序($natural)才是"最近";其余按 _id(ObjectId 单调增)。 + BsonDocument newestSort = item.Kind == ObjectKind.Capped ? new BsonDocument("$natural", -1) : newestFirst; + BsonDocument oldestSort = item.Kind == ObjectKind.Capped ? new BsonDocument("$natural", 1) : oldestFirst; + BsonDocument? newest = await First(collection, newestSort).ConfigureAwait(true); + BsonDocument? oldest = await First(collection, oldestSort).ConfigureAwait(true); + return (IdTime(newest), IdTime(oldest)); + } + } + + async Task First(IMongoCollection source, BsonDocument sort) => + await source.Find(FilterDefinition.Empty, options).Sort(sort).Limit(1) + .FirstOrDefaultAsync(cancellationToken).ConfigureAwait(true); + } + + private static DateTime? IdTime(BsonDocument? doc) => + doc?.GetValue("_id", BsonNull.Value) is BsonObjectId id ? id.Value.CreationTime : null; + + private static DateTime? TimeAt(BsonDocument? doc, params string[] path) + { + BsonValue? value = doc; + foreach (string step in path) + { + if (value is not BsonDocument d || !d.TryGetValue(step, out value)) + { + return null; + } + } + return value is BsonDateTime date ? date.ToUniversalTime() : null; + } + + /// + /// 写入速率:top 两次采样(间隔 1 秒)求差。top 的计数是服务器启动以来的累计值, + /// 单次读数没有意义,差分才是"现在每秒写多少"。 + /// + private async Task SampleWritesAsync(ObjectItem item, CancellationToken cancellationToken) + { + string ns = item.Bucket is { } bucket ? $"{Database}.{bucket.FilesCollection}" : item.Namespace; + long first = await TopWritesAsync(ns, cancellationToken).ConfigureAwait(true); + var watch = Stopwatch.StartNew(); + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken).ConfigureAwait(true); + long second = await TopWritesAsync(ns, cancellationToken).ConfigureAwait(true); + return Math.Max(0, second - first) / Math.Max(0.001, watch.Elapsed.TotalSeconds); + } + + private async Task TopWritesAsync(string ns, CancellationToken cancellationToken) + { + BsonDocument top = await Workspace.Connection.RunCommandAsync("admin", new BsonDocument("top", 1), cancellationToken).ConfigureAwait(true); + if (top.GetValue("totals", new BsonDocument()).AsBsonDocument.TryGetValue(ns, out BsonValue entry) && entry.IsBsonDocument) + { + return new[] { "insert", "update", "remove" } + .Sum(op => entry.AsBsonDocument.TryGetValue(op, out BsonValue counter) && counter.IsBsonDocument + ? counter.AsBsonDocument.GetValue("count", 0).ToInt64() + : 0); + } + return 0; + } + + // ── 动作 ───────────────────────────────────────────────────────────── + + /// 打开选中的对象(双击 / 回车 / 主按钮)。 + internal void OpenSelected() + { + switch (_selected) + { + case null: + return; + case { Kind: ObjectKind.Bucket } bucket: + Workspace.OpenGridFs(Database, bucket.Name); + return; + default: + Workspace.OpenCollection(Database, _selected.Name); + return; + } + } + + private void Design(DesignPage page) + { + if (_selected is { IsCollection: true } item) + { + Workspace.OpenDesign(Database, item.Name, page); + } + } + + private void OpenPipeline() + { + if (_selected is { Kind: not ObjectKind.Bucket } item) + { + Workspace.OpenPipeline(Database, item.Name); + } + } + + private void OpenQuery() => + Workspace.OpenQuery(Database, _selected is { Kind: not ObjectKind.Bucket } item + ? $"{ObjectScripts.CollectionRef(item.Name)}.find({{}}).limit(50)" + : null); + + private void Import() + { + if (_selected is { IsCollection: true } item) + { + Workspace.ShowDialog(new ImportWizardViewModel(Workspace, Database, item.Name)); + } + } + + private void Export() => + Workspace.ShowDialog(new ExportWizardViewModel(Workspace, Database, _selected is { Kind: not ObjectKind.Bucket } item ? item.Name : null, null, null)); + + private async Task CopyStructureAsync() + { + if (_selected is not { } item) + { + return; + } + try + { + string script = await BuildDdlAsync(item, CancellationToken.None).ConfigureAwait(true); + await Workspace.CopyAsync(script).ConfigureAwait(true); + Workspace.Toast(new() + { + Title = Loc.Format("Obj_CopiedDdl", item.DisplayName), + Detail = Loc.Format("Obj_CopiedDdlDetail", script.Count(static c => c == '\n') + 1), + Kind = ToastKind.Success + }); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Workspace.Toast(new() { Title = Loc.Format("Common_Failed", MongoConnector.Describe(ex)), Kind = ToastKind.Error }); + } + } + + private async Task CopyDdlAsync() + { + await Workspace.CopyAsync(DdlText).ConfigureAwait(true); + Workspace.Toast(new() { Title = Loc["Common_Copied"], Kind = ToastKind.Success }); + } + + /// 删除选中的集合 / 视图 / 桶:先过写护栏,再确认(有数据或写前确认时手打名称),再删。 + private async Task DropSelectedAsync() + { + if (_selected is not { } item || !Workspace.EnsureWritable(Database)) + { + return; + } + await EnsureStatsAsync(item).ConfigureAwait(true); + var facts = new List(); + if (!item.IsView && item.HasStats) + { + facts.Add(new(Loc[item.Kind == ObjectKind.Bucket ? "Obj_FactFiles" : "Detail_Documents"], BsonText.Grouped(item.Count ?? 0))); + facts.Add(new(Loc["Confirm_DataSize"], BsonText.Bytes(item.DataSize ?? 0))); + } + string title = item.Kind switch + { + ObjectKind.View => Loc["Confirm_DropViewTitle"], + ObjectKind.Bucket => Loc["Obj_DropBucketTitle"], + _ => Loc["Confirm_DropTitle"] + }; + string message = item.Kind switch + { + ObjectKind.View => Loc.Format("Confirm_DropViewBody", item.Namespace), + ObjectKind.Bucket => Loc.Format("Obj_DropBucketBody", item.Namespace), + _ => Loc.Format("Confirm_DropBody", item.Namespace, item.IndexCount ?? 0) + }; + bool hasData = (item.Count ?? 0) > 0; + bool confirmed = await Workspace.ConfirmAsync(new() + { + Title = title, + Message = message, + ConfirmLabel = title, + Facts = facts, + // 与对象树的删除同口径:写前确认开着,或这次删的东西里有数据,都要手打名称。 + TypeToConfirm = Workspace.Guard.ConfirmWrites || hasData ? item.Name : null, + AsideLabel = item.IsCollection ? Loc["Confirm_BackupFirst"] : null, + AsideAction = item.IsCollection + ? () => Workspace.ShowDialog(new ExportWizardViewModel(Workspace, Database, item.Name, null, ExportFormat.BsonDump)) + : null + }).ConfigureAwait(true); + if (!confirmed) + { + return; + } + try + { + IMongoDatabase database = Workspace.Connection.Database(Database); + if (item.Bucket is { } bucket) + { + await database.DropCollectionAsync(bucket.FilesCollection).ConfigureAwait(true); + await database.DropCollectionAsync(bucket.ChunksCollection).ConfigureAwait(true); + } + else + { + await database.DropCollectionAsync(item.Name).ConfigureAwait(true); + } + Workspace.Toast(new() { Title = Loc.Format("Confirm_Dropped", item.Namespace), Kind = ToastKind.Success }); + CloseTabsOf(item); + SelectedItem = null; + await Workspace.RefreshTreeAsync(Database).ConfigureAwait(true); + await ReloadAsync().ConfigureAwait(true); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Workspace.Toast(new() { Title = Loc.Format("Common_Failed", MongoConnector.Describe(ex)), Kind = ToastKind.Error }); + } + } + + /// + /// 删掉之后,还开着的那个集合的网格 / 设计 / 管道标签都指向一个不存在的东西了 —— 关掉它们 + /// (外壳从对象树删除时也这么做;只关本连接的,别的连接里同名的集合不相干)。 + /// + private void CloseTabsOf(ObjectItem item) => + (Workspace as MongoSession)?.CloseTabsOf(item.Namespace, except: this); + + /// 清空集合(deleteMany({})):索引与验证规则保留。 + private async Task EmptySelectedAsync() + { + if (_selected is not { Kind: ObjectKind.Collection or ObjectKind.TimeSeries or ObjectKind.Clustered } item + || !Workspace.EnsureWritable(Database)) + { + return; + } + try + { + long? count = await Workspace.Connection.EstimatedCountAsync(Database, item.Name).ConfigureAwait(true); + if (!await Workspace.ConfirmAsync(new() + { + Title = Loc["Tree_EmptyTitle"], + Message = Loc.Format("Tree_EmptyBody", item.Namespace, BsonText.Grouped(count ?? 0)), + ConfirmLabel = Loc["Tree_EmptyConfirm"], + IconKey = "Mongo.eraser", + TypeToConfirm = Workspace.Guard.ConfirmWrites ? item.Name : null + }).ConfigureAwait(true)) + { + return; + } + DeleteResult result = await Workspace.Connection.Collection(Database, item.Name) + .DeleteManyAsync(FilterDefinition.Empty).ConfigureAwait(true); + Workspace.Toast(new() { Title = Loc.Format("Tree_EmptyDone", BsonText.Grouped(result.DeletedCount)), Kind = ToastKind.Success }); + _statsTasks.TryRemove(item, out _); + await EnsureStatsAsync(item).ConfigureAwait(true); + if (ReferenceEquals(_selected, item)) + { + _ = LoadDetailAsync(item); + } + await Workspace.RefreshTreeAsync(Database).ConfigureAwait(true); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Workspace.Toast(new() { Title = Loc.Format("Common_Failed", MongoConnector.Describe(ex)), Kind = ToastKind.Error }); + } + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Objects/ObjectsTabViewModel.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Objects/ObjectsTabViewModel.cs new file mode 100644 index 0000000..6408c4f --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Objects/ObjectsTabViewModel.cs @@ -0,0 +1,621 @@ +using System.Collections.Concurrent; +using System.Collections.ObjectModel; +using MongoDB.Bson; +using MongoDB.Driver; +using VelaShell.Plugin.Mongo.Bson; +using VelaShell.Plugin.Mongo.Core; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 对象列表(设计稿 12,Navicat 的对象页):一个库里的集合、视图、时序集合与 GridFS 桶, +/// 带统计列、过滤芯片、三种视图与右侧详情面板。 +/// +/// 加载分两拍:listCollections 一回来就先出整张表(名字、种类、验证规则都在里面), +/// 每个集合的统计再并发(限流 6)去取,取到哪行哪行补上数字 —— 几十个集合的库, +/// 第一屏不该等最慢的那个 $collStats。 +/// +/// +internal sealed partial class ObjectsTabViewModel : WorkspaceTab +{ + private readonly List _all = []; + private readonly ConcurrentDictionary _statsTasks = new(); + private ObjectFilter _filter; + private string _search = ""; + private ObjectViewMode _viewMode = ObjectViewMode.Details; + private ObjectItem? _selected; + private string _sortColumn = "DataSize"; + private bool _sortDescending = true; + private bool _isLoading; + private bool _rebuilding; + private bool _loaded; + private string _errorText = ""; + private DateTime _loadedAt; + private CancellationTokenSource? _load; + + /// 构造。 + /// 外壳服务。 + /// 库名。 + /// 初始过滤芯片(工具栏「视图」按钮打开时直接落在「视图」上)。 + public ObjectsTabViewModel(IMongoWorkspace workspace, string database, ObjectFilter filter) + : base(workspace) + { + Database = database; + _filter = filter; + Title = workspace.Loc["Nav_Objects"]; + Scope = "· " + database; + + OpenCommand = new(OpenSelected, () => _selected is not null); + DesignCommand = new(() => Design(DesignPage.Indexes), () => _selected?.IsCollection == true); + PipelineCommand = new(OpenPipeline, () => _selected is { Kind: not ObjectKind.Bucket }); + QueryCommand = new(OpenQuery); + NewCollectionCommand = new(() => Workspace.ShowDialog(new NewCollectionDialogViewModel(Workspace, Database))); + DropCommand = new(DropSelectedAsync, () => _selected is not null); + EmptyCommand = new(EmptySelectedAsync, () => _selected is { Kind: ObjectKind.Collection or ObjectKind.TimeSeries or ObjectKind.Clustered }); + CopyStructureCommand = new(CopyStructureAsync, () => _selected is not null); + CopyNameCommand = new(() => _selected is { } s ? Workspace.CopyAsync(s.Name) : Task.CompletedTask, () => _selected is not null); + ImportCommand = new(Import, () => _selected?.IsCollection == true); + ExportCommand = new(Export); + SortCommand = new(SortBy); + CopyDdlCommand = new(CopyDdlAsync, () => DdlText.Length > 0); + + ObjectsChanged.Changed += OnObjectsChanged; + } + + /// 库名。 + public string Database { get; } + + /// + public override TabKind Kind => TabKind.Objects; + + /// + public override string Key => $"objects:{Database}"; + + /// + public override string IconKey => "Mongo.layout-grid"; + + /// + public override string IconToken => "VelaTextTertiary"; + + // ── 过滤 / 搜索 / 视图 / 排序 ────────────────────────────────────────── + + /// 过滤芯片(外壳的「集合」「视图」「GridFS」按钮也会改它)。 + public ObjectFilter Filter + { + get => _filter; + set + { + if (!SetProperty(ref _filter, value)) + { + return; + } + RaisePropertiesChanged(nameof(FilterAll), nameof(FilterCollections), nameof(FilterViews), nameof(FilterTimeSeries), nameof(FilterGridFs)); + Rebuild(); + // 芯片是在标签内部改的时候,工具栏上「集合 / 视图」哪个亮着要跟着变。 + (Workspace as MongoSession)?.Shell.OnTabToolChanged(); + } + } + + /// 芯片:全部。 + public bool FilterAll { get => _filter == ObjectFilter.All; set { if (value) { Filter = ObjectFilter.All; } } } + + /// 芯片:集合。 + public bool FilterCollections { get => _filter == ObjectFilter.Collections; set { if (value) { Filter = ObjectFilter.Collections; } } } + + /// 芯片:视图。 + public bool FilterViews { get => _filter == ObjectFilter.Views; set { if (value) { Filter = ObjectFilter.Views; } } } + + /// 芯片:时序。 + public bool FilterTimeSeries { get => _filter == ObjectFilter.TimeSeries; set { if (value) { Filter = ObjectFilter.TimeSeries; } } } + + /// 芯片:GridFS。 + public bool FilterGridFs { get => _filter == ObjectFilter.GridFs; set { if (value) { Filter = ObjectFilter.GridFs; } } } + + /// 右上角的「搜索对象」。 + public string SearchText + { + get => _search; + set + { + if (SetProperty(ref _search, value)) + { + Rebuild(); + } + } + } + + /// 视图:网格大图标 / 列表 / 详情。 + public ObjectViewMode ViewMode + { + get => _viewMode; + set + { + if (SetProperty(ref _viewMode, value)) + { + RaisePropertiesChanged(nameof(IsGridMode), nameof(IsListMode), nameof(IsDetailsMode)); + } + } + } + + /// 网格大图标。 + public bool IsGridMode { get => _viewMode == ObjectViewMode.Grid; set { if (value) { ViewMode = ObjectViewMode.Grid; } } } + + /// 列表。 + public bool IsListMode { get => _viewMode == ObjectViewMode.List; set { if (value) { ViewMode = ObjectViewMode.List; } } } + + /// 详情。 + public bool IsDetailsMode { get => _viewMode == ObjectViewMode.Details; set { if (value) { ViewMode = ObjectViewMode.Details; } } } + + /// + /// 排序列:Name / Type / Count / AvgSize / DataSize / StorageSize / + /// Indexes / IndexSize / Validation。默认按数据大小倒序(设计稿 12 列头上那枚 ↓)。 + /// + public string SortColumn + { + get => _sortColumn; + private set => SetProperty(ref _sortColumn, value); + } + + /// 倒序。 + public bool SortDescending + { + get => _sortDescending; + private set + { + if (SetProperty(ref _sortDescending, value)) + { + RaisePropertyChanged(nameof(SortIconKey)); + } + } + } + + /// 列头上那枚箭头。 + public string SortIconKey => _sortDescending ? "Mongo.arrow-down" : "Mongo.arrow-up"; + + // ── 列表 ───────────────────────────────────────────────────────────── + + /// 过滤、搜索、排序之后的行。 + public ObservableCollection Items { get; } = []; + + /// 芯片上的计数:全部。 + public int AllCount => _all.Count; + + /// 芯片上的计数:集合(含时序、固定、聚簇 —— 与对象树的「集合」分组同口径)。 + public int CollectionCount => _all.Count(static i => i.IsCollection); + + /// 芯片上的计数:视图。 + public int ViewCount => _all.Count(static i => i.Kind == ObjectKind.View); + + /// 芯片上的计数:时序。 + public int TimeSeriesCount => _all.Count(static i => i.Kind == ObjectKind.TimeSeries); + + /// 芯片上的计数:GridFS 桶。 + public int GridFsCount => _all.Count(static i => i.Kind == ObjectKind.Bucket); + + /// 正在列集合。 + public bool IsLoading + { + get => _isLoading; + private set + { + if (SetProperty(ref _isLoading, value)) + { + RaisePropertiesChanged(nameof(ShowEmpty), nameof(EmptyText)); + } + } + } + + /// 列集合失败的原因(列表区域显示它而不是一张空表)。 + public string ErrorText + { + get => _errorText; + private set + { + if (SetProperty(ref _errorText, value)) + { + RaisePropertiesChanged(nameof(ShowEmpty), nameof(EmptyText)); + } + } + } + + /// 列表区域显示空态。 + public bool ShowEmpty => Items.Count == 0; + + /// 空态文字:加载中 / 失败原因 / 搜索无结果 / 空库。 + public string EmptyText => + _isLoading ? Loc["Common_Loading"] + : _errorText.Length > 0 ? _errorText + : _all.Count > 0 ? Loc["Obj_NoMatch"] + : Loc.Format("Obj_EmptyDatabase", Database); + + /// 选中的对象。 + public ObjectItem? SelectedItem + { + get => _selected; + set + { + // 重建列表时 ListBox 会把选中项推回 null —— 那不是用户的意图,忽略。 + if (_rebuilding && value is null) + { + return; + } + if (ReferenceEquals(_selected, value)) + { + return; + } + if (_selected is not null) + { + _selected.IsSelected = false; + } + _selected = value; + if (value is not null) + { + value.IsSelected = true; + } + RaisePropertyChanged(); + RaisePropertiesChanged(nameof(HasSelection), nameof(DetailIconKey), nameof(DetailIconToken), nameof(DetailTitle), + nameof(DetailSubtitle), nameof(OpenLabel), nameof(CanDesign), nameof(CanPipeline), nameof(PrivilegeActionsTitle)); + RaiseCommandStates(); + UpdateStatus(); + _ = LoadDetailAsync(value); + } + } + + /// 有选中项(详情面板显示内容,否则显示提示)。 + public bool HasSelection => _selected is not null; + + // ── 命令 ───────────────────────────────────────────────────────────── + + /// 打开集合 / 视图 / 桶。 + public RelayCommand OpenCommand { get; } + + /// 设计集合。 + public RelayCommand DesignCommand { get; } + + /// 在管道构建器中打开。 + public RelayCommand PipelineCommand { get; } + + /// 新建查询(右键菜单)。 + public RelayCommand QueryCommand { get; } + + /// 新建集合。 + public RelayCommand NewCollectionCommand { get; } + + /// 删除集合 / 视图 / 桶。 + public AsyncCommand DropCommand { get; } + + /// 清空集合。 + public AsyncCommand EmptyCommand { get; } + + /// 复制结构(mongosh 脚本)。 + public AsyncCommand CopyStructureCommand { get; } + + /// 复制名称。 + public AsyncCommand CopyNameCommand { get; } + + /// 导入。 + public RelayCommand ImportCommand { get; } + + /// 导出。 + public RelayCommand ExportCommand { get; } + + /// 点列头排序。 + public RelayCommand SortCommand { get; } + + /// 详情面板 DDL 页的「复制」。 + public AsyncCommand CopyDdlCommand { get; } + + private void RaiseCommandStates() + { + OpenCommand.RaiseCanExecuteChanged(); + DesignCommand.RaiseCanExecuteChanged(); + PipelineCommand.RaiseCanExecuteChanged(); + DropCommand.RaiseCanExecuteChanged(); + EmptyCommand.RaiseCanExecuteChanged(); + CopyStructureCommand.RaiseCanExecuteChanged(); + CopyNameCommand.RaiseCanExecuteChanged(); + ImportCommand.RaiseCanExecuteChanged(); + } + + // ── 加载 ───────────────────────────────────────────────────────────── + + /// + public override Task LoadAsync() => ReloadAsync(); + + /// + public override Task RefreshAsync() => ReloadAsync(); + + /// + protected override void OnActivated() + { + // 切回来时数据可能已经过时(别的标签里建删了集合);半分钟内的不重拉,免得来回切标签时闪。 + if (_loaded && !_isLoading && DateTime.UtcNow - _loadedAt > TimeSpan.FromSeconds(30)) + { + _ = ReloadAsync(); + } + UpdateStatus(); + } + + private void OnObjectsChanged(IMongoWorkspace workspace, string database) + { + if (ReferenceEquals(workspace, Workspace) && database == Database) + { + _ = ReloadAsync(); + } + } + + /// 重新列集合并补统计(保留选中项与过滤条件)。 + public async Task ReloadAsync() + { + // 只取消不释放:上一轮的统计请求还挂在旧令牌上(见 EnsureStatsAsync)。 + _load?.Cancel(); + var cts = new CancellationTokenSource(); + _load = cts; + IsLoading = true; + ErrorText = ""; + IReadOnlyList list; + try + { + list = await Workspace.Connection.ListCollectionsAsync(Database, cts.Token).ConfigureAwait(true); + } + catch (OperationCanceledException) + { + return; + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + ErrorText = Loc.Format("Common_Failed", MongoConnector.Describe(ex)); + IsLoading = false; + return; + } + if (cts.IsCancellationRequested) + { + return; + } + + ObjectItem? keep = _selected; + // 按(种类, 名字)对号:桶 fs 与一个恰好也叫 fs 的集合可以同时存在。 + var previous = new Dictionary<(ObjectKind, string), ObjectItem>(); + foreach (ObjectItem old in _all) + { + previous.TryAdd((old.Kind, old.Name), old); + } + List fresh = BuildItems(Database, list, Loc); + foreach (ObjectItem item in fresh) + { + if (previous.TryGetValue((item.Kind, item.Name), out ObjectItem? old)) + { + item.CarryOver(old); + } + } + _all.Clear(); + _all.AddRange(fresh); + _statsTasks.Clear(); + _loaded = true; + _loadedAt = DateTime.UtcNow; + RaisePropertiesChanged(nameof(AllCount), nameof(CollectionCount), nameof(ViewCount), nameof(TimeSeriesCount), nameof(GridFsCount)); + _rebuilding = true; + try + { + Rebuild(); + } + finally + { + _rebuilding = false; + } + IsLoading = false; + SelectedItem = keep is null ? null : Items.FirstOrDefault(i => i.Name == keep.Name && i.Kind == keep.Kind); + UpdateStatus(); + await FillStatsAsync(cts.Token).ConfigureAwait(true); + } + + /// + /// listCollections → 对象行:系统集合不列(system.views / system.buckets.* 是实现细节), + /// GridFS 的 files / chunks 合成一个桶。 + /// + internal static List BuildItems(string database, IReadOnlyList list, Loc loc) + { + IReadOnlyList buckets = MongoConnection.FindBuckets(database, list); + HashSet bucketCollections = [.. buckets.SelectMany(static b => new[] { b.FilesCollection, b.ChunksCollection })]; + List items = + [ + .. list.Where(c => c.Kind != CollectionKind.System && !bucketCollections.Contains(c.Name)).Select(c => new ObjectItem(c, loc)), + .. buckets.Select(b => new ObjectItem(b, loc)) + ]; + return items; + } + + /// 并发取每一行的统计(视图没有统计)。全部到齐后按数字列重排一次 —— 边到边排会让行在眼前乱跳。 + private async Task FillStatsAsync(CancellationToken cancellationToken) + { + using var gate = new SemaphoreSlim(6); + List targets = [.. _all.Where(static i => !i.IsView)]; + try + { + await Task.WhenAll(targets.Select(async item => + { + await gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await EnsureStatsAsync(item).ConfigureAwait(false); + } + finally + { + gate.Release(); + } + })).ConfigureAwait(true); + } + catch (OperationCanceledException) + { + return; + } + if (!cancellationToken.IsCancellationRequested && _sortColumn is not ("Name" or "Type" or "Validation")) + { + RebuildKeepingSelection(); + } + } + + /// + /// 某一行的统计(同一行只取一次;详情面板与列表共用这一份)。挂在**列表加载**的令牌上而不是调用方的: + /// 详情面板切走时取消的只是它自己的等待,统计照样取完 —— 否则那一行就永远是空白了。 + /// + internal Task EnsureStatsAsync(ObjectItem item) + { + CancellationToken token = _load?.Token ?? CancellationToken.None; + return _statsTasks.GetOrAdd(item, i => LoadStatsAsync(i, token)); + } + + private async Task LoadStatsAsync(ObjectItem item, CancellationToken cancellationToken) + { + MongoConnection connection = Workspace.Connection; + try + { + switch (item.Kind) + { + case ObjectKind.View: + return; + case ObjectKind.Bucket: + { + GridFsBucketInfo bucket = item.Bucket!; + CollectionStats files = await connection.GetStatsAsync(Database, bucket.FilesCollection, cancellationToken).ConfigureAwait(false); + CollectionStats chunks = await connection.GetStatsAsync(Database, bucket.ChunksCollection, cancellationToken).ConfigureAwait(false); + item.SetBucketStats(files, chunks); + return; + } + default: + { + CollectionStats stats = await connection.GetStatsAsync(Database, item.Name, cancellationToken).ConfigureAwait(false); + long? estimated = item.Kind == ObjectKind.TimeSeries && stats.Count == 0 + ? await connection.EstimatedCountAsync(Database, item.Name, cancellationToken).ConfigureAwait(false) + : null; + item.SetStats(stats, estimated); + if (item.Kind == ObjectKind.Collection) + { + item.Indexes = await connection.ListIndexesAsync(Database, item.Name, cancellationToken).ConfigureAwait(false); + item.IsTtl = MongoConnection.HasTtl(item.Indexes); + } + return; + } + } + } + catch (Exception ex) when (ex is MongoException or TimeoutException or OperationCanceledException) + { + // 某一行没权限读统计:那一行留空,不影响其余行,也不值得弹提示。 + } + } + + // ── 过滤与排序 ─────────────────────────────────────────────────────── + + private bool Matches(ObjectItem item) => + _filter switch + { + ObjectFilter.Collections => item.IsCollection, + ObjectFilter.Views => item.Kind == ObjectKind.View, + ObjectFilter.TimeSeries => item.Kind == ObjectKind.TimeSeries, + ObjectFilter.GridFs => item.Kind == ObjectKind.Bucket, + _ => true + } + && (_search.Trim().Length == 0 || item.DisplayName.Contains(_search.Trim(), StringComparison.OrdinalIgnoreCase)); + + /// 按过滤、搜索与排序重算可见行。 + private void Rebuild() + { + IEnumerable rows = _all.Where(Matches); + // 视图与还没取到统计的行在数字列排序里一律沉底,不管正序倒序。 + IComparer comparer = Comparer.Default; + string column = _sortColumn; + List sorted = _sortDescending + ? [.. rows.OrderBy(i => IsBlank(i, column)).ThenByDescending(i => i.SortKey(column), comparer).ThenBy(static i => i.DisplayName, StringComparer.Ordinal)] + : [.. rows.OrderBy(i => IsBlank(i, column)).ThenBy(i => i.SortKey(column), comparer).ThenBy(static i => i.DisplayName, StringComparer.Ordinal)]; + if (sorted.SequenceEqual(Items)) + { + return; + } + ObjectItem? selected = _selected; + bool wasRebuilding = _rebuilding; + _rebuilding = true; + try + { + Items.Clear(); + foreach (ObjectItem row in sorted) + { + Items.Add(row); + } + } + finally + { + _rebuilding = wasRebuilding; + } + RaisePropertiesChanged(nameof(ShowEmpty), nameof(EmptyText)); + if (selected is not null && !sorted.Contains(selected)) + { + SelectedItem = null; + } + else + { + // ListBox 在清空时丢了选中项;把它推回去。 + RaisePropertyChanged(nameof(SelectedItem)); + } + UpdateStatus(); + } + + private void RebuildKeepingSelection() + { + _rebuilding = true; + try + { + Rebuild(); + } + finally + { + _rebuilding = false; + } + } + + private static bool IsBlank(ObjectItem item, string column) => + column is not ("Name" or "Type" or "Validation") && (item.IsView || !item.HasStats); + + private void SortBy(string column) + { + if (_sortColumn == column) + { + SortDescending = !_sortDescending; + } + else + { + SortColumn = column; + // 名字与类型默认正序,数字列默认倒序(最大的在最上面才是看这张表的目的)。 + SortDescending = column is not ("Name" or "Type" or "Validation"); + } + RebuildKeepingSelection(); + } + + // ── 状态行 ─────────────────────────────────────────────────────────── + + private void UpdateStatus() => + StatusText = _selected is { } s + ? Loc.Format("Obj_StatusSelected", s.DisplayName, s.KindName, Items.Count) + : Loc.Format("Obj_StatusCount", Items.Count); + + /// + public override void Dispose() + { + ObjectsChanged.Changed -= OnObjectsChanged; + _load?.Cancel(); + _detail?.Cancel(); + base.Dispose(); + } +} + +/// +/// "这个库里的对象变了"的广播:新建集合对话框建完之后,同一工作台里开着的对象列表据此重拉。 +/// 对话框与标签页之间不互相引用(见 的约定),所以走一个事件。 +/// +internal static class ObjectsChanged +{ + /// 某个工作台的某个库里建删了对象。 + public static event Action? Changed; + + /// 广播一次。 + public static void Raise(IMongoWorkspace workspace, string database) => Changed?.Invoke(workspace, database); +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineCode.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineCode.cs new file mode 100644 index 0000000..a4620a1 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineCode.cs @@ -0,0 +1,480 @@ +using System.Globalization; +using System.Text; +using MongoDB.Bson; +using VelaShell.Plugin.Mongo.Bson; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 「导出为代码」的目标语言。 +internal enum PipelineCodeLanguage +{ + /// mongosh。 + Mongosh, + + /// C#(MongoDB.Driver)。 + CSharp, + + /// Python(PyMongo)。 + Python, + + /// Node.js(mongodb 驱动)。 + Node, + + /// Java(mongodb-driver-sync)。 + Java +} + +/// +/// 把管道写成各语言驱动的代码(设计稿 04 工具栏「导出为代码」)。 +/// +/// 每种语言自己写一遍值的字面量,而不是统一吐一段 JSON 字符串再 Parse: +/// 那样导出的代码能跑,但 ISODate、NumberDecimal 这些类型信息全压在字符串里, +/// 用户要改一个日期还得去扩展 JSON 里找 $date。按语言写出 new DateTime(…)、 +/// Decimal128("…"),导出来的就是能直接维护的代码。 +/// +/// +internal static class PipelineCode +{ + /// 超过这个宽度的容器折行。 + private const int Width = 80; + + /// 生成代码。 + /// 语言。 + /// 库名。 + /// 集合名。 + /// 启用的阶段。 + /// 是否带 allowDiskUse。 + public static string Generate(PipelineCodeLanguage language, string database, string collection, IReadOnlyList stages, bool allowDiskUse) => + language switch + { + PipelineCodeLanguage.CSharp => CSharp(database, collection, stages, allowDiskUse), + PipelineCodeLanguage.Python => Python(database, collection, stages, allowDiskUse), + PipelineCodeLanguage.Node => Node(database, collection, stages, allowDiskUse), + PipelineCodeLanguage.Java => Java(database, collection, stages, allowDiskUse), + _ => Mongosh(database, collection, stages, allowDiskUse) + }; + + // ── mongosh ───────────────────────────────────────────────────────────── + + private static string Mongosh(string database, string collection, IReadOnlyList stages, bool allowDiskUse) + { + var b = new StringBuilder(); + b.Append("use(").Append(BsonText.Quote(database)).Append(");\n\n"); + b.Append("db.").Append(MongoWorkspaceViewModel.ShellCollectionRef(collection)).Append(".aggregate("); + b.Append(List(stages.Select(s => Js(s, 1, shell: true)), "[", "]", 0, " ")); + if (allowDiskUse) + { + b.Append(", { allowDiskUse: true }"); + } + return b.Append(");\n").ToString(); + } + + // ── Node.js ───────────────────────────────────────────────────────────── + + private static string Node(string database, string collection, IReadOnlyList stages, bool allowDiskUse) + { + string body = List(stages.Select(s => Js(s, 2, shell: false)), "[", "]", 1, " "); + var imports = new List { "MongoClient" }; + foreach ((string token, string name) in new[] + { + ("new ObjectId(", "ObjectId"), ("Decimal128.", "Decimal128"), ("Long.", "Long"), + ("new UUID(", "UUID"), ("Binary.", "Binary"), ("new Timestamp(", "Timestamp"), ("new MinKey(", "MinKey"), ("new MaxKey(", "MaxKey") + }) + { + if (body.Contains(token, StringComparison.Ordinal)) + { + imports.Add(name); + } + } + var b = new StringBuilder(); + b.Append("const { ").Append(string.Join(", ", imports)).Append(" } = require(\"mongodb\");\n\n"); + b.Append("const client = new MongoClient(process.env.MONGODB_URI ?? \"mongodb://localhost:27017\");\n\n"); + b.Append("async function run() {\n"); + b.Append(" const pipeline = ").Append(body).Append(";\n"); + b.Append(" const cursor = client.db(").Append(BsonText.Quote(database)).Append(").collection(").Append(BsonText.Quote(collection)) + .Append(").aggregate(pipeline").Append(allowDiskUse ? ", { allowDiskUse: true }" : "").Append(");\n"); + b.Append(" for await (const doc of cursor) {\n console.log(doc);\n }\n}\n\n"); + b.Append("run().finally(() => client.close());\n"); + return b.ToString(); + } + + /// JavaScript 字面量(mongosh 与 Node 只差在类型构造器的写法)。 + private static string Js(BsonValue value, int depth, bool shell) + { + switch (value) + { + case BsonDocument doc: + { + var fields = doc.Select(e => BsonText.FieldName(e.Name) + ": " + Js(e.Value, depth + 1, shell)).ToList(); + return Container(fields, "{ ", " }", "{}", depth, " "); + } + case BsonArray array: + { + var items = array.Select(v => Js(v, depth + 1, shell)).ToList(); + return Container(items, "[ ", " ]", "[]", depth, " "); + } + } + if (shell) + { + return PipelineText.Flat(value); + } + return value.BsonType switch + { + BsonType.ObjectId => $"new ObjectId(\"{value.AsObjectId}\")", + BsonType.DateTime => $"new Date(\"{BsonText.IsoDate(value)}\")", + BsonType.Decimal128 => $"Decimal128.fromString(\"{value.AsDecimal128}\")", + BsonType.Int64 => $"Long.fromString(\"{value.AsInt64.ToString(CultureInfo.InvariantCulture)}\")", + BsonType.Binary when value.AsBsonBinaryData.SubType is BsonBinarySubType.UuidStandard => + $"new UUID(\"{value.AsBsonBinaryData.ToGuid()}\")", + BsonType.Binary => $"Binary.createFromBase64(\"{Convert.ToBase64String(value.AsBsonBinaryData.Bytes)}\", {(int)value.AsBsonBinaryData.SubType})", + BsonType.Timestamp => $"new Timestamp({{ t: {value.AsBsonTimestamp.Timestamp}, i: {value.AsBsonTimestamp.Increment} }})", + BsonType.MinKey => "new MinKey()", + BsonType.MaxKey => "new MaxKey()", + _ => BsonText.Literal(value) + }; + } + + // ── Python ────────────────────────────────────────────────────────────── + + private static string Python(string database, string collection, IReadOnlyList stages, bool allowDiskUse) + { + string body = List(stages.Select(s => Py(s, 1)), "[", "]", 0, " "); + var bsonImports = new List(); + foreach ((string token, string name) in new[] + { + ("ObjectId(", "ObjectId"), ("Decimal128(", "Decimal128"), ("Int64(", "Int64"), ("Regex(", "Regex"), + ("Binary(", "Binary"), ("Timestamp(", "Timestamp"), ("MinKey(", "MinKey"), ("MaxKey(", "MaxKey") + }) + { + if (body.Contains(token, StringComparison.Ordinal)) + { + bsonImports.Add(name); + } + } + var b = new StringBuilder("import os\n"); + if (body.Contains("datetime(", StringComparison.Ordinal)) + { + b.Append("from datetime import datetime, timezone\n"); + } + if (body.Contains("uuid.UUID(", StringComparison.Ordinal)) + { + b.Append("import uuid\n"); + } + if (body.Contains("base64.", StringComparison.Ordinal)) + { + b.Append("import base64\n"); + } + b.Append('\n'); + if (bsonImports.Count > 0) + { + b.Append("from bson import ").Append(string.Join(", ", bsonImports)).Append('\n'); + } + b.Append("from pymongo import MongoClient\n\n"); + b.Append("client = MongoClient(os.environ.get(\"MONGODB_URI\", \"mongodb://localhost:27017\"))\n"); + b.Append("collection = client[").Append(BsonText.Quote(database)).Append("][").Append(BsonText.Quote(collection)).Append("]\n\n"); + b.Append("pipeline = ").Append(body).Append("\n\n"); + b.Append("for doc in collection.aggregate(pipeline").Append(allowDiskUse ? ", allowDiskUse=True" : "").Append("):\n print(doc)\n"); + return b.ToString(); + } + + private static string Py(BsonValue value, int depth) + { + switch (value) + { + case BsonDocument doc: + { + var fields = doc.Select(e => BsonText.Quote(e.Name) + ": " + Py(e.Value, depth + 1)).ToList(); + return Container(fields, "{", "}", "{}", depth, " "); + } + case BsonArray array: + { + var items = array.Select(v => Py(v, depth + 1)).ToList(); + return Container(items, "[", "]", "[]", depth, " "); + } + } + return value.BsonType switch + { + BsonType.String => BsonText.Quote(value.AsString), + BsonType.Int32 => value.AsInt32.ToString(CultureInfo.InvariantCulture), + BsonType.Int64 => $"Int64({value.AsInt64.ToString(CultureInfo.InvariantCulture)})", + BsonType.Double => PyDouble(value.AsDouble), + BsonType.Decimal128 => $"Decimal128(\"{value.AsDecimal128}\")", + BsonType.Boolean => value.AsBoolean ? "True" : "False", + BsonType.Null or BsonType.Undefined => "None", + BsonType.ObjectId => $"ObjectId(\"{value.AsObjectId}\")", + BsonType.DateTime => PyDate(value.AsBsonDateTime), + BsonType.RegularExpression => $"Regex({BsonText.Quote(value.AsBsonRegularExpression.Pattern)}, {BsonText.Quote(value.AsBsonRegularExpression.Options)})", + BsonType.Binary when value.AsBsonBinaryData.SubType is BsonBinarySubType.UuidStandard => + $"Binary.from_uuid(uuid.UUID(\"{value.AsBsonBinaryData.ToGuid()}\"))", + BsonType.Binary => $"Binary(base64.b64decode(\"{Convert.ToBase64String(value.AsBsonBinaryData.Bytes)}\"), {(int)value.AsBsonBinaryData.SubType})", + BsonType.Timestamp => $"Timestamp({value.AsBsonTimestamp.Timestamp}, {value.AsBsonTimestamp.Increment})", + BsonType.MinKey => "MinKey()", + BsonType.MaxKey => "MaxKey()", + _ => BsonText.Quote(value.ToString() ?? "") + }; + } + + private static string PyDouble(double value) => + double.IsNaN(value) ? "float(\"nan\")" + : double.IsPositiveInfinity(value) ? "float(\"inf\")" + : double.IsNegativeInfinity(value) ? "float(\"-inf\")" + : BsonText.FormatDouble(value); + + private static string PyDate(BsonDateTime date) + { + if (!date.IsValidDateTime) + { + return $"datetime.fromtimestamp({date.MillisecondsSinceEpoch / 1000.0}, timezone.utc)"; + } + DateTime d = date.ToUniversalTime(); + var parts = new List { d.Year, d.Month, d.Day }; + if (d.TimeOfDay != TimeSpan.Zero) + { + parts.AddRange([d.Hour, d.Minute, d.Second]); + if (d.Millisecond != 0) + { + parts.Add(d.Millisecond * 1000); + } + } + return $"datetime({string.Join(", ", parts)}, tzinfo=timezone.utc)"; + } + + // ── C# ────────────────────────────────────────────────────────────────── + + private static string CSharp(string database, string collection, IReadOnlyList stages, bool allowDiskUse) + { + var b = new StringBuilder(); + b.Append("using MongoDB.Bson;\nusing MongoDB.Driver;\n\n"); + b.Append("var client = new MongoClient(Environment.GetEnvironmentVariable(\"MONGODB_URI\") ?? \"mongodb://localhost:27017\");\n"); + b.Append("var collection = client.GetDatabase(").Append(CsString(database)).Append(").GetCollection(") + .Append(CsString(collection)).Append(");\n\n"); + b.Append("BsonDocument[] pipeline =\n[\n"); + for (int i = 0; i < stages.Count; i++) + { + b.Append(" ").Append(Cs(stages[i], 1)).Append(i < stages.Count - 1 ? ",\n" : "\n"); + } + b.Append("];\n\n"); + b.Append("var options = new AggregateOptions { AllowDiskUse = ").Append(allowDiskUse ? "true" : "false").Append(" };\n"); + b.Append("foreach (BsonDocument doc in collection.Aggregate(PipelineDefinition.Create(pipeline), options).ToEnumerable())\n"); + b.Append("{\n Console.WriteLine(doc);\n}\n"); + return b.ToString(); + } + + private static string Cs(BsonValue value, int depth) + { + string pad = new(' ', depth * 4); + string inner = new(' ', (depth + 1) * 4); + switch (value) + { + case BsonDocument { ElementCount: 0 }: + return "new BsonDocument()"; + case BsonDocument { ElementCount: 1 } single: + return $"new BsonDocument({CsString(single.GetElement(0).Name)}, {Cs(single.GetElement(0).Value, depth)})"; + case BsonDocument doc: + { + var flat = doc.Select(e => $"{{ {CsString(e.Name)}, {Cs(e.Value, depth + 1)} }}").ToList(); + string oneLine = "new BsonDocument { " + string.Join(", ", flat) + " }"; + if (oneLine.Length + depth * 4 <= Width && !oneLine.Contains('\n')) + { + return oneLine; + } + return "new BsonDocument\n" + pad + "{\n" + string.Join(",\n", flat.Select(f => inner + f)) + "\n" + pad + "}"; + } + case BsonArray { Count: 0 }: + return "new BsonArray()"; + case BsonArray array: + { + var items = array.Select(v => Cs(v, depth + 1)).ToList(); + string oneLine = "new BsonArray { " + string.Join(", ", items) + " }"; + if (oneLine.Length + depth * 4 <= Width && !oneLine.Contains('\n')) + { + return oneLine; + } + return "new BsonArray\n" + pad + "{\n" + string.Join(",\n", items.Select(f => inner + f)) + "\n" + pad + "}"; + } + } + return value.BsonType switch + { + BsonType.String => CsString(value.AsString), + BsonType.Int32 => value.AsInt32.ToString(CultureInfo.InvariantCulture), + BsonType.Int64 => value.AsInt64.ToString(CultureInfo.InvariantCulture) + "L", + BsonType.Double => CsDouble(value.AsDouble), + BsonType.Decimal128 => $"Decimal128.Parse(\"{value.AsDecimal128}\")", + BsonType.Boolean => value.AsBoolean ? "true" : "false", + BsonType.Null => "BsonNull.Value", + BsonType.Undefined => "BsonUndefined.Value", + BsonType.ObjectId => $"ObjectId.Parse(\"{value.AsObjectId}\")", + BsonType.DateTime => CsDate(value.AsBsonDateTime), + BsonType.RegularExpression => $"new BsonRegularExpression({CsString(value.AsBsonRegularExpression.Pattern)}, {CsString(value.AsBsonRegularExpression.Options)})", + BsonType.Binary when value.AsBsonBinaryData.SubType is BsonBinarySubType.UuidStandard => + $"new BsonBinaryData(Guid.Parse(\"{value.AsBsonBinaryData.ToGuid()}\"), GuidRepresentation.Standard)", + BsonType.Binary => $"new BsonBinaryData(Convert.FromBase64String(\"{Convert.ToBase64String(value.AsBsonBinaryData.Bytes)}\"), (BsonBinarySubType){(int)value.AsBsonBinaryData.SubType})", + BsonType.Timestamp => $"new BsonTimestamp({value.AsBsonTimestamp.Timestamp}, {value.AsBsonTimestamp.Increment})", + BsonType.MinKey => "BsonMinKey.Value", + BsonType.MaxKey => "BsonMaxKey.Value", + _ => CsString(value.ToString() ?? "") + }; + } + + private static string CsDouble(double value) => + double.IsNaN(value) ? "double.NaN" + : double.IsPositiveInfinity(value) ? "double.PositiveInfinity" + : double.IsNegativeInfinity(value) ? "double.NegativeInfinity" + : BsonText.FormatDouble(value); + + private static string CsDate(BsonDateTime date) + { + if (!date.IsValidDateTime) + { + return $"new BsonDateTime({date.MillisecondsSinceEpoch}L)"; + } + DateTime d = date.ToUniversalTime(); + return d.Millisecond == 0 + ? $"new DateTime({d.Year}, {d.Month}, {d.Day}, {d.Hour}, {d.Minute}, {d.Second}, DateTimeKind.Utc)" + : $"new DateTime({d.Year}, {d.Month}, {d.Day}, {d.Hour}, {d.Minute}, {d.Second}, {d.Millisecond}, DateTimeKind.Utc)"; + } + + /// C# 字符串字面量(与 JSON 的转义规则在这些字符上一致)。 + private static string CsString(string text) => BsonText.Quote(text); + + // ── Java ──────────────────────────────────────────────────────────────── + + private static string Java(string database, string collection, IReadOnlyList stages, bool allowDiskUse) + { + var body = new StringBuilder(); + for (int i = 0; i < stages.Count; i++) + { + body.Append(" ").Append(Jv(stages[i], 4)).Append(i < stages.Count - 1 ? ",\n" : "\n"); + } + string text = body.ToString(); + var imports = new SortedSet(StringComparer.Ordinal) + { + "com.mongodb.client.MongoClient", "com.mongodb.client.MongoClients", "com.mongodb.client.MongoCollection", + "org.bson.Document", "java.util.Arrays", "java.util.List" + }; + foreach ((string token, string import) in new[] + { + ("new ObjectId(", "org.bson.types.ObjectId"), ("new Decimal128(", "org.bson.types.Decimal128"), + ("new BigDecimal(", "java.math.BigDecimal"), ("Date.from(", "java.util.Date"), ("Instant.", "java.time.Instant"), + ("new BsonRegularExpression(", "org.bson.BsonRegularExpression"), ("new Binary(", "org.bson.types.Binary"), + ("Base64.", "java.util.Base64"), ("UUID.", "java.util.UUID"), ("new BsonTimestamp(", "org.bson.BsonTimestamp"), + ("new MinKey(", "org.bson.types.MinKey"), ("new MaxKey(", "org.bson.types.MaxKey") + }) + { + if (text.Contains(token, StringComparison.Ordinal)) + { + imports.Add(import); + } + } + var b = new StringBuilder(); + foreach (string import in imports) + { + b.Append("import ").Append(import).Append(";\n"); + } + b.Append("\npublic class Pipeline {\n"); + b.Append(" public static void main(String[] args) {\n"); + b.Append(" String uri = System.getenv().getOrDefault(\"MONGODB_URI\", \"mongodb://localhost:27017\");\n"); + b.Append(" try (MongoClient client = MongoClients.create(uri)) {\n"); + b.Append(" MongoCollection collection = client.getDatabase(").Append(BsonText.Quote(database)) + .Append(").getCollection(").Append(BsonText.Quote(collection)).Append(");\n\n"); + b.Append(" List pipeline = Arrays.asList(\n"); + b.Append(text); + b.Append(" );\n\n"); + b.Append(" for (Document doc : collection.aggregate(pipeline)").Append(allowDiskUse ? ".allowDiskUse(true)" : "").Append(") {\n"); + b.Append(" System.out.println(doc.toJson());\n }\n }\n }\n}\n"); + return b.ToString(); + } + + private static string Jv(BsonValue value, int depth) + { + string inner = new(' ', (depth + 1) * 4); + switch (value) + { + case BsonDocument { ElementCount: 0 }: + return "new Document()"; + case BsonDocument doc: + { + var parts = doc.Select((e, i) => (i == 0 ? "new Document(" : ".append(") + BsonText.Quote(e.Name) + ", " + Jv(e.Value, depth + 1) + ")").ToList(); + string oneLine = string.Concat(parts); + if (oneLine.Length + depth * 4 <= Width && !oneLine.Contains('\n')) + { + return oneLine; + } + return parts[0] + string.Concat(parts.Skip(1).Select(p => "\n" + inner + p)); + } + case BsonArray { Count: 0 }: + return "Arrays.asList()"; + case BsonArray array: + { + var items = array.Select(v => Jv(v, depth + 1)).ToList(); + string oneLine = "Arrays.asList(" + string.Join(", ", items) + ")"; + if (oneLine.Length + depth * 4 <= Width && !oneLine.Contains('\n')) + { + return oneLine; + } + return "Arrays.asList(\n" + string.Join(",\n", items.Select(i => inner + i)) + ")"; + } + } + return value.BsonType switch + { + BsonType.String => BsonText.Quote(value.AsString), + BsonType.Int32 => value.AsInt32.ToString(CultureInfo.InvariantCulture), + BsonType.Int64 => value.AsInt64.ToString(CultureInfo.InvariantCulture) + "L", + BsonType.Double => JavaDouble(value.AsDouble), + BsonType.Decimal128 => $"new Decimal128(new BigDecimal(\"{value.AsDecimal128}\"))", + BsonType.Boolean => value.AsBoolean ? "true" : "false", + BsonType.Null or BsonType.Undefined => "null", + BsonType.ObjectId => $"new ObjectId(\"{value.AsObjectId}\")", + BsonType.DateTime => $"Date.from(Instant.parse(\"{BsonText.IsoDate(value)}\"))", + BsonType.RegularExpression => $"new BsonRegularExpression({BsonText.Quote(value.AsBsonRegularExpression.Pattern)}, {BsonText.Quote(value.AsBsonRegularExpression.Options)})", + BsonType.Binary when value.AsBsonBinaryData.SubType is BsonBinarySubType.UuidStandard => + $"UUID.fromString(\"{value.AsBsonBinaryData.ToGuid()}\")", + BsonType.Binary => $"new Binary((byte) {(int)value.AsBsonBinaryData.SubType}, Base64.getDecoder().decode(\"{Convert.ToBase64String(value.AsBsonBinaryData.Bytes)}\"))", + BsonType.Timestamp => $"new BsonTimestamp({value.AsBsonTimestamp.Timestamp}, {value.AsBsonTimestamp.Increment})", + BsonType.MinKey => "new MinKey()", + BsonType.MaxKey => "new MaxKey()", + _ => BsonText.Quote(value.ToString() ?? "") + }; + } + + private static string JavaDouble(double value) => + double.IsNaN(value) ? "Double.NaN" + : double.IsPositiveInfinity(value) ? "Double.POSITIVE_INFINITY" + : double.IsNegativeInfinity(value) ? "Double.NEGATIVE_INFINITY" + : BsonText.FormatDouble(value); + + // ── 公共排版 ──────────────────────────────────────────────────────────── + + /// + /// 容器:放得进一行就一行,否则每项一行。子项已按 + 1 写好 + /// (子项自己的折行与缩进都对),这里只决定本层怎么摆。 + /// + private static string Container(List items, string open, string close, string empty, int depth, string unit) + { + if (items.Count == 0) + { + return empty; + } + string flat = open + string.Join(", ", items) + close; + if (flat.Length + depth * unit.Length <= Width && !flat.Contains('\n')) + { + return flat; + } + string pad = string.Concat(Enumerable.Repeat(unit, depth)); + string inner = pad + unit; + return open.TrimEnd() + "\n" + string.Join(",\n", items.Select(i => inner + i)) + "\n" + pad + close.TrimStart(); + } + + /// 顶层的阶段数组:每个阶段一行。 + private static string List(IEnumerable items, string open, string close, int depth, string unit) + { + string pad = string.Concat(Enumerable.Repeat(unit, depth)); + string inner = pad + unit; + var list = items.ToList(); + if (list.Count == 0) + { + return open + close; + } + return open + "\n" + string.Join(",\n", list.Select(i => inner + i)) + "\n" + pad + close; + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineCodeDialogView.axaml b/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineCodeDialogView.axaml new file mode 100644 index 0000000..db65d96 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineCodeDialogView.axaml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineCodeDialogView.axaml.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineCodeDialogView.axaml.cs new file mode 100644 index 0000000..1aa87d5 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineCodeDialogView.axaml.cs @@ -0,0 +1,20 @@ +using Avalonia.Controls; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 「导出为代码」对话框的视图。 +public sealed partial class PipelineCodeDialogView : UserControl +{ + /// 用给定的视图模型初始化。 + internal PipelineCodeDialogView(PipelineCodeDialogViewModel viewModel) + { + InitializeComponent(); + DataContext = viewModel; + } + + /// 设计器 / headless 用的无参构造。 + public PipelineCodeDialogView() + { + InitializeComponent(); + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineDialogs.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineDialogs.cs new file mode 100644 index 0000000..72caf8d --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineDialogs.cs @@ -0,0 +1,347 @@ +using Avalonia.Controls; +using MongoDB.Bson; +using VelaShell.Plugin.Mongo.Bson; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 「输入一个名字」对话框的配置(保存管道、创建视图、另存为集合共用)。 +internal sealed record PipelineNameOptions +{ + /// 标题。 + public required string Title { get; init; } + + /// 标题栏图标。 + public string IconKey { get; init; } = "Mongo.save"; + + /// 输入框上方的标签。 + public required string Label { get; init; } + + /// 初始值。 + public string Initial { get; init; } = ""; + + /// 确认按钮文字。 + public required string ConfirmLabel { get; init; } + + /// 输入框下方的说明。 + public string? Hint { get; init; } + + /// 可点选的已有名字(保存管道时列出已保存的,点一下即覆盖它)。 + public IReadOnlyList Suggestions { get; init; } = []; + + /// 校验:返回错误文字,通过返回 。 + public Func? Validate { get; init; } + + /// 另存为集合:多给一组「$out 整体替换 / $merge 合并」。 + public bool OfferMerge { get; init; } +} + +/// 名字对话框的结果。 +/// 名字。 +/// 选了 $merge(否则 $out)。 +internal sealed record PipelineNameResult(string Name, bool Merge); + +/// +/// 输入一个名字的小对话框。三处用它:保存管道、创建视图(视图名)、另存为集合(目标集合 + 写入方式)。 +/// 只收集输入;只读拦截与"写清后果"的确认由调用方在它之后做 —— 那两步对每种写法不一样。 +/// +internal sealed class PipelineNameDialogViewModel : DialogViewModel, IViewFactory +{ + private readonly TaskCompletionSource _result = new(TaskCreationOptions.RunContinuationsAsynchronously); + private string _name; + private bool _merge; + + /// 构造。 + public PipelineNameDialogViewModel(IMongoWorkspace workspace, PipelineNameOptions options) + : base(workspace) + { + Options = options; + Title = options.Title; + _name = options.Initial; + ConfirmCommand = new RelayCommand(Confirm, () => CanConfirm); + PickCommand = new RelayCommand(name => Name = name); + } + + /// 配置。 + public PipelineNameOptions Options { get; } + + /// + public override string IconKey => Options.IconKey; + + /// + public override double Width => 440; + + /// 名字。 + public string Name + { + get => _name; + set + { + if (SetProperty(ref _name, value ?? "")) + { + RaisePropertiesChanged(nameof(Error), nameof(HasError), nameof(CanConfirm)); + ConfirmCommand.RaiseCanExecuteChanged(); + } + } + } + + /// 校验错误。 + public string Error => Options.Validate?.Invoke(_name) ?? ""; + + /// 有错。 + public bool HasError => Error.Length > 0; + + /// 能确认。 + public bool CanConfirm => !HasError && _name.Trim().Length > 0; + + /// 有没有说明。 + public bool HasHint => !HasError && !string.IsNullOrEmpty(Options.Hint); + + /// 有没有可点选的已有名字。 + public bool HasSuggestions => Options.Suggestions.Count > 0; + + /// 选了 $out。 + public bool IsOut + { + get => !_merge; + set + { + if (value && _merge) + { + _merge = false; + RaisePropertiesChanged(nameof(IsOut), nameof(IsMerge), nameof(WriteHint)); + } + } + } + + /// 选了 $merge。 + public bool IsMerge + { + get => _merge; + set + { + if (value && !_merge) + { + _merge = true; + RaisePropertiesChanged(nameof(IsOut), nameof(IsMerge), nameof(WriteHint)); + } + } + } + + /// 写入方式的说明。 + public string WriteHint => Loc[_merge ? "Pipe_SaveAsMergeHint" : "Pipe_SaveAsOutHint"]; + + /// 确认。 + public RelayCommand ConfirmCommand { get; } + + /// 点选一个已有名字。 + public RelayCommand PickCommand { get; } + + /// 结果;取消为 。 + public Task Result => _result.Task; + + /// + public Control CreateView() => new PipelineNameDialogView(this); + + private void Confirm() + { + if (!CanConfirm) + { + return; + } + _result.TrySetResult(new(_name.Trim(), _merge)); + Close(); + } + + /// + internal override void OnClosed() + { + _result.TrySetResult(null); + base.OnClosed(); + } +} + +/// 「导出为代码」对话框:五种语言分段切换,只读编辑器展示,一键复制。 +internal sealed class PipelineCodeDialogViewModel : DialogViewModel, IViewFactory +{ + private readonly string _database; + private readonly string _collection; + private readonly IReadOnlyList _stages; + private PipelineCodeLanguage _language = PipelineCodeLanguage.Mongosh; + private bool _allowDiskUse; + private string _code = ""; + + /// 构造。 + public PipelineCodeDialogViewModel(IMongoWorkspace workspace, string database, string collection, IReadOnlyList stages, bool allowDiskUse) + : base(workspace) + { + _database = database; + _collection = collection; + _stages = stages; + _allowDiskUse = allowDiskUse; + Title = Loc["Pipe_CodeTitle"]; + Subtitle = $"{database}.{collection}"; + CopyCommand = new AsyncCommand(CopyAsync); + Regenerate(); + } + + /// + public override string IconKey => "Mongo.code-xml"; + + /// + public override double Width => 760; + + /// + public override double Height => 540; + + /// 当前语言。 + public PipelineCodeLanguage Language + { + get => _language; + set + { + if (SetProperty(ref _language, value)) + { + RaisePropertiesChanged(nameof(IsMongosh), nameof(IsCSharp), nameof(IsPython), nameof(IsNode), nameof(IsJava)); + Regenerate(); + } + } + } + + /// mongosh 分段。 + public bool IsMongosh + { + get => _language == PipelineCodeLanguage.Mongosh; + set + { + if (value) + { + Language = PipelineCodeLanguage.Mongosh; + } + } + } + + /// C# 分段。 + public bool IsCSharp + { + get => _language == PipelineCodeLanguage.CSharp; + set + { + if (value) + { + Language = PipelineCodeLanguage.CSharp; + } + } + } + + /// Python 分段。 + public bool IsPython + { + get => _language == PipelineCodeLanguage.Python; + set + { + if (value) + { + Language = PipelineCodeLanguage.Python; + } + } + } + + /// Node.js 分段。 + public bool IsNode + { + get => _language == PipelineCodeLanguage.Node; + set + { + if (value) + { + Language = PipelineCodeLanguage.Node; + } + } + } + + /// Java 分段。 + public bool IsJava + { + get => _language == PipelineCodeLanguage.Java; + set + { + if (value) + { + Language = PipelineCodeLanguage.Java; + } + } + } + + /// 代码里带不带 allowDiskUse。 + public bool AllowDiskUse + { + get => _allowDiskUse; + set + { + if (SetProperty(ref _allowDiskUse, value)) + { + Regenerate(); + } + } + } + + /// 生成的代码。 + public string Code + { + get => _code; + private set => SetProperty(ref _code, value); + } + + /// 底栏说明(5 个启用阶段 · 停用的阶段不导出)。 + public string Note => Loc.Format("Pipe_CodeNote", _stages.Count); + + /// 复制。 + public AsyncCommand CopyCommand { get; } + + /// + public Control CreateView() => new PipelineCodeDialogView(this); + + private void Regenerate() => Code = PipelineCode.Generate(_language, _database, _collection, _stages, _allowDiskUse); + + private async Task CopyAsync() + { + await Workspace.CopyAsync(Code).ConfigureAwait(true); + Workspace.Toast(new() { Title = Loc["Common_Copied"], Kind = ToastKind.Success }); + } +} + +/// 某个阶段输出预览的"看全部"(卡片上只摆得下两张小文档卡)。 +internal sealed class PipelinePreviewDialogViewModel : DialogViewModel, IViewFactory +{ + /// 构造。 + public PipelinePreviewDialogViewModel(IMongoWorkspace workspace, string title, string subtitle, IReadOnlyList documents) + : base(workspace) + { + Title = title; + Subtitle = subtitle; + Text = string.Join(",\n", documents.Select(static d => BsonText.Pretty(d, EjsonMode.Shell))); + CopyCommand = new AsyncCommand(async () => + { + await Workspace.CopyAsync("[\n" + Text + "\n]").ConfigureAwait(true); + Workspace.Toast(new() { Title = Loc["Common_Copied"], Kind = ToastKind.Success }); + }); + } + + /// + public override string IconKey => "Mongo.maximize-2"; + + /// + public override double Width => 760; + + /// + public override double Height => 560; + + /// 文档(mongosh 写法,逐份排开)。 + public string Text { get; } + + /// 复制全部。 + public AsyncCommand CopyCommand { get; } + + /// + public Control CreateView() => new PipelinePreviewDialogView(this); +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineFields.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineFields.cs new file mode 100644 index 0000000..ef9e838 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineFields.cs @@ -0,0 +1,219 @@ +using MongoDB.Bson; +using VelaShell.Plugin.Mongo.Bson; +using VelaShell.Plugin.Mongo.Shell; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 抽样里出现过的一个字段路径。 +/// 点号路径(items.sku)。 +/// 最常见的类型。 +/// 出现率(含它的文档 / 抽样文档数)。 +internal sealed record PipelineFieldSample(string Path, BsonKind Kind, double Ratio); + +/// +/// 上游字段抽样与阶段编辑器的补全。 +/// +/// 补全里的"上游字段"不是集合的 Schema,而是**前一个阶段输出预览**里实际出现的字段: +/// $group 之后只剩 _id / qty / revenue,这时再提示 orderNo 只会误导。 +/// 预览本来就在跑,顺手把它的输出抽一遍字段,不多打一次服务器。 +/// +/// +internal static class PipelineFields +{ + /// 嵌套多深就不再展开(a.b.c)。 + private const int MaxDepth = 3; + + /// 从一批文档里抽字段路径(按首次出现的顺序;数组里的子文档按同一路径展开)。 + public static IReadOnlyList Sample(IReadOnlyList documents) + { + if (documents.Count == 0) + { + return []; + } + var order = new List(); + var counts = new Dictionary(StringComparer.Ordinal); + var kinds = new Dictionary>(StringComparer.Ordinal); + foreach (BsonDocument doc in documents) + { + var seen = new HashSet(StringComparer.Ordinal); + Visit(doc, "", 0, seen, kinds, order); + foreach (string path in seen) + { + counts[path] = counts.GetValueOrDefault(path) + 1; + } + } + return + [ + .. order.Select(path => new PipelineFieldSample( + path, + kinds[path].OrderByDescending(static k => k.Value).First().Key, + (double)counts.GetValueOrDefault(path) / documents.Count)) + ]; + } + + private static void Visit( + BsonDocument doc, + string prefix, + int depth, + HashSet seen, + Dictionary> kinds, + List order) + { + foreach (BsonElement element in doc) + { + string path = prefix.Length == 0 ? element.Name : prefix + "." + element.Name; + Record(path, element.Value, seen, kinds, order); + if (depth + 1 >= MaxDepth) + { + continue; + } + switch (element.Value) + { + case BsonDocument child: + Visit(child, path, depth + 1, seen, kinds, order); + break; + case BsonArray array: + foreach (BsonValue item in array.Take(20)) + { + if (item is BsonDocument itemDoc) + { + Visit(itemDoc, path, depth + 1, seen, kinds, order); + } + } + break; + } + } + } + + private static void Record(string path, BsonValue value, HashSet seen, Dictionary> kinds, List order) + { + if (!kinds.TryGetValue(path, out Dictionary? byKind)) + { + byKind = []; + kinds[path] = byKind; + order.Add(path); + } + BsonKind kind = BsonKinds.Of(value); + byKind[kind] = byKind.GetValueOrDefault(kind) + 1; + seen.Add(path); + } + + /// + /// 阶段编辑器的补全: + /// 字符串里敲 "$ → 上游字段路径;敲 $ → 运算符($match 里先给查询运算符,其余阶段先给表达式); + /// 键的位置敲字母 → 上游字段名。 + /// + /// 补全请求。 + /// 所在阶段的运算符;文本模式(整条管道)为 。 + /// 上游字段。 + /// 抽样的输入文档数(标题里说清"这是抽样")。 + /// 文案。 + public static CompletionSet? Complete(CompletionRequest request, string? stageOperator, IReadOnlyList fields, int sampleSize, Loc loc) + { + string text = request.Text; + int caret = Math.Clamp(request.CaretOffset, 0, text.Length); + int start = caret; + while (start > 0 && (char.IsLetterOrDigit(text[start - 1]) || text[start - 1] is '_' or '$' or '.')) + { + start--; + } + string prefix = text[start..caret]; + bool chinese = loc.IsChinese; + bool inString = start > 0 && text[start - 1] is '"' or '\'' && InsideString(text, start - 1); + var items = new List(); + string? header = null; + + if (inString) + { + if (!prefix.StartsWith('$') && !(request.Explicit && prefix.Length == 0)) + { + return null; + } + items.AddRange(fields.Select(f => FieldItem(f, "$" + f.Path, "$" + f.Path, loc))); + header = loc.Format("Pipe_FieldsHeader", sampleSize); + } + else if (prefix.StartsWith('$')) + { + IEnumerable entries = stageOperator switch + { + null => MongoVocabulary.Stages.Concat(MongoVocabulary.QueryOperators).Concat(MongoVocabulary.Expressions), + "$match" => MongoVocabulary.QueryOperators.Concat(MongoVocabulary.Expressions), + _ => MongoVocabulary.Expressions.Concat(MongoVocabulary.QueryOperators) + }; + var seen = new HashSet(StringComparer.Ordinal); + foreach (VocabularyEntry entry in entries) + { + if (seen.Add(entry.Name)) + { + items.Add(MongoVocabulary.ToCompletion(entry, chinese)); + } + } + } + else if (IsKeyPosition(text, start) || request.Explicit) + { + items.AddRange(fields.Select(f => FieldItem(f, f.Path, BsonText.FieldName(f.Path) + ": |", loc))); + header = fields.Count > 0 ? loc.Format("Pipe_FieldsHeader", sampleSize) : null; + } + if (items.Count == 0) + { + return null; + } + return new CompletionSet + { + Items = items, + ReplaceOffset = start, + ReplaceLength = caret - start, + Header = header, + Footer = loc["Pipe_CompletionFooter"] + }; + } + + private static CompletionItem FieldItem(PipelineFieldSample field, string label, string insert, Loc loc) => new() + { + Label = label, + InsertText = insert, + IconKey = "Mongo.variable", + IconToken = "VelaWarning", + Category = BsonKinds.Name(field.Kind), + Badge = loc["Pipe_FieldBadge"], + Description = loc.Format("Pipe_FieldDetail", field.Path, BsonKinds.Name(field.Kind), $"{field.Ratio * 100:0}%"), + Ratio = field.Ratio + }; + + /// 位置 的引号是不是一个字符串的开引号(数一数前面未闭合的引号)。 + private static bool InsideString(string text, int quote) + { + int i = 0; + while (i < quote) + { + char c = text[i]; + if (c is '"' or '\'') + { + int end = PipelineText.SkipString(text, i); + if (end > quote) + { + return false; + } + i = end; + continue; + } + i++; + } + return true; + } + + /// 前一个有效字符是 { 或 ,(或者在行首)—— 这里该写键名。 + private static bool IsKeyPosition(string text, int start) + { + for (int i = start - 1; i >= 0; i--) + { + char c = text[i]; + if (char.IsWhiteSpace(c)) + { + continue; + } + return c is '{' or ','; + } + return false; + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineInlineCode.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineInlineCode.cs new file mode 100644 index 0000000..3e62f76 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineInlineCode.cs @@ -0,0 +1,63 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Documents; +using Avalonia.Media; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 一行按 token 着色的 mongosh 代码(折叠态阶段卡片里的 { path: "$items" })。 +/// +/// 只是一个 :把文本切成记号,每个记号一个 。 +/// 画刷取自 (长期有效、换肤时就地改色),所以换主题不必重建。 +/// +/// +public sealed class PipelineInlineCode : TextBlock +{ + /// 代码。 + public static readonly StyledProperty CodeProperty = + AvaloniaProperty.Register(nameof(Code)); + + /// 整体压成弱色(停用的阶段)。 + public static readonly StyledProperty MutedProperty = + AvaloniaProperty.Register(nameof(Muted)); + + /// + protected override Type StyleKeyOverride => typeof(TextBlock); + + /// + public string? Code + { + get => GetValue(CodeProperty); + set => SetValue(CodeProperty, value); + } + + /// + public bool Muted + { + get => GetValue(MutedProperty); + set => SetValue(MutedProperty, value); + } + + /// + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == CodeProperty || change.Property == MutedProperty) + { + Rebuild(); + } + } + + private void Rebuild() + { + string code = Code ?? ""; + var inlines = new InlineCollection(); + foreach (PipelineToken token in PipelineTokens.Tokenize(code)) + { + string color = Muted ? "VelaTextMuted" : PipelineTokens.ColorToken(token.Kind); + inlines.Add(new Run(code.Substring(token.Start, token.Length)) { Foreground = ThemeBrushes.Get(color, Brushes.Gray) }); + } + Inlines = inlines; + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineNameDialogView.axaml b/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineNameDialogView.axaml new file mode 100644 index 0000000..a0c4355 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineNameDialogView.axaml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelinePreviewDialogView.axaml.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelinePreviewDialogView.axaml.cs new file mode 100644 index 0000000..9fca51a --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelinePreviewDialogView.axaml.cs @@ -0,0 +1,20 @@ +using Avalonia.Controls; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 阶段输出预览"看全部"的视图。 +public sealed partial class PipelinePreviewDialogView : UserControl +{ + /// 用给定的视图模型初始化。 + internal PipelinePreviewDialogView(PipelinePreviewDialogViewModel viewModel) + { + InitializeComponent(); + DataContext = viewModel; + } + + /// 设计器 / headless 用的无参构造。 + public PipelinePreviewDialogView() + { + InitializeComponent(); + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineResults.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineResults.cs new file mode 100644 index 0000000..2f96603 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineResults.cs @@ -0,0 +1,261 @@ +using System.Globalization; +using MongoDB.Bson; +using VelaShell.Plugin.Mongo.Bson; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 「管道输出」表的一列。列宽可拖(列头分隔线),同一列的格子绑的就是这一个宽度。 +/// 字段名。 +/// 初始列宽。 +/// 数值列(右对齐)。 +/// 被停用阶段影响的列(只在停用的阶段里出现,表里没有数据)。 +/// 主要类型(列头提示)。 +internal sealed class PipelineColumn(string name, double width, bool isNumeric, bool isGhost, string typeName) : ObservableObject +{ + private double _width = width; + + /// 字段名。 + public string Name { get; } = name; + + /// 列宽(拖分隔线改,双击分隔线按内容自适应)。 + public double Width + { + get => _width; + set => SetProperty(ref _width, Math.Clamp(value, 40, 1200)); + } + + /// 数值列(右对齐)。 + public bool IsNumeric { get; } = isNumeric; + + /// 被停用阶段影响的提示列。 + public bool IsGhost { get; } = isGhost; + + /// 主要类型。 + public string TypeName { get; } = typeName; + + /// 列头提示(qty · Int32)。 + public string Tip => IsGhost ? Name : $"{Name} · {TypeName}"; +} + +/// 表里的一格。 +/// 文字。 +/// 颜色令牌。 +/// 右对齐(数值)。 +/// 所在列(宽度跟着它走)。 +/// "(阶段 6 已停用)" 那种提示格。 +internal sealed record PipelineCell(string Text, string Token, bool AlignRight, PipelineColumn Column, bool IsGhost) +{ + /// 对齐方式。 + public Avalonia.Layout.HorizontalAlignment Alignment => + AlignRight ? Avalonia.Layout.HorizontalAlignment.Right : Avalonia.Layout.HorizontalAlignment.Left; +} + +/// 表里的一行。 +/// 行号(1 起)。 +/// 各格。 +internal sealed record PipelineRow(int Number, IReadOnlyList Cells); + +/// 一列"被停用阶段影响"的提示列。 +/// 字段名(product.name)。 +/// 停用的那个阶段的序号。 +internal sealed record PipelineGhost(string Name, int StageNumber); + +/// 输出预览卡里的一行(_id: 66f5c2a1b04e97d2…)。 +/// 键名(带冒号)。 +/// 值的摘要。 +/// 值的颜色令牌。 +internal sealed record PipelineMiniField(string Key, string Value, string Token); + +/// 输出预览里的一张小文档卡。 +/// 头几个字段。 +internal sealed record PipelineMiniDoc(IReadOnlyList Fields); + +/// +/// 「管道输出」表与输出预览卡的排版:类型化列、BSON 着色、停用阶段的提示列。 +/// +internal static class PipelineResults +{ + /// 行高(设计稿的输出表是 24px 行)。 + public const double RowHeight = 24; + + /// 行号列宽。 + public const double NumberWidth = 40; + + /// 最多出几列(再宽的文档也不该把表拉成一条横幅)。 + private const int MaxColumns = 40; + + /// + /// 那些"重塑文档"的阶段:它们之后,前面停用的阶段加的字段反正也不会出现在输出里, + /// 不必为它们画提示列。 + /// + private static readonly HashSet Reshaping = new(StringComparer.Ordinal) + { + "$group", "$project", "$replaceRoot", "$replaceWith", "$bucket", "$bucketAuto", "$count", "$facet", "$sortByCount" + }; + + /// 排出列与行。 + public static (IReadOnlyList Columns, IReadOnlyList Rows) Build( + IReadOnlyList documents, + IReadOnlyList ghosts, + Loc loc) + { + var names = new List(); + var seen = new HashSet(StringComparer.Ordinal); + foreach (BsonDocument doc in documents) + { + foreach (BsonElement element in doc) + { + if (names.Count < MaxColumns && seen.Add(element.Name)) + { + names.Add(element.Name); + } + } + } + var columns = new List(); + foreach (string name in names) + { + var kinds = documents.Take(200) + .Select(d => BsonKinds.Of(d.GetValue(name, null))) + .Where(static k => k is not BsonKind.Missing and not BsonKind.Null) + .GroupBy(static k => k) + .OrderByDescending(static g => g.Count()) + .Select(static g => g.Key) + .ToList(); + BsonKind main = kinds.Count > 0 ? kinds[0] : BsonKind.Null; + int longest = Math.Max(name.Length, documents.Take(200).Select(d => Cell(d.GetValue(name, null)).Text.Length).DefaultIfEmpty(0).Max()); + columns.Add(new(name, WidthFor(longest), BsonKinds.IsNumeric(main), isGhost: false, BsonKinds.Name(main))); + } + var ghostCells = new List<(PipelineGhost Ghost, string Text)>(); + foreach (PipelineGhost ghost in ghosts) + { + if (seen.Contains(ghost.Name)) + { + continue; + } + string note = loc.Format("Pipe_GhostCell", ghost.StageNumber); + columns.Add(new(ghost.Name, Math.Max(WidthFor(ghost.Name.Length), WidthFor(note.Length + 6)), false, isGhost: true, "")); + ghostCells.Add((ghost, note)); + } + + var rows = new List(documents.Count); + for (int r = 0; r < documents.Count; r++) + { + BsonDocument doc = documents[r]; + var cells = new List(columns.Count); + int g = 0; + foreach (PipelineColumn column in columns) + { + if (column.IsGhost) + { + cells.Add(new(ghostCells[g++].Text, "VelaTextMuted", false, column, IsGhost: true)); + continue; + } + (string text, string token) = Cell(doc.GetValue(column.Name, null)); + BsonKind kind = BsonKinds.Of(doc.GetValue(column.Name, null)); + cells.Add(new(text, token, BsonKinds.IsNumeric(kind), column, IsGhost: false)); + } + rows.Add(new(r + 1, cells)); + } + return (columns, rows); + } + + /// + /// 停用阶段会产出、但输出里没有的字段 —— 设计稿里 product.name 列写着"(阶段 6 已停用)", + /// 让人一眼看出"这一列空着不是数据问题,是我把 $lookup 关了"。 + /// + /// 全部阶段(运算符、解析出的值、是否启用)。 + /// 给 $lookup.from 集合抽一个字段名(product → product.name);没有返回 。 + public static IReadOnlyList Ghosts( + IReadOnlyList<(string Operator, BsonValue? Value, bool Enabled)> stages, + Func? lookupSample = null) + { + int lastReshape = -1; + for (int i = 0; i < stages.Count; i++) + { + if (stages[i].Enabled && Reshaping.Contains(stages[i].Operator)) + { + lastReshape = i; + } + } + var ghosts = new List(); + for (int i = lastReshape + 1; i < stages.Count; i++) + { + (string op, BsonValue? value, bool enabled) = stages[i]; + if (enabled || value is not BsonDocument body) + { + continue; + } + switch (op) + { + case "$lookup" or "$graphLookup" when body.TryGetValue("as", out BsonValue asValue) && asValue.IsString: + { + string field = asValue.AsString; + string? sub = body.TryGetValue("from", out BsonValue from) && from.IsString ? lookupSample?.Invoke(from.AsString) : null; + ghosts.Add(new(sub is null ? field : $"{field}.{sub}", i + 1)); + break; + } + case "$addFields" or "$set": + ghosts.AddRange(body.Names.Select(n => new PipelineGhost(n, i + 1))); + break; + case "$setWindowFields" when body.TryGetValue("output", out BsonValue output) && output is BsonDocument outputs: + ghosts.AddRange(outputs.Names.Select(n => new PipelineGhost(n, i + 1))); + break; + case "$unwind" when body.TryGetValue("includeArrayIndex", out BsonValue index) && index.IsString: + ghosts.Add(new(index.AsString, i + 1)); + break; + } + } + return ghosts; + } + + /// 一格的文字与颜色:字符串带引号(与设计稿一致),数值给千分位,容器给摘要。 + public static (string Text, string Token) Cell(BsonValue? value) + { + BsonKind kind = BsonKinds.Of(value); + return kind switch + { + BsonKind.Missing => ("—", "VelaTextMuted"), + BsonKind.String => (BsonText.Quote(Clip(BsonText.OneLine(value!.AsString), 120)), "VelaShellCyan"), + _ => (Clip(BsonText.Cell(value), 160), BsonKinds.ColorToken(kind)) + }; + } + + /// 输出预览卡:头几个字段的摘要(ObjectId 缩成 16 位 + 省略号,容器写成 Array[3])。 + public static PipelineMiniDoc Mini(BsonDocument doc, int maxFields = 4) + { + var fields = new List(); + foreach (BsonElement element in doc.Take(maxFields)) + { + BsonValue v = element.Value; + BsonKind kind = BsonKinds.Of(v); + string text = kind switch + { + BsonKind.ObjectId => Ellipsis(v.AsObjectId.ToString(), 16), + BsonKind.String => BsonText.Quote(Clip(BsonText.OneLine(v.AsString), 60)), + BsonKind.Int32 => v.AsInt32.ToString(CultureInfo.InvariantCulture), + BsonKind.Int64 => v.AsInt64.ToString(CultureInfo.InvariantCulture), + BsonKind.Double => BsonText.FormatDouble(v.AsDouble, forceDecimalPoint: false), + BsonKind.Decimal128 => v.AsDecimal128.ToString(), + BsonKind.Array => $"Array[{v.AsBsonArray.Count}]", + BsonKind.Object => $"Object{{{v.AsBsonDocument.ElementCount}}}", + BsonKind.Date => BsonText.FormatDate(v), + _ => Clip(BsonText.Inline(v), 60) + }; + fields.Add(new(element.Name + ":", text, BsonKinds.ColorToken(kind))); + } + return new(fields); + } + + /// 耗时文字:一秒以内给毫秒,以上给一位小数的秒(1.4 s)。 + public static string Elapsed(TimeSpan elapsed) => + elapsed.TotalMilliseconds < 1000 + ? $"{Math.Max(0, (int)Math.Round(elapsed.TotalMilliseconds))} ms" + : $"{elapsed.TotalSeconds.ToString("0.0", CultureInfo.InvariantCulture)} s"; + + /// 按字符数估一个等宽列宽(11px 等宽 ≈ 6.7px/字),夹在 90–320 之间。 + private static double WidthFor(int characters) => Math.Clamp(Math.Ceiling(characters * 6.7 + 26), 90, 320); + + private static string Clip(string text, int max) => text.Length <= max ? text : text[..max] + "…"; + + private static string Ellipsis(string text, int keep) => text.Length <= keep ? text : text[..keep] + "…"; +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineStage.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineStage.cs new file mode 100644 index 0000000..652b351 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineStage.cs @@ -0,0 +1,287 @@ +using MongoDB.Bson; +using VelaShell.Plugin.Mongo.Bson; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 一张阶段卡片(设计稿 04 的一行 ① $match 筛选文档 … → 1,284 份文档)。 +/// +/// 阶段体以**原文**为准(),解析结果是派生出来的: +/// 用户敲到一半的阶段体解析不了是常态,卡片要能带着错误继续存在,而不是把用户的字吞掉。 +/// +/// +internal sealed class PipelineStage : ObservableObject +{ + private readonly PipelineTabViewModel _owner; + private string _operator; + private string _body; + private bool _isEnabled; + private bool _isExpanded; + private int _index; + private PipelineBodyParse _parse = new(null); + private PipelineStageOutcome _outcome = PipelineStageOutcome.Pending; + private IReadOnlyList _upstream = []; + + /// 构造。 + /// 所在的管道。 + /// 运算符。 + /// 阶段体原文。 + /// 是否启用。 + public PipelineStage(PipelineTabViewModel owner, string op, string body, bool enabled = true) + { + _owner = owner; + _operator = op; + _body = body; + _isEnabled = enabled; + Reparse(); + CompletionProvider = request => Task.FromResult( + PipelineFields.Complete(request, Operator, _upstream, _owner.SampleSize, _owner.Loc)); + ToggleExpandCommand = new RelayCommand(() => IsExpanded = !IsExpanded); + RemoveCommand = new RelayCommand(() => _owner.RemoveStage(this)); + DuplicateCommand = new RelayCommand(() => _owner.DuplicateStage(this)); + MoveUpCommand = new RelayCommand(() => _owner.MoveStage(this, -1)); + MoveDownCommand = new RelayCommand(() => _owner.MoveStage(this, +1)); + ShowPreviewCommand = new RelayCommand(() => _owner.ShowStagePreview(this)); + } + + /// 运算符、阶段体或启用状态变了(管道据此重算预览、标记未保存)。 + public event Action? Changed; + + /// 文案。 + public Loc Loc => _owner.Loc; + + /// 运算符($match)。 + public string Operator + { + get => _operator; + set + { + if (SetProperty(ref _operator, value)) + { + RaisePropertiesChanged(nameof(Description), nameof(Tip)); + Changed?.Invoke(this); + } + } + } + + /// 阶段体原文(编辑器双向绑定)。 + public string Body + { + get => _body; + set + { + if (SetProperty(ref _body, value ?? "")) + { + Reparse(); + Changed?.Invoke(this); + } + } + } + + /// 启用(停用的卡片整体变淡并标「已停用」,预览与运行都跳过它)。 + public bool IsEnabled + { + get => _isEnabled; + set + { + if (SetProperty(ref _isEnabled, value)) + { + RaisePropertiesChanged(nameof(Description), nameof(CountText), nameof(OperatorToken), nameof(IndexToken), + nameof(IndexBackgroundToken), nameof(InlineMuted)); + Changed?.Invoke(this); + } + } + } + + /// 展开(左编辑器 + 右输出预览)。 + public bool IsExpanded + { + get => _isExpanded; + set + { + if (SetProperty(ref _isExpanded, value)) + { + RaisePropertiesChanged(nameof(ChevronKey), nameof(ShowInline)); + } + } + } + + /// 序号(1 起;拖动重排后由管道重新编号)。 + public int Index + { + get => _index; + set => SetProperty(ref _index, value); + } + + /// 灰色说明(筛选文档;停用时加「· 已停用」)。 + public string Description + { + get + { + string key = "Pipe_Desc_" + Operator.TrimStart('$'); + string text = Loc[key]; + if (text == key) + { + text = Loc["Pipe_Desc_custom"]; + } + return IsEnabled ? text : text + " · " + Loc["Pipe_Disabled"]; + } + } + + /// 运算符下拉的悬停说明(词汇表里的完整一句)。 + public string Tip => + Shell.MongoVocabulary.Stages.FirstOrDefault(s => s.Name == Operator)?.Describe(Loc.IsChinese) ?? Operator; + + /// 运算符文字颜色(停用时退成三级文字色)。 + public string OperatorToken => IsEnabled ? "VelaShellMagenta" : "VelaTextTertiary"; + + /// 序号圆标文字色。 + public string IndexToken => IsEnabled ? "VelaAccent" : "VelaTextTertiary"; + + /// 序号圆标底色。 + public string IndexBackgroundToken => IsEnabled ? "VelaAccentDim" : "VelaBgActive"; + + /// 折叠箭头。 + public string ChevronKey => IsExpanded ? "Mongo.chevron-up" : "Mongo.chevron-down"; + + /// + /// 编辑器高度:按行数给一个确定的高度(至少 3 行)。卡片在竖向 StackPanel 里拿到的是无限高, + /// 让 AvaloniaEdit 自己"按内容撑开"会一路撑到上限 —— 它的滚动区在无限高下量不出内容高度。 + /// 阶段体整段都摆出来、编辑器自己不竖向滚动:滚轮交给外面那一列卡片,长阶段体就是一张高卡片。 + /// + public double EditorHeight => Math.Clamp(_body.Count(static c => c == '\n') + 1, 3, 400) * EditorLineHeight + 16; + + /// 编辑器一行的高度(11px 等宽字)。 + internal const double EditorLineHeight = 16; + + /// 折叠态的一行内联阶段体。 + public string InlineBody => PipelineTokens.OneLine(Body); + + /// 内联阶段体是否显示(只在折叠态)。 + public bool ShowInline => !IsExpanded; + + /// 内联阶段体整体压成弱色(停用时)。 + public bool InlineMuted => !IsEnabled; + + /// 解析出的阶段体;解析不了为 。 + public BsonValue? Value => _parse.Value; + + /// 整个阶段 { $op: body };解析不了为 。 + public BsonDocument? Document => _parse.Value is { } value ? new BsonDocument(Operator, value) : null; + + /// 阶段体的语法错误(本地化后的一句);没有为 。 + public string? ParseError => _parse.Ok ? null : Loc.Format(_parse.ErrorKey!, _parse.ErrorArgument); + + /// 编辑器诊断(波浪线 + 行尾提示)。 + public IReadOnlyList Diagnostics => _parse.Ok + ? [] + : [new EditorDiagnostic(Math.Clamp(_parse.Offset, 0, Math.Max(0, Body.Length - 1)), Math.Max(1, _parse.Length), ParseError!, + DiagnosticSeverity.Error, _parse.Fix, _parse.Fix is null ? null : Loc["Pipe_FixHint"])]; + + /// 服务器在预览里对这个阶段的报错。 + public string? ServerError => _outcome.State == PipelineStageState.Error ? _outcome.Error : null; + + /// 卡片右上角的错误标记文字;没有为空。 + public string ErrorText => ParseError ?? ServerError ?? ""; + + /// 有错(卡片描红)。 + public bool HasError => ErrorText.Length > 0; + + /// 错误标记上的短字(语法错误 / 运行出错)。 + public string ErrorBadge => ParseError is not null ? Loc["Pipe_SyntaxError"] : Loc["Pipe_ServerError"]; + + /// 预览结局。 + public PipelineStageOutcome Outcome => _outcome; + + /// 预览带回的文档(放大看全部)。 + public IReadOnlyList PreviewDocuments => _outcome.Documents; + + /// 卡片右侧的两张小文档卡。 + public IReadOnlyList PreviewCards { get; private set; } = []; + + /// 有没有预览卡。 + public bool HasPreviewCards => PreviewCards.Count > 0; + + /// 右上角计数:1,284;没算出来为「—」。 + public string CountText => IsEnabled && _outcome.State == PipelineStageState.Ok ? BsonText.Grouped(_outcome.Count) : "—"; + + /// 计数的悬停说明(说清是抽样上的计数)。 + public string CountTip => _outcome.State == PipelineStageState.Ok + ? Loc.Format("Pipe_CountTip", BsonText.Grouped(_outcome.Count), _owner.SampleSize) + : ""; + + /// 预览面板的副标题:前 2 / 1,284。 + public string PreviewHeader => _outcome.State == PipelineStageState.Ok + ? Loc.Format("Pipe_PreviewFirst", PreviewCards.Count, BsonText.Grouped(_outcome.Count)) + : ""; + + /// 预览面板没有卡片时的那一句(计算中 / 无输出 / 上游出错 / 写入阶段不预览 …)。 + public string PreviewNote => _outcome.State switch + { + PipelineStageState.Ok when _outcome.Count == 0 => Loc["Pipe_PreviewEmpty"], + PipelineStageState.Ok => "", + PipelineStageState.Error => _outcome.Error ?? "", + PipelineStageState.Blocked => Loc["Pipe_PreviewBlocked"], + PipelineStageState.Invalid => Loc["Pipe_PreviewInvalid"], + PipelineStageState.Skipped => Loc["Pipe_PreviewSkipped"], + PipelineStageState.WriteStage => Loc["Pipe_PreviewWrite"], + _ => _owner.AutoPreview ? Loc["Pipe_PreviewPending"] : Loc["Pipe_PreviewOff"] + }; + + /// 预览说明是不是一条错误(红字)。 + public bool PreviewNoteIsError => _outcome.State == PipelineStageState.Error; + + /// 能不能放大看全部预览。 + public bool CanShowPreview => _outcome.State == PipelineStageState.Ok && _outcome.Documents.Count > 0; + + /// 上游字段(补全用:前一个启用阶段的输出预览里抽出来的)。 + public IReadOnlyList UpstreamFields + { + get => _upstream; + set => SetProperty(ref _upstream, value); + } + + /// 阶段编辑器的补全来源。 + public Func> CompletionProvider { get; } + + /// 展开 / 折叠。 + public RelayCommand ToggleExpandCommand { get; } + + /// 删除。 + public RelayCommand RemoveCommand { get; } + + /// 复制一张同样的卡片(插在它后面)。 + public RelayCommand DuplicateCommand { get; } + + /// 上移。 + public RelayCommand MoveUpCommand { get; } + + /// 下移。 + public RelayCommand MoveDownCommand { get; } + + /// 放大看全部预览。 + public RelayCommand ShowPreviewCommand { get; } + + /// 把一次预览的结局挂到卡片上。 + internal void ApplyOutcome(PipelineStageOutcome outcome) + { + _outcome = outcome; + PreviewCards = outcome.State == PipelineStageState.Ok ? [.. outcome.Documents.Take(2).Select(d => PipelineResults.Mini(d))] : []; + RaisePropertiesChanged(nameof(Outcome), nameof(PreviewDocuments), nameof(PreviewCards), nameof(HasPreviewCards), nameof(CountText), + nameof(CountTip), nameof(PreviewHeader), nameof(PreviewNote), nameof(PreviewNoteIsError), nameof(CanShowPreview), + nameof(ServerError), nameof(ErrorText), nameof(HasError), nameof(ErrorBadge)); + } + + /// 自动预览开关变了:没算过的卡片要换一句说明。 + internal void RefreshNote() => RaisePropertyChanged(nameof(PreviewNote)); + + /// 当前的文本形态(文本模式、保存、导出用)。 + internal PipelineStageSpec ToSpec() => new(Operator, Body, IsEnabled); + + private void Reparse() + { + _parse = PipelineText.ParseBody(_body); + RaisePropertiesChanged(nameof(Value), nameof(Document), nameof(ParseError), nameof(Diagnostics), nameof(InlineBody), + nameof(ErrorText), nameof(HasError), nameof(ErrorBadge), nameof(EditorHeight)); + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineStagePickerView.axaml b/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineStagePickerView.axaml new file mode 100644 index 0000000..e36aab6 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineStagePickerView.axaml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineStagePickerView.axaml.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineStagePickerView.axaml.cs new file mode 100644 index 0000000..30c4b10 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineStagePickerView.axaml.cs @@ -0,0 +1,81 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 阶段搜索弹层的视图:搜索框里 ↑↓ 选、↵ 确认、Esc 关;单击列表项即选中。 +public sealed partial class PipelineStagePickerView : UserControl +{ + /// 用给定的视图模型初始化。 + internal PipelineStagePickerView(PipelineStagePickerViewModel viewModel) + { + InitializeComponent(); + DataContext = viewModel; + Search.AddHandler(KeyDownEvent, OnSearchKeyDown, RoutingStrategies.Tunnel); + List.AddHandler(PointerReleasedEvent, OnListPointerReleased, RoutingStrategies.Bubble, handledEventsToo: true); + } + + /// 设计器 / headless 用的无参构造。 + public PipelineStagePickerView() + { + InitializeComponent(); + } + + /// Esc:请宿主(弹层)关掉自己。 + public event EventHandler? CloseRequested; + + private PipelineStagePickerViewModel? ViewModel => DataContext as PipelineStagePickerViewModel; + + /// 打开弹层后把焦点给搜索框。 + public void FocusSearch() + { + Search.Focus(); + Search.SelectAll(); + } + + private void OnSearchKeyDown(object? sender, KeyEventArgs e) + { + if (ViewModel is not { } vm) + { + return; + } + switch (e.Key) + { + case Key.Down: + vm.Move(1); + Reveal(); + e.Handled = true; + break; + case Key.Up: + vm.Move(-1); + Reveal(); + e.Handled = true; + break; + case Key.Enter: + vm.Accept(); + e.Handled = true; + break; + case Key.Escape: + CloseRequested?.Invoke(this, EventArgs.Empty); + e.Handled = true; + break; + } + } + + private void OnListPointerReleased(object? sender, PointerReleasedEventArgs e) + { + if ((e.Source as Control)?.DataContext is PipelineStageChoice choice) + { + ViewModel?.Accept(choice); + } + } + + private void Reveal() + { + if (ViewModel?.Selected is { } selected) + { + List.ScrollIntoView(selected); + } + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineStagePickerViewModel.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineStagePickerViewModel.cs new file mode 100644 index 0000000..3474ff4 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineStagePickerViewModel.cs @@ -0,0 +1,131 @@ +using VelaShell.Plugin.Mongo.Shell; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 阶段搜索列表里的一项。 +/// 阶段名($lookup)。 +/// 短说明(关联集合)。 +/// 完整一句(词汇表的说明)。 +internal sealed record PipelineStageChoice(string Name, string Short, string Description); + +/// +/// 「添加阶段 · 输入 $ 可搜索 30+ 个阶段」的搜索弹层,也是阶段卡片上运算符下拉的那张列表。 +/// 一份列表两处用:新加与改运算符面对的是同一个问题("我要哪个阶段"),理应是同一个答案。 +/// +internal sealed class PipelineStagePickerViewModel : ObservableObject +{ + private readonly IReadOnlyList _all; + private string _query = ""; + private IReadOnlyList _items; + private PipelineStageChoice? _selected; + + /// 构造。 + /// 文案。 + public PipelineStagePickerViewModel(Loc loc) + { + Loc = loc; + _all = + [ + .. MongoVocabulary.Stages.Select(entry => + { + string key = "Pipe_Desc_" + entry.Name.TrimStart('$'); + string shortText = loc[key] == key ? loc["Pipe_Desc_custom"] : loc[key]; + return new PipelineStageChoice(entry.Name, shortText, entry.Describe(loc.IsChinese)); + }) + ]; + _items = _all; + _selected = _all.FirstOrDefault(); + } + + /// 文案。 + public Loc Loc { get; } + + /// 选中后回调(由视图挂上:新加阶段,或者给某张卡片换运算符)。 + public Action? Picked { get; set; } + + /// 搜索词($lo、lookup、关联 都能搜到 $lookup)。 + public string Query + { + get => _query; + set + { + if (SetProperty(ref _query, value ?? "")) + { + Filter(); + } + } + } + + /// 过滤后的列表。 + public IReadOnlyList Items + { + get => _items; + private set => SetProperty(ref _items, value); + } + + /// 当前选中。 + public PipelineStageChoice? Selected + { + get => _selected; + set => SetProperty(ref _selected, value); + } + + /// 重置(每次打开弹层)。 + public void Reset(string? current = null) + { + Query = ""; + Selected = _all.FirstOrDefault(c => c.Name == current) ?? _all.FirstOrDefault(); + } + + /// 上下移动选中。 + public void Move(int delta) + { + if (_items.Count == 0) + { + return; + } + int index = _selected is null ? -1 : IndexOf(_selected); + Selected = _items[Math.Clamp(index + delta, 0, _items.Count - 1)]; + } + + /// 确认选中项。 + public void Accept(PipelineStageChoice? choice = null) + { + if ((choice ?? _selected) is { } picked) + { + Picked?.Invoke(picked.Name); + } + } + + private int IndexOf(PipelineStageChoice choice) + { + for (int i = 0; i < _items.Count; i++) + { + if (ReferenceEquals(_items[i], choice)) + { + return i; + } + } + return -1; + } + + private void Filter() + { + string q = _query.Trim().TrimStart('$'); + if (q.Length == 0) + { + Items = _all; + } + else + { + // 名字前缀匹配排前面,其余(名字包含、说明包含)随后 —— 敲 "$s" 先给 $sort / $set 而不是 $densify。 + var prefix = _all.Where(c => c.Name.TrimStart('$').StartsWith(q, StringComparison.OrdinalIgnoreCase)); + var rest = _all.Where(c => !c.Name.TrimStart('$').StartsWith(q, StringComparison.OrdinalIgnoreCase) + && (c.Name.Contains(q, StringComparison.OrdinalIgnoreCase) + || c.Short.Contains(q, StringComparison.OrdinalIgnoreCase) + || c.Description.Contains(q, StringComparison.OrdinalIgnoreCase))); + Items = [.. prefix, .. rest]; + } + Selected = _items.FirstOrDefault(); + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineTabView.axaml b/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineTabView.axaml new file mode 100644 index 0000000..fe8d82d --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineTabView.axaml @@ -0,0 +1,468 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineTabView.axaml.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineTabView.axaml.cs new file mode 100644 index 0000000..99a6bf2 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineTabView.axaml.cs @@ -0,0 +1,292 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Threading; +using Avalonia.VisualTree; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 管道构建器的视图。代码里只做三件 AXAML 做不好的事: +/// 阶段搜索弹层(两处入口共用一张,挂到点的那个控件下面)、抽样数菜单、拖动手柄重排。 +/// +public sealed partial class PipelineTabView : UserControl +{ + private readonly PipelineTabViewModel? _viewModel; + private readonly PipelineStagePickerViewModel? _picker; + private readonly PipelineStagePickerView? _pickerView; + private PipelineStage? _dragging; + private Border? _draggingCard; + + /// 用给定的视图模型初始化。 + internal PipelineTabView(PipelineTabViewModel viewModel) + { + InitializeComponent(); + DataContext = viewModel; + _viewModel = viewModel; + _picker = new PipelineStagePickerViewModel(viewModel.Loc); + _pickerView = new PipelineStagePickerView(_picker); + _pickerView.CloseRequested += (_, _) => PickerPopup.IsOpen = false; + PickerPopup.Child = _pickerView; + + // 拖动手柄:按下时把指针捕获到列表本身(而不是手柄)—— 重排时卡片的容器会被拆了重建, + // 捕获在手柄上就会在第一次换位时丢掉。 + StageList.AddHandler(PointerPressedEvent, OnStagePointerPressed, RoutingStrategies.Tunnel); + StageList.AddHandler(PointerMovedEvent, OnStagePointerMoved, RoutingStrategies.Tunnel); + StageList.AddHandler(PointerReleasedEvent, OnStagePointerReleased, RoutingStrategies.Tunnel); + StageList.AddHandler(PointerCaptureLostEvent, (_, _) => EndDrag(), RoutingStrategies.Bubble); + ResultScroller.SizeChanged += (_, _) => FitResultTable(); + ColumnFit.OnGripDoubleClick(ResultHeader, column => + { + if (column is PipelineColumn c) + { + FitResultColumn(c); + } + }); + } + + /// 设计器 / headless 用的无参构造。 + public PipelineTabView() + { + InitializeComponent(); + } + + // ── 阶段搜索弹层 ─────────────────────────────────────────────────────── + + /// 在 下面打开阶段列表,选中后回调。 + private void OpenPicker(Control anchor, string? current, Action picked) + { + if (_picker is null || _pickerView is null) + { + return; + } + _picker.Reset(current); + _picker.Picked = op => + { + PickerPopup.IsOpen = false; + picked(op); + }; + PickerPopup.PlacementTarget = anchor; + PickerPopup.IsOpen = true; + Dispatcher.UIThread.Post(_pickerView.FocusSearch, DispatcherPriority.Input); + } + + private void OnAddStageClick(object? sender, RoutedEventArgs e) => OpenAddPicker(AddStageTool); + + private void OnAddStageBoxReleased(object? sender, PointerReleasedEventArgs e) + { + if (e.InitialPressMouseButton == MouseButton.Left) + { + OpenAddPicker(AddStageBox); + } + } + + private void OpenAddPicker(Control anchor) + { + if (_viewModel is not { } vm) + { + return; + } + OpenPicker(anchor, null, op => + { + PipelineStage stage = vm.AddStage(op); + Dispatcher.UIThread.Post(() => Reveal(stage), DispatcherPriority.Background); + }); + } + + private void OnOperatorClick(object? sender, RoutedEventArgs e) + { + if (_viewModel is not { } vm || sender is not Control { DataContext: PipelineStage stage } anchor) + { + return; + } + OpenPicker(anchor, stage.Operator, op => vm.ChangeOperator(stage, op)); + } + + /// 新加的卡片滚进视野(加在末尾时它在可视区下方)。 + private void Reveal(PipelineStage stage) + { + if (StageList.ContainerFromItem(stage) is Control container) + { + container.BringIntoView(); + } + } + + /// + /// 阶段编辑器按行数定高、整段摆出,所以关掉它自己的竖向滚动: + /// 否则滚轮停在编辑器上时被它吃掉,外面那一列卡片滚不动。 + /// + private void OnStageEditorLoaded(object? sender, RoutedEventArgs e) + { + if (sender is not CodeEditor editor || editor.Tag is "pipeline-hooked") + { + return; + } + editor.Tag = "pipeline-hooked"; + editor.Editor.VerticalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Disabled; + // 诊断行尾写着「Alt+↵ 修复」(含点号的裸键加引号):在这里兑现它。 + editor.AddHandler(KeyDownEvent, (_, args) => + { + if (args.Key == Key.Enter && args.KeyModifiers == KeyModifiers.Alt + && editor.DataContext is PipelineStage stage + && stage.Diagnostics.FirstOrDefault(static d => d.FixText is not null) is { FixText: { } fix } diagnostic) + { + editor.Replace(diagnostic.Offset, diagnostic.Length, fix); + args.Handled = true; + } + }, RoutingStrategies.Tunnel); + } + + // ── 抽样数 ───────────────────────────────────────────────────────────── + + private void OnSampleClick(object? sender, RoutedEventArgs e) + { + if (_viewModel is not { } vm) + { + return; + } + var items = new List(); + foreach (int size in PipelineTabViewModel.SampleChoices) + { + items.Add(new MenuItem + { + Header = vm.Loc.Format("Pipe_Sample", size), + Icon = size == vm.SampleSize + ? new Glyph { Key = "Mongo.check", Size = 12, Brush = ThemeBrushes.Get("VelaAccent", Avalonia.Media.Brushes.Gray) } + : null, + Command = new RelayCommand(() => vm.SampleSize = size) + }); + } + var menu = new ContextMenu { ItemsSource = items, Placement = PlacementMode.BottomEdgeAlignedLeft }; + menu.Open(SampleButton); + } + + // ── 拖动重排 ─────────────────────────────────────────────────────────── + + private void OnStagePointerPressed(object? sender, PointerPressedEventArgs e) + { + if (e.Source is not Visual source || !e.GetCurrentPoint(StageList).Properties.IsLeftButtonPressed) + { + return; + } + // 只认手柄:从按下的元素往上找,先碰到卡片外框就说明按的不是手柄。 + Border? grip = null; + for (Visual? v = source; v is not null && !ReferenceEquals(v, StageList); v = v.GetVisualParent()) + { + if (v is Border border && border.Classes.Contains("grip")) + { + grip = border; + break; + } + if (v is Border card && card.Classes.Contains("stage")) + { + return; + } + } + if (grip?.DataContext is not PipelineStage stage) + { + return; + } + _dragging = stage; + _draggingCard = grip.GetVisualAncestors().OfType().FirstOrDefault(static b => b.Classes.Contains("stage")); + _draggingCard?.Classes.Add("dragging"); + e.Pointer.Capture(StageList); + e.Handled = true; + } + + private void OnStagePointerMoved(object? sender, PointerEventArgs e) + { + if (_dragging is not { } stage || _viewModel is not { } vm) + { + return; + } + double y = e.GetPosition(StageList).Y; + int target = vm.Stages.Count - 1; + for (int i = 0; i < vm.Stages.Count; i++) + { + if (StageList.ContainerFromIndex(i) is not Control container) + { + continue; + } + Rect bounds = container.Bounds; + if (y < bounds.Y + bounds.Height / 2) + { + target = i; + break; + } + } + int from = vm.Stages.IndexOf(stage); + // 往下拖时目标是"越过了谁的中线":落在它后面,索引要减掉自己腾出来的那一格。 + if (target > from) + { + target = Math.Max(from, target - 1); + if (StageList.ContainerFromIndex(vm.Stages.Count - 1) is Control last && y >= last.Bounds.Y + last.Bounds.Height / 2) + { + target = vm.Stages.Count - 1; + } + } + if (target != from) + { + vm.MoveStageTo(stage, target); + RestyleDragged(); + } + } + + private void OnStagePointerReleased(object? sender, PointerReleasedEventArgs e) + { + if (_dragging is null) + { + return; + } + e.Pointer.Capture(null); + EndDrag(); + e.Handled = true; + } + + private void EndDrag() + { + _draggingCard?.Classes.Remove("dragging"); + _draggingCard = null; + _dragging = null; + } + + /// 换位后卡片的容器可能是新的:把高亮挪到新容器上。 + private void RestyleDragged() + { + _draggingCard?.Classes.Remove("dragging"); + _draggingCard = null; + if (_dragging is { } stage && StageList.ContainerFromItem(stage) is Control container + && container.GetVisualDescendants().OfType().FirstOrDefault(static b => b.Classes.Contains("stage")) is { } card) + { + card.Classes.Add("dragging"); + _draggingCard = card; + } + } + + // ── 输出表 ───────────────────────────────────────────────────────────── + + /// 表比视口窄时撑满视口(行的底线一直画到右边),比视口宽时横向滚动。 + private void FitResultTable() => ResultTable.MinWidth = Math.Max(0, ResultScroller.Bounds.Width); + + /// 拖列头分隔线改列宽。 + private void OnResultResizeDelta(object? sender, Avalonia.Input.VectorEventArgs e) + { + if ((sender as Control)?.DataContext is PipelineColumn column) + { + column.Width += e.Vector.X; + } + } + + /// 双击列头分隔线:按列头与已实现的行里这一列的内容自适应。 + private void FitResultColumn(PipelineColumn column) + { + double width = ColumnFit.Widest(ResultTable.GetVisualDescendants().OfType() + .Where(b => b.Classes.Contains("rcell") + && (ReferenceEquals(b.DataContext, column) || b.DataContext is PipelineCell cell && ReferenceEquals(cell.Column, column)))); + if (width > 0) + { + column.Width = Math.Ceiling(width) + 2; + } + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineTabViewModel.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineTabViewModel.cs new file mode 100644 index 0000000..8167a81 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineTabViewModel.cs @@ -0,0 +1,1378 @@ +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.Diagnostics; +using MongoDB.Bson; +using MongoDB.Driver; +using VelaShell.Plugin.Mongo.Bson; +using VelaShell.Plugin.Mongo.Core; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 管道构建器的两种编辑方式。 +internal enum PipelineMode +{ + /// 阶段卡片。 + Stages, + + /// 整条管道一段文本。 + Text +} + +/// +/// 聚合管道构建器(设计稿 04):阶段卡片 + 逐阶段输出预览 + 整条运行的输出表。 +/// +/// 三件事分开算:**预览**只跑抽样($limit N 之后的前缀管道),便宜、自动、每次改动后防抖重算; +/// **运行**跑整条管道,按按钮才跑,结果进底部的输出表;**执行计划**交给查询编辑器的 explain 去画。 +/// 把预览和运行混成一件事,要么预览慢得没法边打边看,要么"运行"的数字其实是抽样 —— 两头都不诚实。 +/// +/// +internal sealed class PipelineTabViewModel : WorkspaceTab +{ + /// 输出表最多装多少份(再多请去结果标签里翻页)。 + public const int ResultLimit = 500; + + /// 抽样数的可选值。 + public static IReadOnlyList SampleChoices { get; } = [10, 20, 50, 100, 500, 1000]; + + private PipelineMode _mode = PipelineMode.Stages; + private string _pipelineText = ""; + private bool _syncingText; + private IReadOnlyList _textDiagnostics = []; + private string _textError = ""; + private bool _autoPreview = true; + private int _sampleSize = 20; + private bool _allowDiskUse = true; + private string _sourceCount = ""; + private bool _bulk; + private CancellationTokenSource? _previewDebounce; + private CancellationTokenSource? _previewRun; + private CancellationTokenSource? _textDebounce; + private CancellationTokenSource? _runCts; + private string? _runComment; + private bool _isPreviewing; + private TimeSpan? _previewElapsed; + private string _previewError = ""; + private IReadOnlyList _inputSample = []; + private string _savedSnapshot; + private string? _savedName; + private IReadOnlyList _columns = []; + private IReadOnlyList _rows = []; + private bool _hasRun; + private bool _isRunning; + private string _runError = ""; + private string _resultSummary = ""; + private string _activity = ""; + + /// 构造。 + /// 外壳服务。 + /// 源集合(或视图)。 + /// 带进来的管道(视图的定义、查询编辑器里的 aggregate);空则从一个 $match 起步。 + public PipelineTabViewModel(IMongoWorkspace workspace, CollectionInfo info, BsonArray? pipeline) + : base(workspace) + { + Info = info; + Title = Loc.Format("Pipe_Title", info.Name); + Scope = "@" + info.Database; + Stages.CollectionChanged += OnStagesChanged; + + _bulk = true; + if (pipeline is { Count: > 0 }) + { + foreach (BsonValue item in pipeline) + { + if (item is BsonDocument { ElementCount: > 0 } stage) + { + BsonElement element = stage.GetElement(0); + Stages.Add(new PipelineStage(this, element.Name, PipelineText.FormatBody(element.Value))); + } + } + } + if (Stages.Count == 0) + { + Stages.Add(new PipelineStage(this, "$match", PipelineText.DefaultBody("$match"))); + } + Stages[0].IsExpanded = true; + _bulk = false; + _savedSnapshot = Snapshot(); + + RunCommand = new AsyncCommand(RunAsync, () => !IsRunning); + StopCommand = new RelayCommand(StopRun); + ExplainCommand = new RelayCommand(Explain); + SaveCommand = new AsyncCommand(SaveAsync); + CreateViewCommand = new AsyncCommand(CreateViewAsync); + ExportCodeCommand = new RelayCommand(ExportCode); + OpenResultsCommand = new RelayCommand(OpenInResults); + SaveAsCollectionCommand = new AsyncCommand(SaveAsCollectionAsync); + ToggleAllowDiskUseCommand = new RelayCommand(() => AllowDiskUse = !AllowDiskUse); + RefreshPreviewCommand = new AsyncCommand(RefreshPreviewAsync); + UpdateStatus(); + } + + /// 源集合。 + public CollectionInfo Info { get; } + + /// 库名。 + public string Database => Info.Database; + + /// 集合名。 + public string CollectionName => Info.Name; + + /// + public override TabKind Kind => TabKind.Pipeline; + + /// + public override string Key => $"pipeline:{Info.Namespace}"; + + /// + public override string IconKey => "Mongo.workflow"; + + /// + public override string IconToken => "VelaAccent"; + + /// 阶段卡片。 + public ObservableCollection Stages { get; } = []; + + // ── 工具行 ───────────────────────────────────────────────────────────── + + /// 源集合芯片的文字(shop.orders)。 + public string SourceLabel => Info.Namespace; + + /// 源集合的文档数缩写(1.28M)。 + public string SourceCount + { + get => _sourceCount; + private set => SetProperty(ref _sourceCount, value); + } + + /// 源集合图标(视图是眼睛,时序集合是柱状)。 + public string SourceIcon => Info.Kind switch + { + CollectionKind.View => "Mongo.eye", + CollectionKind.TimeSeries => "Mongo.chart-no-axes-column", + _ => "Mongo.table-2" + }; + + /// 当前编辑方式。 + public PipelineMode Mode => _mode; + + /// 「阶段」分段。 + public bool IsStagesMode + { + get => _mode == PipelineMode.Stages; + set + { + if (value) + { + SwitchMode(PipelineMode.Stages); + } + } + } + + /// 「文本」分段。 + public bool IsTextMode + { + get => _mode == PipelineMode.Text; + set + { + if (value) + { + SwitchMode(PipelineMode.Text); + } + } + } + + /// 自动预览。 + public bool AutoPreview + { + get => _autoPreview; + set + { + if (!SetProperty(ref _autoPreview, value)) + { + return; + } + RaisePropertiesChanged(nameof(PreviewSummary), nameof(CanRefreshPreview)); + foreach (PipelineStage stage in Stages) + { + stage.RefreshNote(); + } + if (value) + { + SchedulePreview(immediate: true); + } + else + { + _previewDebounce?.Cancel(); + } + } + } + + /// 预览的抽样输入文档数。 + public int SampleSize + { + get => _sampleSize; + set + { + value = Math.Clamp(value, 1, 100_000); + if (SetProperty(ref _sampleSize, value)) + { + RaisePropertiesChanged(nameof(SampleText), nameof(SampleTip), nameof(PreviewSummary)); + if (AutoPreview) + { + SchedulePreview(immediate: true); + } + UpdateStatus(); + } + } + } + + /// 抽样 20。 + public string SampleText => Loc.Format("Pipe_Sample", _sampleSize); + + /// 抽样数的悬停说明。 + public string SampleTip => Loc.Format("Pipe_SampleTip", _sampleSize); + + /// allowDiskUse(底栏右侧可切)。 + public bool AllowDiskUse + { + get => _allowDiskUse; + set + { + if (SetProperty(ref _allowDiskUse, value)) + { + RaisePropertyChanged(nameof(AllowDiskUseText)); + } + } + } + + /// allowDiskUse · 已开启。 + public string AllowDiskUseText => Loc.Format("Pipe_AllowDiskUse", Loc[_allowDiskUse ? "Pipe_On" : "Pipe_Off"]); + + // ── 文本模式 ─────────────────────────────────────────────────────────── + + /// 整条管道的文本(文本模式的编辑器双向绑定)。 + public string PipelineTextValue + { + get => _pipelineText; + set + { + if (!SetProperty(ref _pipelineText, value ?? "") || _syncingText) + { + return; + } + ScheduleTextSync(); + } + } + + /// 文本的诊断。 + public IReadOnlyList TextDiagnostics + { + get => _textDiagnostics; + private set => SetProperty(ref _textDiagnostics, value); + } + + /// 文本解析失败的原因;同步成功为空。 + public string TextError + { + get => _textError; + private set + { + if (SetProperty(ref _textError, value)) + { + RaisePropertyChanged(nameof(HasTextError)); + } + } + } + + /// 文本有错(停在文本模式)。 + public bool HasTextError => _textError.Length > 0; + + /// 文本模式编辑器的补全(阶段名、运算符、源集合的字段)。 + public Func> TextCompletionProvider => request => + Task.FromResult(PipelineFields.Complete(request, null, PipelineFields.Sample(_inputSample), SampleSize, Loc)); + + // ── 预览与底栏 ───────────────────────────────────────────────────────── + + /// 正在算预览。 + public bool IsPreviewing + { + get => _isPreviewing; + private set + { + if (SetProperty(ref _isPreviewing, value)) + { + RaisePropertyChanged(nameof(PreviewSummary)); + } + } + } + + /// 底栏左侧:6 个阶段 · 5 个启用(有错时加「· 1 个有错」)。 + public string StageSummary + { + get + { + int enabled = Stages.Count(static s => s.IsEnabled); + string text = Loc.Format("Pipe_StageSummary", Stages.Count, enabled); + int errors = ErrorCount; + return errors > 0 ? text + " · " + Loc.Format("Pipe_ErrorCount", errors) : text; + } + } + + /// 有错的阶段数(启用的阶段里)。 + public int ErrorCount => Stages.Count(static s => s.IsEnabled && s.HasError); + + /// 底栏的状态图标。 + public string SummaryIcon => ErrorCount > 0 || HasTextError ? "Mongo.circle-x" : "Mongo.circle-check"; + + /// 底栏状态图标颜色。 + public string SummaryToken => ErrorCount > 0 || HasTextError ? "VelaError" : "VelaStatusConnected"; + + /// 底栏中间:预览基于前 20 份输入文档 · 64 ms。 + public string PreviewSummary + { + get + { + if (!_autoPreview && _previewElapsed is null) + { + return Loc["Pipe_PreviewOffHint"]; + } + if (_isPreviewing) + { + return Loc.Format("Pipe_Previewing", _sampleSize); + } + if (_previewError.Length > 0) + { + return Loc.Format("Pipe_PreviewFailed", _previewError); + } + return _previewElapsed is { } elapsed + ? Loc.Format("Pipe_PreviewBasis", _sampleSize, PipelineResults.Elapsed(elapsed)) + : Loc.Format("Pipe_PreviewBasisPending", _sampleSize); + } + } + + /// 自动预览关着时,底栏那句可以点一下手动算一次。 + public bool CanRefreshPreview => !_autoPreview; + + // ── 输出表 ───────────────────────────────────────────────────────────── + + /// 输出表的列。 + public IReadOnlyList ResultColumns + { + get => _columns; + private set => SetProperty(ref _columns, value); + } + + /// 输出表的行。 + public IReadOnlyList ResultRows + { + get => _rows; + private set => SetProperty(ref _rows, value); + } + + /// 输出表的总宽(横向滚动用)。 + public double ResultWidth => PipelineResults.NumberWidth + _columns.Sum(static c => c.Width); + + /// 输出表的高度:最多十二行,再多在表里滚。 + public double ResultListHeight => Math.Max(1, Math.Min(_rows.Count, 12)) * PipelineResults.RowHeight; + + /// 跑过一次(输出卡片才出现)。 + public bool HasRun + { + get => _hasRun; + private set => SetProperty(ref _hasRun, value); + } + + /// 有输出行。 + public bool HasRows => _rows.Count > 0; + + /// 跑完了、没出错、但一份文档也没有(给一句话,而不是一张空表)。 + public bool ShowEmptyResult => _hasRun && !_isRunning && _runError.Length == 0 && _rows.Count == 0; + + /// 正在运行。 + public bool IsRunning + { + get => _isRunning; + private set + { + if (SetProperty(ref _isRunning, value)) + { + RunCommand.RaiseCanExecuteChanged(); + RaisePropertiesChanged(nameof(ResultSummary), nameof(ShowEmptyResult)); + } + } + } + + /// 运行的报错。 + public string RunError + { + get => _runError; + private set + { + if (SetProperty(ref _runError, value)) + { + RaisePropertiesChanged(nameof(HasRunError), nameof(ShowEmptyResult)); + } + } + } + + /// 运行失败。 + public bool HasRunError => _runError.Length > 0; + + /// 输出卡片标题旁:10 份文档 · 完整运行 1.4 s。 + public string ResultSummary => _isRunning ? Loc["Pipe_Running"] : _resultSummary; + + // ── 命令 ─────────────────────────────────────────────────────────────── + + /// 运行整条管道。 + public AsyncCommand RunCommand { get; } + + /// 停止运行(取消 + 按 comment killOp)。 + public RelayCommand StopCommand { get; } + + /// 执行计划(交给查询编辑器 explain)。 + public RelayCommand ExplainCommand { get; } + + /// 保存管道。 + public AsyncCommand SaveCommand { get; } + + /// 创建视图。 + public AsyncCommand CreateViewCommand { get; } + + /// 导出为代码。 + public RelayCommand ExportCodeCommand { get; } + + /// 在结果标签中打开。 + public RelayCommand OpenResultsCommand { get; } + + /// 另存为集合($out / $merge)。 + public AsyncCommand SaveAsCollectionCommand { get; } + + /// 切 allowDiskUse。 + public RelayCommand ToggleAllowDiskUseCommand { get; } + + /// 手动算一次预览。 + public AsyncCommand RefreshPreviewCommand { get; } + + // ── 生命周期 ─────────────────────────────────────────────────────────── + + /// + public override async Task LoadAsync() + { + await LoadSourceCountAsync().ConfigureAwait(true); + if (AutoPreview) + { + await RefreshPreviewAsync().ConfigureAwait(true); + } + } + + /// + public override async Task RefreshAsync() + { + await LoadSourceCountAsync().ConfigureAwait(true); + await RefreshPreviewAsync().ConfigureAwait(true); + } + + /// + public override void Dispose() + { + foreach (CancellationTokenSource? cts in new[] { _previewDebounce, _previewRun, _textDebounce, _runCts }) + { + try + { + cts?.Cancel(); + } + catch (ObjectDisposedException) + { + } + } + base.Dispose(); + } + + private async Task LoadSourceCountAsync() + { + try + { + long? count = await Workspace.Connection.EstimatedCountAsync(Database, CollectionName).ConfigureAwait(true); + SourceCount = count is { } n ? BsonText.Count(n) : ""; + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + SourceCount = ""; + } + } + + // ── 阶段增删改 ───────────────────────────────────────────────────────── + + /// 加一个阶段(默认在末尾),展开它。 + internal PipelineStage AddStage(string op, int? index = null) + { + var stage = new PipelineStage(this, op, PipelineText.DefaultBody(op)) { IsExpanded = true }; + int at = Math.Clamp(index ?? Stages.Count, 0, Stages.Count); + Stages.Insert(at, stage); + return stage; + } + + /// + /// 换运算符。阶段体还是旧运算符的默认模板(或者是空的)时一并换成新模板 —— + /// 用户写过的阶段体不动(从 $project 换成 $addFields 时原样保留恰恰是对的)。 + /// + internal void ChangeOperator(PipelineStage stage, string op) + { + if (stage.Operator == op) + { + return; + } + bool untouched = string.IsNullOrWhiteSpace(stage.Body) + || Squash(stage.Body) == Squash(PipelineText.DefaultBody(stage.Operator)); + _bulk = true; + stage.Operator = op; + if (untouched) + { + stage.Body = PipelineText.DefaultBody(op); + } + _bulk = false; + OnPipelineChanged(); + + static string Squash(string text) => string.Concat(text.Where(static c => !char.IsWhiteSpace(c))); + } + + /// 删除一个阶段(提示里带「撤销」)。 + internal void RemoveStage(PipelineStage stage) + { + int index = Stages.IndexOf(stage); + if (index < 0) + { + return; + } + Stages.RemoveAt(index); + Workspace.Toast(new() + { + Title = Loc.Format("Pipe_Removed", index + 1, stage.Operator), + ActionLabel = Loc["Pipe_Undo"], + Action = () => + { + Stages.Insert(Math.Min(index, Stages.Count), stage); + return Task.CompletedTask; + }, + Duration = TimeSpan.FromSeconds(8) + }); + } + + /// 复制一张卡片,插在它后面。 + internal void DuplicateStage(PipelineStage stage) + { + int index = Stages.IndexOf(stage); + var copy = new PipelineStage(this, stage.Operator, stage.Body, stage.IsEnabled) { IsExpanded = stage.IsExpanded }; + Stages.Insert(index + 1, copy); + } + + /// 上下移一格。 + internal void MoveStage(PipelineStage stage, int delta) + { + int from = Stages.IndexOf(stage); + int to = from + delta; + if (from < 0 || to < 0 || to >= Stages.Count) + { + return; + } + Stages.Move(from, to); + } + + /// 拖到某个位置(拖拽重排)。 + internal void MoveStageTo(PipelineStage stage, int to) + { + int from = Stages.IndexOf(stage); + to = Math.Clamp(to, 0, Stages.Count - 1); + if (from >= 0 && from != to) + { + Stages.Move(from, to); + } + } + + /// 放大看某个阶段的全部预览。 + internal void ShowStagePreview(PipelineStage stage) + { + if (!stage.CanShowPreview) + { + return; + } + Workspace.ShowDialog(new PipelinePreviewDialogViewModel( + Workspace, + Loc.Format("Pipe_PreviewTitle", stage.Index, stage.Operator), + Loc.Format("Pipe_PreviewSubtitle", stage.PreviewDocuments.Count, stage.CountText, SampleSize), + stage.PreviewDocuments)); + } + + /// + /// 用一段管道文本整体替换阶段(截图与单测用; 时视为刚打开、未修改)。 + /// + internal bool LoadFromText(string text, bool markClean = false) + { + PipelineParse parse = PipelineText.Parse(text); + if (!parse.Ok) + { + return false; + } + SyncStages(parse.Stages); + if (markClean) + { + _savedSnapshot = Snapshot(); + UpdateModified(); + } + return true; + } + + private void OnStagesChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (e.OldItems is not null) + { + foreach (PipelineStage stage in e.OldItems) + { + stage.Changed -= OnStageChanged; + } + } + if (e.NewItems is not null) + { + foreach (PipelineStage stage in e.NewItems) + { + stage.Changed -= OnStageChanged; + stage.Changed += OnStageChanged; + } + } + if (e.Action == NotifyCollectionChangedAction.Reset) + { + foreach (PipelineStage stage in Stages) + { + stage.Changed -= OnStageChanged; + stage.Changed += OnStageChanged; + } + } + for (int i = 0; i < Stages.Count; i++) + { + Stages[i].Index = i + 1; + } + if (!_bulk) + { + OnPipelineChanged(); + } + } + + private void OnStageChanged(PipelineStage stage) + { + if (!_bulk) + { + OnPipelineChanged(); + } + } + + /// 管道变了:更新底栏、未保存标记,排一次预览。 + private void OnPipelineChanged() + { + RaisePropertiesChanged(nameof(StageSummary), nameof(ErrorCount), nameof(SummaryIcon), nameof(SummaryToken)); + UpdateModified(); + if (AutoPreview) + { + SchedulePreview(); + } + } + + private void UpdateModified() + { + IsModified = Snapshot() != _savedSnapshot; + UpdateStatus(); + } + + private string Snapshot() => PipelineText.Format(Stages.Select(static s => s.ToSpec())); + + // ── 两种模式之间 ─────────────────────────────────────────────────────── + + private void SwitchMode(PipelineMode mode) + { + if (_mode == mode) + { + return; + } + if (mode == PipelineMode.Text) + { + _syncingText = true; + PipelineTextValue = Snapshot(); + _syncingText = false; + TextError = ""; + TextDiagnostics = []; + _mode = mode; + } + else + { + _textDebounce?.Cancel(); + // 解析失败就停在文本模式:切回卡片会丢掉文本里那段改动,而用户多半正改到一半。 + if (!ApplyText()) + { + Workspace.Toast(new() { Title = Loc["Pipe_TextInvalid"], Detail = TextError, Kind = ToastKind.Warning }); + RaisePropertiesChanged(nameof(IsStagesMode), nameof(IsTextMode)); + return; + } + _mode = mode; + } + RaisePropertiesChanged(nameof(Mode), nameof(IsStagesMode), nameof(IsTextMode)); + } + + private void ScheduleTextSync() + { + _textDebounce?.Cancel(); + var cts = new CancellationTokenSource(); + _textDebounce = cts; + _ = SyncTextLaterAsync(cts.Token); + } + + private async Task SyncTextLaterAsync(CancellationToken cancellationToken) + { + try + { + await Task.Delay(300, cancellationToken).ConfigureAwait(true); + } + catch (OperationCanceledException) + { + return; + } + ApplyText(); + } + + /// 把文本解析回卡片;失败时标错并返回 (卡片不动)。 + internal bool ApplyText() + { + PipelineParse parse = PipelineText.Parse(_pipelineText); + if (!parse.Ok) + { + string message = Loc.Format(parse.ErrorKey!, parse.ErrorArgument); + TextError = message; + TextDiagnostics = [new EditorDiagnostic(Math.Clamp(parse.ErrorOffset, 0, Math.Max(0, _pipelineText.Length - 1)), parse.ErrorLength, message)]; + RaisePropertiesChanged(nameof(SummaryIcon), nameof(SummaryToken)); + return false; + } + TextError = ""; + TextDiagnostics = []; + RaisePropertiesChanged(nameof(SummaryIcon), nameof(SummaryToken)); + SyncStages(parse.Stages); + return true; + } + + /// 按位置就地更新卡片(保留展开状态),多出的加、少了的删。 + private void SyncStages(IReadOnlyList specs) + { + _bulk = true; + try + { + for (int i = 0; i < specs.Count; i++) + { + PipelineStageSpec spec = specs[i]; + if (i < Stages.Count) + { + PipelineStage stage = Stages[i]; + stage.Operator = spec.Operator; + stage.Body = spec.Body; + stage.IsEnabled = spec.Enabled; + } + else + { + Stages.Add(new PipelineStage(this, spec.Operator, spec.Body, spec.Enabled)); + } + } + while (Stages.Count > specs.Count) + { + Stages.RemoveAt(Stages.Count - 1); + } + } + finally + { + _bulk = false; + } + OnPipelineChanged(); + } + + // ── 预览 ─────────────────────────────────────────────────────────────── + + private IMongoCollection Collection => Workspace.Connection.Collection(Database, CollectionName); + + private TimeSpan? MaxTime => Workspace.Connection.Settings.MaxTimeMs > 0 + ? TimeSpan.FromMilliseconds(Workspace.Connection.Settings.MaxTimeMs) + : null; + + private void SchedulePreview(bool immediate = false) + { + _previewDebounce?.Cancel(); + var cts = new CancellationTokenSource(); + _previewDebounce = cts; + _ = PreviewLaterAsync(immediate ? TimeSpan.Zero : TimeSpan.FromMilliseconds(400), cts.Token); + } + + private async Task PreviewLaterAsync(TimeSpan delay, CancellationToken cancellationToken) + { + try + { + if (delay > TimeSpan.Zero) + { + await Task.Delay(delay, cancellationToken).ConfigureAwait(true); + } + } + catch (OperationCanceledException) + { + return; + } + if (!cancellationToken.IsCancellationRequested) + { + await RefreshPreviewAsync().ConfigureAwait(true); + } + } + + /// + /// 算一次逐阶段预览(新的一次作废上一次:用户连着改,只有最后那份结果该落到卡片上)。 + /// + internal async Task RefreshPreviewAsync() + { + _previewRun?.Cancel(); + var cts = new CancellationTokenSource(); + _previewRun = cts; + List stages = [.. Stages]; + List<(BsonDocument? Stage, bool Enabled)> input = [.. stages.Select(static s => (s.Document, s.IsEnabled))]; + IsPreviewing = true; + try + { + var options = new AggregateOptions + { + AllowDiskUse = AllowDiskUse, + MaxTime = MaxTime, + Comment = "velashell-pipeline-preview" + }; + PipelinePreviewRun run = await PipelinePreview.RunAsync(Collection, input, SampleSize, options, cts.Token).ConfigureAwait(true); + if (cts.IsCancellationRequested) + { + return; + } + ApplyPreview(stages, run); + } + catch (OperationCanceledException) + { + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // 预览从不写,任何失败都只是"这次没算出来":记在底栏,不弹错误框打断用户打字。 + if (!cts.IsCancellationRequested) + { + _previewError = MongoConnector.Describe(ex); + RaisePropertyChanged(nameof(PreviewSummary)); + } + } + finally + { + if (ReferenceEquals(_previewRun, cts)) + { + IsPreviewing = false; + } + } + } + + private void ApplyPreview(List stages, PipelinePreviewRun run) + { + _previewError = ""; + _inputSample = run.Input; + IReadOnlyList upstream = PipelineFields.Sample(run.Input); + for (int i = 0; i < stages.Count && i < run.Stages.Count; i++) + { + PipelineStage stage = stages[i]; + PipelineStageOutcome outcome = run.Stages[i]; + stage.ApplyOutcome(outcome); + stage.UpstreamFields = upstream; + if (stage.IsEnabled && outcome.State == PipelineStageState.Ok) + { + upstream = PipelineFields.Sample(outcome.Documents); + } + } + _previewElapsed = run.Elapsed; + _activity = Loc.Format("Pipe_StatusPreview", PipelineResults.Elapsed(run.Elapsed), SampleSize); + RaisePropertiesChanged(nameof(PreviewSummary), nameof(StageSummary), nameof(ErrorCount), nameof(SummaryIcon), nameof(SummaryToken)); + UpdateStatus(); + } + + // ── 运行 ─────────────────────────────────────────────────────────────── + + /// 启用的阶段(运行、保存为视图、导出用)。 + internal List EnabledPipeline() => + [.. Stages.Where(static s => s.IsEnabled).Select(static s => s.Document).OfType()]; + + /// + /// 跑之前的检查:文本模式先同步,启用的阶段都要解析得了。 + /// 返回 时已经提示过用户。 + /// + private bool EnsureRunnable() + { + if (_mode == PipelineMode.Text && !ApplyText()) + { + Workspace.Toast(new() { Title = Loc["Pipe_TextInvalid"], Detail = TextError, Kind = ToastKind.Warning }); + return false; + } + if (Stages.FirstOrDefault(static s => s.IsEnabled && s.Document is null) is { } invalid) + { + invalid.IsExpanded = true; + Workspace.Toast(new() { Title = Loc.Format("Pipe_RunInvalid", invalid.Index), Detail = invalid.ParseError, Kind = ToastKind.Warning }); + return false; + } + return true; + } + + /// 运行整条管道,结果进输出表。 + internal async Task RunAsync() + { + if (IsRunning || !EnsureRunnable()) + { + return; + } + List pipeline = EnabledPipeline(); + (string Database, string Collection)? target = WriteTarget(pipeline); + if (target is { } write && !await ConfirmWriteAsync(pipeline[^1].GetElement(0).Name, write).ConfigureAwait(true)) + { + return; + } + await ExecuteAsync(pipeline, target).ConfigureAwait(true); + } + + private async Task ExecuteAsync(List pipeline, (string Database, string Collection)? target) + { + var cts = new CancellationTokenSource(); + _runCts = cts; + _runComment = $"velashell-pipeline-{Guid.NewGuid():N}"; + IsRunning = true; + RunError = ""; + HasRun = true; + var watch = Stopwatch.StartNew(); + try + { + var options = new AggregateOptions { AllowDiskUse = AllowDiskUse, MaxTime = MaxTime, Comment = _runComment }; + using IAsyncCursor cursor = await Collection + .AggregateAsync(PipelineDefinition.Create(pipeline), options, cts.Token) + .ConfigureAwait(true); + var docs = new List(); + bool more = false; + while (!more && await cursor.MoveNextAsync(cts.Token).ConfigureAwait(true)) + { + foreach (BsonDocument doc in cursor.Current) + { + if (docs.Count >= ResultLimit) + { + more = true; + break; + } + docs.Add(doc); + } + } + watch.Stop(); + IReadOnlyList ghosts = target is null ? await GhostsAsync(cts.Token).ConfigureAwait(true) : []; + SetResults(docs, more, watch.Elapsed, ghosts); + if (target is { } write) + { + Workspace.Toast(new() + { + Title = Loc.Format("Pipe_WriteDone", $"{write.Database}.{write.Collection}"), + Detail = PipelineResults.Elapsed(watch.Elapsed), + Kind = ToastKind.Success, + ActionLabel = Loc["Pipe_Open"], + Action = () => + { + Workspace.OpenCollection(write.Database, write.Collection); + return Task.CompletedTask; + }, + Duration = TimeSpan.FromSeconds(8) + }); + await Workspace.RefreshTreeAsync(write.Database).ConfigureAwait(true); + } + } + catch (OperationCanceledException) + { + RunError = Loc["Pipe_RunCancelled"]; + _resultSummary = ""; + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + RunError = MongoConnector.Describe(ex); + _resultSummary = ""; + ResultColumns = []; + ResultRows = []; + RaisePropertiesChanged(nameof(HasRows), nameof(ResultWidth), nameof(ResultListHeight), nameof(ShowEmptyResult)); + Workspace.Toast(new() { Title = Loc.Format("Common_Failed", RunError), Kind = ToastKind.Error }); + } + finally + { + if (ReferenceEquals(_runCts, cts)) + { + _runCts = null; + } + IsRunning = false; + RaisePropertyChanged(nameof(ResultSummary)); + } + } + + private void StopRun() + { + if (_runCts is not { } cts) + { + return; + } + cts.Cancel(); + // 取消只断了客户端的等待;服务器上那条聚合还在跑,按 comment 找到它 killOp。 + if (_runComment is { } comment) + { + _ = KillAsync(comment); + } + } + + private async Task KillAsync(string comment) + { + try + { + await Workspace.Connection.KillByCommentAsync(comment).ConfigureAwait(false); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Workspace.Log.Info($"killOp for '{comment}' failed: {ex.Message}"); + } + } + + private void SetResults(List docs, bool more, TimeSpan elapsed, IReadOnlyList ghosts) + { + (IReadOnlyList columns, IReadOnlyList rows) = PipelineResults.Build(docs, ghosts, Loc); + ResultColumns = columns; + ResultRows = rows; + string count = BsonText.Grouped(docs.Count); + _resultSummary = more + ? Loc.Format("Pipe_ResultSummaryMore", count, PipelineResults.Elapsed(elapsed)) + : Loc.Format("Pipe_ResultSummary", count, PipelineResults.Elapsed(elapsed)); + _activity = Loc.Format("Pipe_StatusRun", more ? count + "+" : count, PipelineResults.Elapsed(elapsed)); + RaisePropertiesChanged(nameof(ResultSummary), nameof(HasRows), nameof(ResultWidth), nameof(ResultListHeight), nameof(ShowEmptyResult)); + UpdateStatus(); + } + + /// 停用阶段会产出的字段($lookup 的 from 集合抽一个字段名,画成 product.name)。 + private async Task> GhostsAsync(CancellationToken cancellationToken) + { + List<(string Operator, BsonValue? Value, bool Enabled)> specs = [.. Stages.Select(static s => (s.Operator, s.Value, s.IsEnabled))]; + var samples = new Dictionary(StringComparer.Ordinal); + foreach ((string op, BsonValue? value, bool enabled) in specs) + { + if (enabled || op != "$lookup" || value is not BsonDocument body + || !body.TryGetValue("from", out BsonValue from) || !from.IsString || samples.ContainsKey(from.AsString)) + { + continue; + } + try + { + BsonDocument? one = await Workspace.Connection.Collection(Database, from.AsString) + .Find(FilterDefinition.Empty).Limit(1).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(true); + samples[from.AsString] = one?.Names.FirstOrDefault(static n => n != "_id"); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + samples[from.AsString] = null; + } + } + return PipelineResults.Ghosts(specs, from => samples.GetValueOrDefault(from)); + } + + /// 管道末尾是 $out / $merge 时它写到哪里;不写返回 。 + internal (string Database, string Collection)? WriteTarget(IReadOnlyList pipeline) + { + if (pipeline.Count == 0 || pipeline[^1] is not { ElementCount: > 0 } last) + { + return null; + } + BsonElement element = last.GetElement(0); + BsonValue spec = element.Value; + switch (element.Name) + { + case "$out": + return spec switch + { + BsonString name => (Database, name.Value), + BsonDocument doc => (doc.GetValue("db", Database).ToString()!, doc.GetValue("coll", "").ToString()!), + _ => (Database, spec.ToString()!) + }; + case "$merge": + { + BsonValue into = spec is BsonDocument merge ? merge.GetValue("into", "") : spec; + return into switch + { + BsonString name => (Database, name.Value), + BsonDocument doc => (doc.GetValue("db", Database).ToString()!, doc.GetValue("coll", "").ToString()!), + _ => (Database, into.ToString()!) + }; + } + default: + return null; + } + } + + /// 写入阶段的确认:只读拦截 → 写清后果($out 是整体替换,红色)→ 生产连接手打集合名。 + private async Task ConfirmWriteAsync(string op, (string Database, string Collection) target) + { + if (!Workspace.EnsureWritable(target.Database)) + { + return false; + } + return await Workspace.ConfirmAsync(new() + { + Title = Loc["Pipe_WriteTitle"], + Message = Loc.Format(op == "$out" ? "Pipe_OutBody" : "Pipe_MergeBody", $"{target.Database}.{target.Collection}"), + ConfirmLabel = Loc["Pipe_WriteConfirm"], + IconKey = "Mongo.save", + Danger = op == "$out", + TypeToConfirm = Workspace.Guard.ConfirmWrites ? target.Collection : null + }).ConfigureAwait(true); + } + + // ── 工具行与输出卡上的动作 ───────────────────────────────────────────── + + /// mongosh 写法的 aggregate 调用(执行计划、在结果标签中打开)。 + internal string AggregateCall(bool withoutWrites = false) + { + IEnumerable specs = Stages.Where(static s => s.IsEnabled).Select(static s => s.ToSpec()); + if (withoutWrites) + { + specs = specs.Where(static s => !PipelinePreview.WriteStages.Contains(s.Operator)); + } + string options = AllowDiskUse ? ", { allowDiskUse: true }" : ""; + return $"db.{MongoWorkspaceViewModel.ShellCollectionRef(CollectionName)}.aggregate({PipelineText.Format(specs)}{options})"; + } + + private void Explain() + { + if (!EnsureRunnable()) + { + return; + } + // explain 不该真写:去掉 $out / $merge 再看计划(写入阶段本身不影响读取那一段的计划)。 + Workspace.OpenQuery(Database, AggregateCall(withoutWrites: true) + ".explain(\"executionStats\")", run: true); + } + + private void OpenInResults() + { + if (!EnsureRunnable()) + { + return; + } + // 带写入阶段时只打开不自动运行 —— 换个标签就悄悄写了一次库,那是事故。 + bool writes = WriteTarget(EnabledPipeline()) is not null; + Workspace.OpenQuery(Database, AggregateCall(), run: !writes); + } + + private void ExportCode() + { + if (!EnsureRunnable()) + { + return; + } + Workspace.ShowDialog(new PipelineCodeDialogViewModel(Workspace, Database, CollectionName, EnabledPipeline(), AllowDiskUse)); + } + + /// 保存管道(按连接存放,同名覆盖)。 + private async Task SaveAsync() + { + if (_mode == PipelineMode.Text && !ApplyText()) + { + Workspace.Toast(new() { Title = Loc["Pipe_TextInvalid"], Detail = TextError, Kind = ToastKind.Warning }); + return; + } + IReadOnlyList existing = await Workspace.Store.LoadSavedAsync("pipeline", Workspace.ConnectionKey).ConfigureAwait(true); + var dialog = new PipelineNameDialogViewModel(Workspace, new() + { + Title = Loc["Pipe_SaveTitle"], + IconKey = "Mongo.save", + Label = Loc["Pipe_SaveName"], + Initial = _savedName ?? $"{CollectionName} · {DateTime.Now:MM-dd HH:mm}", + ConfirmLabel = Loc["Common_Save"], + Hint = Loc["Pipe_SaveHint"], + Suggestions = [.. existing.Select(static i => i.Name).Take(12)], + Validate = name => name.Trim().Length == 0 ? Loc["Pipe_NameRequired"] : null + }); + Workspace.ShowDialog(dialog); + if (await dialog.Result.ConfigureAwait(true) is not { } chosen) + { + return; + } + string name = chosen.Name.Trim(); + await SavePipelineAsync(name).ConfigureAwait(true); + Workspace.Toast(new() { Title = Loc.Format("Pipe_SaveDone", name), Kind = ToastKind.Success }); + } + + /// 保存的正文:第一行注释写明命名空间,其后是整条管道的文本(停用的阶段照样以注释保存)。 + internal async Task SavePipelineAsync(string name) + { + string snapshot = Snapshot(); + string content = $"// {Info.Namespace}\n{snapshot}"; + await Workspace.Store.SaveItemAsync("pipeline", Workspace.ConnectionKey, new SavedItem(name, content, DateTimeOffset.Now)).ConfigureAwait(true); + _savedName = name; + _savedSnapshot = snapshot; + UpdateModified(); + } + + /// 创建视图:输入视图名 → 只读拦截 → 确认 → create { viewOn, pipeline }。 + private async Task CreateViewAsync() + { + if (!EnsureRunnable()) + { + return; + } + List pipeline = EnabledPipeline(); + if (WriteTarget(pipeline) is not null) + { + Workspace.Toast(new() { Title = Loc["Pipe_ViewNoWrite"], Kind = ToastKind.Warning }); + return; + } + if (!Workspace.EnsureWritable(Database)) + { + return; + } + var dialog = new PipelineNameDialogViewModel(Workspace, new() + { + Title = Loc["Pipe_ViewTitle"], + IconKey = "Mongo.eye", + Label = Loc["Pipe_ViewName"], + Initial = $"v_{CollectionName}", + ConfirmLabel = Loc["Pipe_ViewConfirm"], + Hint = Loc.Format("Pipe_ViewHint", Info.Namespace, pipeline.Count), + Validate = ValidateNewCollectionName + }); + Workspace.ShowDialog(dialog); + if (await dialog.Result.ConfigureAwait(true) is not { } chosen) + { + return; + } + string name = chosen.Name.Trim(); + if (!await Workspace.ConfirmAsync(new() + { + Title = Loc["Pipe_ViewTitle"], + Message = Loc.Format("Pipe_ViewConfirmBody", name, CollectionName, Database), + ConfirmLabel = Loc["Pipe_ViewConfirm"], + IconKey = "Mongo.eye", + Danger = false, + TypeToConfirm = Workspace.Guard.ConfirmWrites ? name : null + }).ConfigureAwait(true)) + { + return; + } + await CreateViewCoreAsync(name, pipeline).ConfigureAwait(true); + } + + /// 真正建视图(单测直接调)。 + internal async Task CreateViewCoreAsync(string name, IReadOnlyList pipeline) + { + try + { + await Workspace.Connection.RunCommandAsync(Database, new BsonDocument + { + { "create", name }, + { "viewOn", CollectionName }, + { "pipeline", new BsonArray(pipeline) } + }).ConfigureAwait(true); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Workspace.Toast(new() { Title = Loc.Format("Common_Failed", MongoConnector.Describe(ex)), Kind = ToastKind.Error }); + return false; + } + Workspace.Toast(new() + { + Title = Loc.Format("Pipe_ViewDone", $"{Database}.{name}"), + Kind = ToastKind.Success, + ActionLabel = Loc["Pipe_Open"], + Action = () => + { + Workspace.OpenCollection(Database, name); + return Task.CompletedTask; + }, + Duration = TimeSpan.FromSeconds(8) + }); + await Workspace.RefreshTreeAsync(Database).ConfigureAwait(true); + return true; + } + + /// 另存为集合:选目标与方式($out 整体替换 / $merge 合并)→ 确认 → 跑带写入阶段的管道。 + private async Task SaveAsCollectionAsync() + { + if (!EnsureRunnable()) + { + return; + } + List pipeline = EnabledPipeline(); + if (WriteTarget(pipeline) is not null) + { + Workspace.Toast(new() { Title = Loc.Format("Pipe_SaveAsHasWrite", pipeline[^1].GetElement(0).Name), Kind = ToastKind.Warning }); + return; + } + if (!Workspace.EnsureWritable(Database)) + { + return; + } + var dialog = new PipelineNameDialogViewModel(Workspace, new() + { + Title = Loc["Pipe_SaveAsTitle"], + IconKey = "Mongo.save", + Label = Loc["Pipe_SaveAsName"], + Initial = $"{CollectionName}_out", + ConfirmLabel = Loc["Common_Next"], + OfferMerge = true, + Validate = name => + { + string trimmed = name.Trim(); + if (trimmed.Length == 0) + { + return Loc["Pipe_NameRequired"]; + } + if (trimmed == CollectionName) + { + return Loc["Pipe_SaveAsSelf"]; + } + return trimmed.Contains('$', StringComparison.Ordinal) || trimmed.StartsWith("system.", StringComparison.Ordinal) + ? Loc["Pipe_NameInvalid"] + : null; + } + }); + Workspace.ShowDialog(dialog); + if (await dialog.Result.ConfigureAwait(true) is not { } chosen) + { + return; + } + string target = chosen.Name.Trim(); + BsonDocument write = chosen.Merge + ? new BsonDocument("$merge", new BsonDocument { { "into", target }, { "on", "_id" }, { "whenMatched", "merge" }, { "whenNotMatched", "insert" } }) + : new BsonDocument("$out", target); + List full = [.. pipeline, write]; + if (!await ConfirmWriteAsync(write.GetElement(0).Name, (Database, target)).ConfigureAwait(true)) + { + return; + } + await ExecuteAsync(full, (Database, target)).ConfigureAwait(true); + } + + /// 新集合 / 视图名的校验(不能空、不能已存在、不能含 $)。 + private string? ValidateNewCollectionName(string name) + { + string trimmed = name.Trim(); + if (trimmed.Length == 0) + { + return Loc["Pipe_NameRequired"]; + } + if (trimmed.Contains('$', StringComparison.Ordinal) || trimmed.StartsWith("system.", StringComparison.Ordinal) || trimmed.Contains('\0')) + { + return Loc["Pipe_NameInvalid"]; + } + return Workspace.CollectionsOf(Database).Any(c => c.Name == trimmed) ? Loc.Format("Pipe_NameExists", trimmed, Database) : null; + } + + // ── 状态栏 ───────────────────────────────────────────────────────────── + + private void UpdateStatus() + { + string state = _savedName is null + ? Loc["Pipe_Unsaved"] + : IsModified ? Loc.Format("Pipe_SavedModified", _savedName) : Loc.Format("Pipe_SavedState", _savedName); + StatusText = _activity.Length > 0 ? $"{Info.Namespace} · {state} · {_activity}" : $"{Info.Namespace} · {state}"; + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineText.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineText.cs new file mode 100644 index 0000000..b834e3f --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineText.cs @@ -0,0 +1,847 @@ +using System.Globalization; +using System.Text; +using MongoDB.Bson; +using VelaShell.Plugin.Mongo.Bson; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 一个阶段的文本形态:运算符、阶段体原文、是否启用。 +/// 运算符($match)。 +/// 阶段体原文(mongosh 写法,保留用户的排版)。 +/// 是否启用。 +internal sealed record PipelineStageSpec(string Operator, string Body, bool Enabled = true); + +/// 整条管道文本的解析结果。 +/// 阶段;失败时为空。 +/// 失败原因的文案键;成功为 。 +/// 文案参数。 +/// 出错位置(相对于整段文本)。 +/// 出错区间长度。 +internal sealed record PipelineParse( + IReadOnlyList Stages, + string? ErrorKey = null, + string ErrorArgument = "", + int ErrorOffset = 0, + int ErrorLength = 1) +{ + /// 成功。 + public bool Ok => ErrorKey is null; +} + +/// 阶段体的解析结果(卡片上的错误标记与编辑器波浪线都从它来)。 +/// 解析出的值;失败为 。 +/// 失败原因的文案键。 +/// 文案参数。 +/// 出错位置(相对于阶段体)。 +/// 出错区间长度。 +/// 一键修复的替换文本。 +internal sealed record PipelineBodyParse(BsonValue? Value, string? ErrorKey = null, string ErrorArgument = "", int Offset = 0, int Length = 0, string? Fix = null) +{ + /// 成功。 + public bool Ok => Value is not null; +} + +/// +/// 管道的「文本」形态:阶段卡片 ⇄ 一整段 mongosh 数组文本。 +/// +/// 文本模式与卡片模式是**同一份数据的两种编辑方式**,所以这里的格式化与解析必须互逆: +/// 卡片 → 文本 → 卡片要回到原样(包括用户在阶段体里的换行与缩进)。为此阶段体按**原文**搬运, +/// 而不是解析成 BSON 再写回 —— 写回会把 ISODate("2026-08-27") 变成带毫秒的全称, +/// 把用户精心排好的一行拆成五行,用户在两种模式之间切一次就"被格式化"一次。 +/// +/// +/// 停用的阶段在文本里写成**整段注释**(// { $lookup: … },):这是 Compass 导出时的同一个约定, +/// 粘到 mongosh 里也能直接跑(注释被忽略,正好就是"停用")。解析时只把"在数组顶层、以 { 起头、 +/// 括号能配平"的那种注释块认成停用阶段;其余注释照常当注释丢掉。 +/// +/// +internal static class PipelineText +{ + /// 行宽:超过它的容器才折行(与 mongosh 的默认输出宽度相近)。 + private const int Width = 72; + + // ── 卡片 → 文本 ───────────────────────────────────────────────────────── + + /// 把阶段写成一整段数组文本(停用的阶段整段注释掉)。 + public static string Format(IEnumerable stages) + { + var b = new StringBuilder("[\n"); + bool first = true; + foreach (PipelineStageSpec stage in stages) + { + if (!first) + { + b.Append('\n'); + } + first = false; + string text = FormatStage(stage.Operator, stage.Body) + ","; + foreach (string line in text.Split('\n')) + { + b.Append(" "); + if (!stage.Enabled) + { + b.Append("// "); + } + b.Append(line).Append('\n'); + } + b.Length--; + } + if (!first) + { + b.Append('\n'); + } + b.Append(']'); + return b.ToString(); + } + + /// + /// 一个阶段:单行阶段体写成 { $limit: 10 };多行的把运算符单列一行,阶段体整体缩进两格。 + /// + public static string FormatStage(string op, string body) + { + string[] lines = Normalize(body).Trim().Split('\n'); + if (lines.Length == 1) + { + return $"{{ {op}: {lines[0]} }}"; + } + var b = new StringBuilder("{\n ").Append(op).Append(": ").Append(lines[0]); + for (int i = 1; i < lines.Length; i++) + { + b.Append('\n'); + if (lines[i].Length > 0) + { + b.Append(" ").Append(lines[i]); + } + } + return b.Append("\n}").ToString(); + } + + // ── 文本 → 卡片 ───────────────────────────────────────────────────────── + + /// 解析一整段数组文本。空白文本视为空管道。 + public static PipelineParse Parse(string? text) + { + text = Normalize(text ?? ""); + int i = SkipTrivia(text, 0); + if (i >= text.Length) + { + return new([]); + } + if (text[i] != '[') + { + return Fail("Pipe_ErrNotArray", "", i, 1); + } + i++; + var stages = new List(); + while (true) + { + // 元素之间的空白、逗号与注释;顶层的注释块可能是一个停用的阶段。 + while (i < text.Length) + { + char c = text[i]; + if (char.IsWhiteSpace(c) || c == ',') + { + i++; + continue; + } + if (c == '/' && At(text, i + 1) == '/') + { + if (TryReadDisabled(text, i, out PipelineStageSpec? disabled, out int next)) + { + stages.Add(disabled); + i = next; + continue; + } + i = LineEnd(text, i); + continue; + } + if (c == '/' && At(text, i + 1) == '*') + { + i = BlockCommentEnd(text, i); + continue; + } + break; + } + if (i >= text.Length) + { + return Fail("Pipe_ErrUnclosed", "", Math.Max(0, text.Length - 1), 1); + } + if (text[i] == ']') + { + i++; + break; + } + int start = i; + int end = ElementEnd(text, i); + PipelineParse? error = ParseElement(text[start..end], start, out PipelineStageSpec? stage); + if (error is not null) + { + return error; + } + stages.Add(stage!); + i = end; + } + i = SkipTrivia(text, i); + if (i < text.Length && text[i] == ';') + { + i = SkipTrivia(text, i + 1); + } + if (i < text.Length) + { + return Fail("Pipe_ErrTrailing", "", i, text.Length - i); + } + return new(stages); + } + + /// 解析一个数组元素 { $op: body };出错返回带位置的失败结果。 + private static PipelineParse? ParseElement(string element, int baseOffset, out PipelineStageSpec? stage) + { + stage = null; + string trimmed = element.TrimEnd(); + int i = SkipTrivia(trimmed, 0); + if (i >= trimmed.Length || trimmed[i] != '{') + { + return Fail("Pipe_ErrStageShape", "", baseOffset + i, Math.Max(1, Math.Min(trimmed.Length - i, 12))); + } + int close = Matching(trimmed, i); + if (close < 0) + { + return Fail("Pipe_ErrUnbalanced", "", baseOffset + i, 1); + } + if (SkipTrivia(trimmed, close + 1) < trimmed.Length) + { + return Fail("Pipe_ErrStageShape", "", baseOffset + close + 1, trimmed.Length - close - 1); + } + int k = SkipTrivia(trimmed, i + 1); + if (k >= close) + { + return Fail("Pipe_ErrEmptyStage", "", baseOffset + i, close - i + 1); + } + int keyStart = k; + string key; + if (trimmed[k] is '"' or '\'') + { + int end = SkipString(trimmed, k); + key = trimmed[(k + 1)..Math.Max(k + 1, end - 1)]; + k = end; + } + else + { + while (k < close && (char.IsLetterOrDigit(trimmed[k]) || trimmed[k] is '_' or '$')) + { + k++; + } + key = trimmed[keyStart..k]; + } + if (key.Length == 0) + { + return Fail("Pipe_ErrStageShape", "", baseOffset + keyStart, 1); + } + if (!key.StartsWith('$')) + { + return Fail("Pipe_ErrStageName", key, baseOffset + keyStart, k - keyStart); + } + k = SkipTrivia(trimmed, k); + if (k >= close || trimmed[k] != ':') + { + return Fail("Pipe_ErrExpectedColon", key, baseOffset + Math.Min(k, close), 1); + } + int bodyStart = k + 1; + int bodyEnd = close; + int comma = TopLevelComma(trimmed, bodyStart, close); + if (comma >= 0) + { + int rest = SkipTrivia(trimmed, comma + 1); + if (rest < close) + { + return Fail("Pipe_ErrOneOperator", key, baseOffset + rest, close - rest); + } + bodyEnd = comma; + } + string raw = trimmed[bodyStart..bodyEnd]; + int lead = 0; + while (lead < raw.Length && char.IsWhiteSpace(raw[lead])) + { + lead++; + } + string body = Dedent(raw.Trim()); + if (body.Length == 0) + { + return Fail("Pipe_ErrEmptyBody", key, baseOffset + bodyStart, 1); + } + PipelineBodyParse parsed = ParseBody(body); + if (!parsed.Ok) + { + int offset = baseOffset + bodyStart + lead + MapDedented(raw.Trim(), parsed.Offset); + return Fail(parsed.ErrorKey!, parsed.ErrorArgument, offset, Math.Max(1, parsed.Length)); + } + stage = new PipelineStageSpec(key, body); + return null; + } + + /// + /// 读一个停用阶段的注释块:从 的 // 起, + /// 连续的注释行去掉 // 前缀后能配平成一个 { $op: … },就是一个停用阶段。 + /// 配不平或解析不出阶段,就当普通注释(返回 )。 + /// + private static bool TryReadDisabled(string text, int start, out PipelineStageSpec stage, out int next) + { + stage = null!; + next = start; + var content = new StringBuilder(); + int depth = 0; + bool opened = false; + int i = start; + while (true) + { + int lineEnd = LineEnd(text, i); + // i 指着 "//"。 + int from = i + 2; + if (from < lineEnd && text[from] == ' ') + { + from++; + } + string line = text[from..lineEnd]; + if (!opened) + { + string head = line.TrimStart(); + if (!head.StartsWith('{')) + { + return false; + } + } + content.Append(line).Append('\n'); + depth += BraceDelta(line, ref opened); + if (opened && depth <= 0) + { + next = lineEnd; + break; + } + // 下一行必须仍是注释行。 + int j = lineEnd + 1; + while (j < text.Length && text[j] is ' ' or '\t') + { + j++; + } + if (j + 1 >= text.Length || text[j] != '/' || text[j + 1] != '/') + { + return false; + } + i = j; + } + string element = content.ToString().Trim(); + if (element.EndsWith(',')) + { + element = element[..^1].TrimEnd(); + } + if (ParseElement(element, 0, out PipelineStageSpec? parsed) is not null || parsed is null) + { + return false; + } + stage = parsed with { Enabled = false }; + return true; + } + + /// 一行代码里花括号的净增减(跳过字符串)。 + private static int BraceDelta(string line, ref bool opened) + { + int delta = 0; + for (int i = 0; i < line.Length; i++) + { + char c = line[i]; + if (c is '"' or '\'') + { + i = SkipString(line, i) - 1; + continue; + } + if (c is '{' or '[') + { + delta++; + opened = true; + } + else if (c is '}' or ']') + { + delta--; + } + } + return delta; + } + + // ── 阶段体 ────────────────────────────────────────────────────────────── + + /// + /// 解析一个阶段体({ … }、10、"$items"、[ "a" ] 都可以 —— 各阶段的形态不同)。 + /// 先跑 shell 诊断(含点号的裸键这类 mongosh 自己也会报的错),再交给解析器。 + /// + public static PipelineBodyParse ParseBody(string? body) + { + if (string.IsNullOrWhiteSpace(body)) + { + return new(null, "Pipe_ErrEmptyBody", "", 0, 1); + } + IReadOnlyList diagnostics = ShellJson.Diagnose(body); + if (diagnostics.FirstOrDefault() is { } first) + { + return new(null, first.MessageKey, first.Argument, first.Offset, Math.Max(1, first.Length), first.Fix); + } + int unbalanced = FindUnbalanced(body); + if (unbalanced >= 0) + { + return new(null, "Pipe_ErrUnbalanced", "", unbalanced, 1); + } + try + { + return new(ShellJson.ParseValue(body)); + } + catch (ShellJsonException ex) + { + (int offset, int length) = ex.Offset >= 0 && ex.Offset < body.Length + ? (ex.Offset, 1) + : GuessErrorSpan(body); + return new(null, "Pipe_ErrSyntax", ex.Message, offset, length); + } + } + + /// + /// 驱动的 JSON 读取器报错时不给位置。退一步:标第一个非空行 —— 至少让波浪线落在阶段体上, + /// 而不是什么都不标(那会让人以为错的是别处)。 + /// + private static (int Offset, int Length) GuessErrorSpan(string body) + { + int start = 0; + while (start < body.Length && char.IsWhiteSpace(body[start])) + { + start++; + } + int end = body.IndexOf('\n', start); + if (end < 0) + { + end = body.Length; + } + return (start, Math.Max(1, end - start)); + } + + /// 第一个配不上的括号位置;都配上了返回 -1。 + internal static int FindUnbalanced(string text) + { + var stack = new Stack<(char Open, int At)>(); + int result = -1; + Walk(text, 0, text.Length, (i, c) => + { + switch (c) + { + case '{' or '[' or '(': + stack.Push((c, i)); + break; + case '}' or ']' or ')': + char expected = c switch { '}' => '{', ']' => '[', _ => '(' }; + if (stack.Count == 0 || stack.Peek().Open != expected) + { + result = i; + return false; + } + stack.Pop(); + break; + } + return true; + }); + if (result >= 0) + { + return result; + } + return stack.Count > 0 ? stack.Peek().At : -1; + } + + /// + /// 去掉多行阶段体第二行起的公共缩进(第一行紧跟在冒号后面,它自己的缩进没有意义)。 + /// 文本模式里阶段体整体缩进了四格,这一步把它还原成卡片里的样子。 + /// + public static string Dedent(string body) + { + string[] lines = Normalize(body).Split('\n'); + if (lines.Length <= 1) + { + return body.Trim(); + } + int min = int.MaxValue; + for (int i = 1; i < lines.Length; i++) + { + if (lines[i].Trim().Length == 0) + { + continue; + } + min = Math.Min(min, Indentation(lines[i])); + } + if (min == int.MaxValue) + { + min = 0; + } + var b = new StringBuilder(lines[0].Trim()); + for (int i = 1; i < lines.Length; i++) + { + b.Append('\n'); + string line = lines[i]; + b.Append(line.Trim().Length == 0 ? "" : line[Math.Min(min, Indentation(line))..].TrimEnd()); + } + return b.ToString().TrimEnd(); + } + + /// 去缩进后的偏移 → 去缩进前的偏移(诊断位置要落回原文)。 + private static int MapDedented(string raw, int dedentedOffset) + { + string dedented = Dedent(raw); + if (dedentedOffset <= 0) + { + return 0; + } + // 按行列对齐:找到偏移在去缩进文本里的行与列,再到原文的同一行加回被去掉的缩进。 + int line = 0; + int column = 0; + for (int i = 0; i < Math.Min(dedentedOffset, dedented.Length); i++) + { + if (dedented[i] == '\n') + { + line++; + column = 0; + } + else + { + column++; + } + } + string[] rawLines = Normalize(raw).Split('\n'); + string[] newLines = dedented.Split('\n'); + int offset = 0; + for (int i = 0; i < line && i < rawLines.Length; i++) + { + offset += rawLines[i].Length + 1; + } + // 第一行没有被去缩进(原文已 Trim);其余行被去掉的是两边缩进之差。 + int removed = line > 0 && line < rawLines.Length && line < newLines.Length + ? Indentation(rawLines[line]) - Indentation(newLines[line]) + : 0; + return Math.Min(raw.Length, offset + Math.Max(0, removed) + column); + } + + /// 新阶段的默认阶段体:取词汇表的插入模板($unwind: { path: "$|" })冒号后那段。 + public static string DefaultBody(string op) + { + // 模板里光标落在值的位置($limit: |)的,去掉光标就成了空阶段体;给一个能直接跑的值。 + string? fallback = op switch + { + "$limit" => "10", + "$skip" => "0", + "$sample" => "{ size: 10 }", + _ => null + }; + if (fallback is not null) + { + return fallback; + } + Shell.VocabularyEntry? entry = Shell.MongoVocabulary.Stages.FirstOrDefault(s => s.Name == op); + string insert = entry?.InsertText ?? op + ": { | }"; + int colon = insert.IndexOf(": ", StringComparison.Ordinal); + string body = (colon >= 0 ? insert[(colon + 2)..] : "{ }").Replace("|", "", StringComparison.Ordinal); + return body.Trim() switch + { + "{ }" or "{ }" or "{}" => "{\n \n}", + "" => "{ }", + var other => other + }; + } + + // ── BSON → 阶段体文本 ──────────────────────────────────────────────────── + + /// + /// 把一个已解析的值写成阶段体文本(从外部带进来的管道用):顶层文档两个字段以上逐行写, + /// 内层容器能放进一行就放一行 —— 与设计稿里 createdAt: { $gte: ISODate("2026-08-27") } 的写法一致。 + /// + public static string FormatBody(BsonValue value) => Write(value, 0, topLevel: true); + + private static string Write(BsonValue value, int depth, bool topLevel = false) + { + string flat = Flat(value); + bool fits = flat.Length + depth * 2 <= Width; + switch (value) + { + case BsonDocument doc when doc.ElementCount > 0 && (!fits || (topLevel && doc.ElementCount > 1)): + { + var b = new StringBuilder("{"); + int i = 0; + foreach (BsonElement element in doc) + { + b.Append(i++ == 0 ? "\n" : ",\n").Append(' ', (depth + 1) * 2) + .Append(BsonText.FieldName(element.Name)).Append(": ") + .Append(Write(element.Value, depth + 1)); + } + return b.Append('\n').Append(' ', depth * 2).Append('}').ToString(); + } + case BsonArray array when array.Count > 0 && !fits: + { + var b = new StringBuilder("["); + for (int i = 0; i < array.Count; i++) + { + b.Append(i == 0 ? "\n" : ",\n").Append(' ', (depth + 1) * 2).Append(Write(array[i], depth + 1)); + } + return b.Append('\n').Append(' ', depth * 2).Append(']').ToString(); + } + default: + return flat; + } + } + + /// 单行字面量。日期恰在 UTC 零点时写成 ISODate("2026-08-27")(mongosh 接受,而且好读得多)。 + internal static string Flat(BsonValue value) => value switch + { + BsonDocument { ElementCount: 0 } => "{}", + BsonDocument doc => "{ " + string.Join(", ", doc.Select(e => BsonText.FieldName(e.Name) + ": " + Flat(e.Value))) + " }", + BsonArray { Count: 0 } => "[]", + BsonArray array => "[ " + string.Join(", ", array.Select(Flat)) + " ]", + BsonDateTime date when IsMidnight(date) => + $"ISODate(\"{date.ToUniversalTime().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)}\")", + _ => BsonText.Literal(value) + }; + + private static bool IsMidnight(BsonDateTime date) + { + long ms = date.MillisecondsSinceEpoch; + return ms % 86_400_000 == 0 && date.IsValidDateTime; + } + + // ── 扫描器 ────────────────────────────────────────────────────────────── + + /// + /// 从 往后走到 ,跳过字符串、注释与正则字面量, + /// 对每个"代码字符"回调;回调返回 即停。返回停下的位置。 + /// + internal static int Walk(string text, int start, int end, Func onCode) + { + char previous = '\0'; + int i = start; + while (i < end) + { + char c = text[i]; + if (c is '"' or '\'') + { + i = SkipString(text, i); + previous = '"'; + continue; + } + if (c == '/' && At(text, i + 1) == '/') + { + i = LineEnd(text, i); + continue; + } + if (c == '/' && At(text, i + 1) == '*') + { + i = BlockCommentEnd(text, i); + continue; + } + if (c == '/' && previous is ':' or ',' or '[' or '(' or '\0') + { + i = SkipRegex(text, i); + previous = '/'; + continue; + } + if (!onCode(i, c)) + { + return i; + } + if (!char.IsWhiteSpace(c)) + { + previous = c; + } + i++; + } + return Math.Min(i, end); + } + + /// 一个数组元素在哪里结束:顶层的 , 或 ](返回它的位置)。 + private static int ElementEnd(string text, int start) + { + int depth = 0; + return Walk(text, start, text.Length, (_, c) => + { + switch (c) + { + case '{' or '[' or '(': + depth++; + return true; + case ']' when depth == 0: + return false; + case ',' when depth == 0: + return false; + case '}' or ']' or ')': + depth--; + return depth >= 0; + default: + return true; + } + }); + } + + /// 与 处的括号配对的那个;配不上返回 -1。 + private static int Matching(string text, int open) + { + int depth = 0; + int found = -1; + Walk(text, open, text.Length, (i, c) => + { + if (c is '{' or '[' or '(') + { + depth++; + } + else if (c is '}' or ']' or ')') + { + depth--; + if (depth == 0) + { + found = i; + return false; + } + } + return true; + }); + return found; + } + + /// 区间里第一个顶层逗号;没有返回 -1。 + private static int TopLevelComma(string text, int start, int end) + { + int depth = 0; + int found = -1; + Walk(text, start, end, (i, c) => + { + if (c is '{' or '[' or '(') + { + depth++; + } + else if (c is '}' or ']' or ')') + { + depth--; + } + else if (c == ',' && depth == 0) + { + found = i; + return false; + } + return true; + }); + return found; + } + + /// 跳过空白与注释。 + internal static int SkipTrivia(string text, int i) + { + while (i < text.Length) + { + char c = text[i]; + if (char.IsWhiteSpace(c)) + { + i++; + } + else if (c == '/' && At(text, i + 1) == '/') + { + i = LineEnd(text, i); + } + else if (c == '/' && At(text, i + 1) == '*') + { + i = BlockCommentEnd(text, i); + } + else + { + break; + } + } + return i; + } + + /// 字符串字面量结束后的位置(处理转义;没收尾就到文本末尾)。 + internal static int SkipString(string text, int start) + { + char quote = text[start]; + int i = start + 1; + while (i < text.Length) + { + if (text[i] == '\\') + { + i += 2; + continue; + } + if (text[i] == quote) + { + return i + 1; + } + if (text[i] == '\n') + { + return i; + } + i++; + } + return text.Length; + } + + private static int SkipRegex(string text, int start) + { + int i = start + 1; + bool inClass = false; + while (i < text.Length && text[i] != '\n') + { + char c = text[i]; + if (c == '\\') + { + i += 2; + continue; + } + if (c == '[') + { + inClass = true; + } + else if (c == ']') + { + inClass = false; + } + else if (c == '/' && !inClass) + { + i++; + while (i < text.Length && char.IsLetter(text[i])) + { + i++; + } + return i; + } + i++; + } + return i; + } + + private static int LineEnd(string text, int i) + { + int end = text.IndexOf('\n', i); + return end < 0 ? text.Length : end; + } + + private static int BlockCommentEnd(string text, int i) + { + int close = text.IndexOf("*/", i + 2, StringComparison.Ordinal); + return close < 0 ? text.Length : close + 2; + } + + private static char At(string text, int i) => i < text.Length ? text[i] : '\0'; + + private static int Indentation(string line) + { + int n = 0; + while (n < line.Length && line[n] is ' ' or '\t') + { + n++; + } + return n; + } + + /// 统一换行符(Windows 剪贴板粘进来的是 CRLF)。 + internal static string Normalize(string text) => text.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n'); + + private static PipelineParse Fail(string key, string argument, int offset, int length) => + new([], key, argument, Math.Max(0, offset), Math.Max(1, length)); +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineTokens.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineTokens.cs new file mode 100644 index 0000000..5f0ef01 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Pipeline/PipelineTokens.cs @@ -0,0 +1,219 @@ +using System.Text; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// mongosh 代码里一个记号的种类(决定它的颜色)。 +internal enum PipelineTokenKind +{ + /// 括号、冒号、逗号、空白。 + Punct, + + /// 键名(status:)。 + Key, + + /// 运算符($gte、$sum)。 + Operator, + + /// 字符串。 + String, + + /// 字段路径字符串("$items.qty")。 + FieldPath, + + /// 数字。 + Number, + + /// 类型构造器(ISODate、NumberDecimal)。 + Constructor, + + /// ObjectId。 + ObjectId, + + /// true / false。 + Bool, + + /// null / undefined。 + Null, + + /// 注释。 + Comment, + + /// 其余标识符。 + Identifier +} + +/// 一个记号。 +/// 起点。 +/// 长度。 +/// 种类。 +internal readonly record struct PipelineToken(int Start, int Length, PipelineTokenKind Kind); + +/// +/// 一个轻量的 mongosh 记号切分器:折叠态卡片里那一行内联阶段体要按 token 着色, +/// 而那里放不下一个 AvaloniaEdit(几十张卡片各挂一个编辑器,滚动时会明显卡)。 +/// 配色与 Syntax/Mongosh.xshd 逐条对应(键名用主文字色,与设计稿的阶段体一致)。 +/// +internal static class PipelineTokens +{ + private static readonly HashSet Constructors = new(StringComparer.Ordinal) + { + "ISODate", "Date", "NumberDecimal", "NumberLong", "NumberInt", "NumberDouble", "Decimal128", "Long", "Int32", + "Double", "BinData", "UUID", "Timestamp", "MinKey", "MaxKey", "RegExp", "HexData", "MD5", "new" + }; + + /// 切分。 + public static IReadOnlyList Tokenize(string text) + { + var tokens = new List(); + int i = 0; + while (i < text.Length) + { + char c = text[i]; + int start = i; + if (c is '"' or '\'') + { + i = PipelineText.SkipString(text, i); + bool path = i - start > 2 && text[start + 1] == '$'; + tokens.Add(new(start, i - start, path ? PipelineTokenKind.FieldPath : PipelineTokenKind.String)); + continue; + } + if (c == '/' && i + 1 < text.Length && text[i + 1] is '/' or '*') + { + int end = text[i + 1] == '/' + ? (text.IndexOf('\n', i) is var nl && nl >= 0 ? nl : text.Length) + : (text.IndexOf("*/", i + 2, StringComparison.Ordinal) is var close && close >= 0 ? close + 2 : text.Length); + tokens.Add(new(start, end - start, PipelineTokenKind.Comment)); + i = end; + continue; + } + if (char.IsDigit(c) || (c is '-' or '.' && i + 1 < text.Length && char.IsDigit(text[i + 1]) && !PreviousIsWord(text, i))) + { + i++; + while (i < text.Length && (char.IsLetterOrDigit(text[i]) || text[i] is '.' or '_')) + { + i++; + } + tokens.Add(new(start, i - start, PipelineTokenKind.Number)); + continue; + } + if (char.IsLetter(c) || c is '_' or '$') + { + while (i < text.Length && (char.IsLetterOrDigit(text[i]) || text[i] is '_' or '$')) + { + i++; + } + string word = text[start..i]; + tokens.Add(new(start, i - start, Classify(word, IsFollowedByColon(text, i)))); + continue; + } + i++; + // 连续的标点与空白合成一个记号,少建几个 Run。 + while (i < text.Length && !IsTokenStart(text, i)) + { + i++; + } + tokens.Add(new(start, i - start, PipelineTokenKind.Punct)); + } + return tokens; + } + + /// 记号种类 → 宿主颜色令牌。 + public static string ColorToken(PipelineTokenKind kind) => kind switch + { + PipelineTokenKind.Key or PipelineTokenKind.Identifier => "VelaTextPrimary", + PipelineTokenKind.Operator => "VelaShellMagenta", + PipelineTokenKind.String => "VelaShellCyan", + PipelineTokenKind.FieldPath => "VelaWarning", + PipelineTokenKind.Number => "VelaShellGreen", + PipelineTokenKind.Constructor => "VelaInfo", + PipelineTokenKind.ObjectId => "VelaShellBlue", + PipelineTokenKind.Bool => "VelaShellYellow", + PipelineTokenKind.Null or PipelineTokenKind.Comment => "VelaTextTertiary", + _ => "VelaTextSecondary" + }; + + /// + /// 把多行代码压成一行(折叠态卡片):注释去掉,字符串外的换行与连续空白收成一个空格。 + /// + public static string OneLine(string code) + { + var b = new StringBuilder(code.Length); + bool space = false; + int i = 0; + while (i < code.Length) + { + char c = code[i]; + if (c is '"' or '\'') + { + int end = PipelineText.SkipString(code, i); + if (space && b.Length > 0) + { + b.Append(' '); + } + space = false; + b.Append(code, i, end - i); + i = end; + continue; + } + if (c == '/' && i + 1 < code.Length && code[i + 1] == '/') + { + int nl = code.IndexOf('\n', i); + i = nl < 0 ? code.Length : nl; + continue; + } + if (char.IsWhiteSpace(c)) + { + space = true; + i++; + continue; + } + if (space && b.Length > 0) + { + b.Append(' '); + } + space = false; + b.Append(c); + i++; + } + return b.ToString(); + } + + private static PipelineTokenKind Classify(string word, bool key) + { + if (word.StartsWith('$')) + { + return PipelineTokenKind.Operator; + } + if (key) + { + return PipelineTokenKind.Key; + } + return word switch + { + "true" or "false" => PipelineTokenKind.Bool, + "null" or "undefined" => PipelineTokenKind.Null, + "ObjectId" => PipelineTokenKind.ObjectId, + _ when Constructors.Contains(word) => PipelineTokenKind.Constructor, + _ => PipelineTokenKind.Identifier + }; + } + + private static bool IsFollowedByColon(string text, int i) + { + while (i < text.Length && text[i] is ' ' or '\t') + { + i++; + } + return i < text.Length && text[i] == ':'; + } + + private static bool PreviousIsWord(string text, int i) => i > 0 && (char.IsLetterOrDigit(text[i - 1]) || text[i - 1] is '_' or '$'); + + private static bool IsTokenStart(string text, int i) + { + char c = text[i]; + return c is '"' or '\'' or '_' or '$' || char.IsLetterOrDigit(c) + || (c == '/' && i + 1 < text.Length && text[i + 1] is '/' or '*') + || (c is '-' && i + 1 < text.Length && char.IsDigit(text[i + 1])); + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Profiler/ProfilerDetail.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Profiler/ProfilerDetail.cs new file mode 100644 index 0000000..39f8944 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Profiler/ProfilerDetail.cs @@ -0,0 +1,180 @@ +using System.Globalization; +using MongoDB.Bson; +using VelaShell.Plugin.Mongo.Analysis; +using VelaShell.Plugin.Mongo.Bson; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 慢查询右侧的详情(设计稿 15):最近一次样本、还原的 mongosh 语句、扫描 / 返回 / 计划 / 内存排序, +/// 以及按 ESR 推出来的建议索引。 +/// +internal sealed class ProfilerDetail : ObservableObject +{ + private string _adviceText; + private bool _canCreateIndex; + + /// 构造。 + /// 所属形状。 + /// 展示的那一条样本(按形状模式是最近一次;逐条模式是选中的那条)。 + /// 文案。 + public ProfilerDetail(ProfilerShapeRow group, ProfileEntry sample, Loc loc) + { + Group = group; + Loc = loc; + Sample = sample; + string plan = ProfilerFormat.PlanText(group.Plan, sample.PlanSummary); + Title = $"{group.Title} · {plan}"; + Subtitle = loc.Format("Prof_DetailCount", BsonText.Grouped(group.Count), + (group.Share * 100).ToString("0", CultureInfo.InvariantCulture) + "%"); + SampleHeader = loc.Format("Prof_Sample", sample.Time.ToLocalTime().ToString("HH:mm:ss", CultureInfo.InvariantCulture)); + (Statement, Explain) = QueryShape.Rebuild(sample.Raw); + string client = string.IsNullOrEmpty(sample.Client) ? "—" : sample.Client; + if (sample.User.Length > 0) + { + client = $"{sample.User}@{client}"; + } + Client = sample.AppName.Length > 0 ? $"{client} · {sample.AppName}" : client; + DocsExamined = BsonText.Grouped(sample.DocsExamined); + DocsWarn = ProfilerFormat.RatioHigh(sample.DocsExamined, sample.Returned); + Returned = BsonText.Grouped(sample.Returned); + PlanSummary = sample.PlanSummary.Length > 0 ? sample.PlanSummary : "—"; + PlanToken = sample.Plan switch + { + SlowPlanKind.CollectionScan => "VelaError", + SlowPlanKind.InMemorySort => "VelaWarning", + SlowPlanKind.IndexScan => "VelaStatusConnected", + _ => "VelaTextSecondary" + }; + InMemorySort = sample.HasSortStage + ? sample.SortBytes > 0 + ? loc.Format("Prof_SortYes", BsonText.Bytes(sample.SortBytes)) + (sample.UsedDisk ? " · " + loc["Prof_SortDisk"] : "") + : loc["Common_Yes"] + : loc["Common_No"]; + SortWarn = sample.HasSortStage; + + (BsonDocument filter, BsonDocument? sort) = sample.FilterAndSort(); + Advice = IndexAdvice.Suggest(filter, sort); + bool healthy = sample.Plan == SlowPlanKind.IndexScan && !ProfilerFormat.RatioHigh(group.Examined, group.Returned); + if (Advice is null) + { + _adviceText = filter.ElementCount == 0 && sort is null ? loc["Prof_AdviceNoFilter"] : loc["Prof_AdviceNone"]; + } + else if (healthy) + { + _adviceText = loc.Format("Prof_AdviceFine", sample.PlanSummary); + } + else + { + _adviceText = DescribeAdvice(Advice, loc); + _canCreateIndex = true; + } + AdviceKeys = Advice is not null && !healthy ? Advice.KeysText : ""; + // 代码块的高度随语句行数走(只读展示,不需要滚动条);上限 12 行,再长就让编辑器自己滚。 + int lines = Math.Clamp(Statement.Count(static c => c == '\n') + 1, 1, 12); + StatementHeight = (lines * 14) + 12; + } + + /// 所属形状。 + public ProfilerShapeRow Group { get; } + + /// 文案(详情模板里的标签 {Binding Loc[...]})。 + public Loc Loc { get; } + + /// 样本。 + public ProfileEntry Sample { get; } + + /// 集合名(建索引、拼语句用)。 + public string Collection => Group.Shape.Collection; + + /// 标题(orders.find · COLLSCAN)。 + public string Title { get; } + + /// 副标题(1,204 次 · 占慢查询总耗时 71%)。 + public string Subtitle { get; } + + /// 「最近一次样本 · 21:08:44」。 + public string SampleHeader { get; } + + /// 还原的 mongosh 语句。 + public string Statement { get; } + + /// 对应的执行计划语句。 + public string Explain { get; } + + /// 代码块高度。 + public double StatementHeight { get; } + + /// 客户端(10.0.8.44 · order-sweeper)。 + public string Client { get; } + + /// docsExamined。 + public string DocsExamined { get; } + + /// docsExamined 偏高(橙)。 + public bool DocsWarn { get; } + + /// nreturned。 + public string Returned { get; } + + /// planSummary。 + public string PlanSummary { get; } + + /// planSummary 的颜色。 + public string PlanToken { get; } + + /// 内存排序(是 · 18 MB / 否)。 + public string InMemorySort { get; } + + /// 有内存排序(橙)。 + public bool SortWarn { get; } + + /// 建议的索引;推不出为 null。 + public IndexAdvice? Advice { get; } + + /// 建议的说明文字。 + public string AdviceText + { + get => _adviceText; + set => SetProperty(ref _adviceText, value); + } + + /// 建议索引键的代码({ "customer.level": 1, total: -1 });没有建议为空。 + public string AdviceKeys { get; } + + /// 有没有索引键代码块。 + public bool HasAdviceKeys => AdviceKeys.Length > 0; + + /// 「创建建议索引」可不可用(没有建议、已被现有索引覆盖时不可用)。 + public bool CanCreateIndex + { + get => _canCreateIndex; + set => SetProperty(ref _canCreateIndex, value); + } + + /// + /// 把 ESR 的推理写成一句话(customer.level 为等值条件,total 同时用于排序与范围,按 ESR 建议:)—— + /// 只给一个索引键而不说为什么,用户没法判断该不该照建。 + /// + private static string DescribeAdvice(IndexAdvice advice, Loc loc) + { + var parts = new List(); + if (advice.Equality.Count > 0) + { + parts.Add(loc.Format("Prof_EsrEquality", string.Join(", ", advice.Equality))); + } + if (advice.Sort.Count > 0) + { + parts.Add(loc.Format("Prof_EsrSort", string.Join(", ", advice.Sort))); + } + if (advice.SortAndRange.Count > 0) + { + parts.Add(loc.Format("Prof_EsrSortRange", string.Join(", ", advice.SortAndRange))); + } + if (advice.Range.Count > 0) + { + parts.Add(loc.Format("Prof_EsrRange", string.Join(", ", advice.Range))); + } + return string.Join(loc["Prof_EsrJoin"], parts) + loc["Prof_EsrTail"]; + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Profiler/ProfilerModels.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Profiler/ProfilerModels.cs new file mode 100644 index 0000000..c06fd16 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Profiler/ProfilerModels.cs @@ -0,0 +1,463 @@ +using System.Globalization; +using MongoDB.Bson; +using VelaShell.Plugin.Mongo.Analysis; +using VelaShell.Plugin.Mongo.Bson; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 执行计划的大类(慢查询表「计划」列的标签颜色)。 +internal enum SlowPlanKind +{ + /// 其他(EOF、单纯的命令)。 + Other, + + /// 走了索引。 + IndexScan, + + /// 走了索引但在内存里排序(橙)。 + InMemorySort, + + /// 全表扫描(红)。 + CollectionScan +} + +/// 一条 system.profile 记录的要点。 +internal sealed record ProfileEntry +{ + /// 发生时刻(UTC)。 + public DateTime Time { get; init; } + + /// 操作类型(query / update / command / getmore…)。 + public string Op { get; init; } = ""; + + /// 命名空间。 + public string Namespace { get; init; } = ""; + + /// 查询形状。 + public required QueryShape Shape { get; init; } + + /// 耗时(毫秒)。 + public double Millis { get; init; } + + /// 扫描的文档数。 + public long DocsExamined { get; init; } + + /// 扫描的索引键数。 + public long KeysExamined { get; init; } + + /// 返回的文档数(写操作取匹配 / 删除数)。 + public long Returned { get; init; } + + /// 计划摘要(COLLSCAN、IXSCAN { status: 1 })。 + public string PlanSummary { get; init; } = ""; + + /// 有没有内存排序。 + public bool HasSortStage { get; init; } + + /// 内存排序的数据量(字节;执行统计里没有为 0)。 + public long SortBytes { get; init; } + + /// 排序溢写到了磁盘。 + public bool UsedDisk { get; init; } + + /// 客户端地址。 + public string Client { get; init; } = ""; + + /// 应用名(appName)。 + public string AppName { get; init; } = ""; + + /// 用户。 + public string User { get; init; } = ""; + + /// 是不是 getMore(并进原查询的形状,但不算一次执行)。 + public bool IsGetMore => Op == "getmore"; + + /// 原文(还原语句、取条件与排序用)。 + public BsonDocument Raw { get; init; } = []; + + /// 计划大类。 + public SlowPlanKind Plan => ClassifyPlan(PlanSummary, HasSortStage); + + /// + /// 元数据 / 诊断类命令(listIndexes、$collStats、dbStats…)。level 2 下它们会被如实记下来 —— + /// 包括本工具自己的对象树在加载统计 —— 却不是"查询",混进慢查询表只会把真问题挤到下面。 + /// + public bool IsDiagnostic + { + get + { + if (Op != "command" || Raw.GetValue("command", BsonNull.Value) is not BsonDocument { ElementCount: > 0 } command) + { + return false; + } + string verb = command.GetElement(0).Name; + if (DiagnosticVerbs.Contains(verb)) + { + return true; + } + if (verb == "count" && PlanSummary.Contains("RECORD_STORE_FAST_COUNT", StringComparison.Ordinal)) + { + // 不带条件的 count 读的是集合元数据(estimatedDocumentCount),不扫描任何文档。 + return true; + } + return verb == "aggregate" + && command.GetValue("pipeline", BsonNull.Value) is BsonArray { Count: > 0 } pipeline + && pipeline[0] is BsonDocument { ElementCount: > 0 } first + && first.GetElement(0).Name is "$collStats" or "$indexStats" or "$currentOp" or "$listSessions" or "$planCacheStats"; + } + } + + private static readonly HashSet DiagnosticVerbs = new(StringComparer.OrdinalIgnoreCase) + { + "listIndexes", "listCollections", "listDatabases", "collStats", "dbStats", "profile", "ping", "hello", "isMaster", + "buildInfo", "serverStatus", "getParameter", "connectionStatus", "killCursors", "endSessions", "explain", + "replSetGetStatus", "replSetGetConfig", "getCmdLineOpts", "hostInfo", "usersInfo", "rolesInfo", "validate" + }; + + /// 解析一条记录。 + public static ProfileEntry Parse(BsonDocument doc) + { + long sortBytes = 0; + bool usedDisk = false; + if (doc.TryGetValue("execStats", out BsonValue stats) && stats is BsonDocument s) + { + FindSort(s, ref sortBytes, ref usedDisk); + } + usedDisk |= doc.GetValue("usedDisk", false).ToBoolean(); + long returned = Number(doc, "nreturned"); + if (!doc.Contains("nreturned")) + { + returned = Number(doc, "nMatched") + Number(doc, "ndeleted"); + } + return new() + { + Time = doc.TryGetValue("ts", out BsonValue ts) && ts.IsValidDateTime ? ts.ToUniversalTime() : DateTime.MinValue, + Op = Str(doc, "op"), + Namespace = Str(doc, "ns"), + Shape = QueryShape.FromProfile(doc), + Millis = doc.TryGetValue("millis", out BsonValue ms) && ms.IsNumeric ? ms.ToDouble() : 0, + DocsExamined = Number(doc, "docsExamined"), + KeysExamined = Number(doc, "keysExamined"), + Returned = returned, + PlanSummary = Str(doc, "planSummary"), + HasSortStage = doc.GetValue("hasSortStage", false).ToBoolean(), + SortBytes = sortBytes, + UsedDisk = usedDisk, + Client = Str(doc, "client"), + AppName = Str(doc, "appName"), + User = Str(doc, "user"), + Raw = doc + }; + } + + /// + /// 计划大类。COLLSCAN 最严重(红),其次是内存排序(橙)—— 走了索引却还要在内存里排序, + /// 数据一多就会撞上 100 MB 的排序上限;IDHACK / COUNT_SCAN / DISTINCT_SCAN 都算走索引。 + /// + public static SlowPlanKind ClassifyPlan(string summary, bool hasSortStage) + { + if (summary.Contains("COLLSCAN", StringComparison.Ordinal)) + { + return SlowPlanKind.CollectionScan; + } + if (hasSortStage) + { + return SlowPlanKind.InMemorySort; + } + return summary.Contains("IXSCAN", StringComparison.Ordinal) + || summary.Contains("IDHACK", StringComparison.Ordinal) + || summary.Contains("COUNT_SCAN", StringComparison.Ordinal) + || summary.Contains("DISTINCT_SCAN", StringComparison.Ordinal) + || summary.Contains("EXPRESS", StringComparison.Ordinal) + ? SlowPlanKind.IndexScan + : SlowPlanKind.Other; + } + + /// + /// 条件与排序(建议索引的输入)。find 取 filter / sort;聚合取第一个 $match 与紧跟其后的 $sort; + /// 写操作取 q;count / distinct / findAndModify 取 query。 + /// + public (BsonDocument Filter, BsonDocument? Sort) FilterAndSort() + { + BsonDocument command = Raw.GetValue("command", new BsonDocument()) as BsonDocument ?? []; + if (IsGetMore && Raw.GetValue("originatingCommand", BsonNull.Value) is BsonDocument origin) + { + command = origin; + } + if (command.Contains("q")) + { + return (Doc(command, "q") ?? [], null); + } + if (command.Contains("filter") || command.Contains("find")) + { + return (Doc(command, "filter") ?? [], Doc(command, "sort")); + } + if (command.GetValue("pipeline", BsonNull.Value) is BsonArray pipeline) + { + // 只有打头的 $match / $sort 能用上索引;排在 $group、$unwind 之后的就与集合上的索引无关了。 + string Head(int i) => i < pipeline.Count && pipeline[i] is BsonDocument { ElementCount: > 0 } s ? s.GetElement(0).Name : ""; + if (Head(0) == "$match" && pipeline[0][0] is BsonDocument match) + { + return (match, Head(1) == "$sort" ? pipeline[1][0] as BsonDocument : null); + } + BsonDocument? leadingSort = Head(0) == "$sort" ? pipeline[0][0] as BsonDocument : null; + return (new BsonDocument(), leadingSort); + } + return (Doc(command, "query") ?? [], Doc(command, "sort")); + } + + private static void FindSort(BsonDocument stage, ref long bytes, ref bool disk) + { + if (stage.GetValue("stage", "").ToString() is "SORT" or "sort") + { + foreach (string key in new[] { "totalDataSizeSorted", "totalDataSizeSortedBytesEstimate" }) + { + if (stage.TryGetValue(key, out BsonValue v) && v.IsNumeric) + { + bytes = Math.Max(bytes, v.ToInt64()); + } + } + disk |= stage.GetValue("usedDisk", false).ToBoolean(); + } + foreach (string child in new[] { "inputStage", "outerStage", "innerStage" }) + { + if (stage.TryGetValue(child, out BsonValue c) && c is BsonDocument cd) + { + FindSort(cd, ref bytes, ref disk); + } + } + if (stage.TryGetValue("inputStages", out BsonValue list) && list is BsonArray stages) + { + foreach (BsonDocument cd in stages.OfType()) + { + FindSort(cd, ref bytes, ref disk); + } + } + } + + private static string Str(BsonDocument doc, string name) => + doc.TryGetValue(name, out BsonValue v) && v.IsString ? v.AsString : ""; + + private static long Number(BsonDocument doc, string name) => + doc.TryGetValue(name, out BsonValue v) && v.IsNumeric ? v.ToInt64() : 0; + + private static BsonDocument? Doc(BsonDocument doc, string name) => + doc.TryGetValue(name, out BsonValue v) && v.IsBsonDocument ? v.AsBsonDocument : null; +} + +/// 慢查询表的格式化(毫秒 / 秒、比例、计划标签)。 +internal static class ProfilerFormat +{ + /// 820 ms / 41.2 s。 + public static string Millis(double ms) => ms < 1000 + ? Math.Round(ms).ToString("0", CultureInfo.InvariantCulture) + " ms" + : (ms / 1000).ToString(ms < 100_000 ? "0.0" : "0", CultureInfo.InvariantCulture) + " s"; + + /// + /// 扫描 : 返回(42 : 1)。比值约到整数;一条都没返回时写成 扫描数 : 0 —— + /// "扫了一百万条、返回零条"本身就是最该看的那种。 + /// + public static string Ratio(long examined, long returned) => returned <= 0 + ? $"{BsonText.Grouped(examined)} : 0" + : $"{Math.Max(0, Math.Round((double)examined / returned)).ToString("0", CultureInfo.InvariantCulture)} : 1"; + + /// 扫描与返回之比是否偏高(≥ 10 : 1,或扫了不少却一条没返回)。 + public static bool RatioHigh(long examined, long returned) => + returned <= 0 ? examined >= 100 : (double)examined / returned >= 10; + + /// 计划标签文字。 + public static string PlanText(SlowPlanKind kind, string summary) => kind switch + { + SlowPlanKind.CollectionScan => "COLLSCAN", + SlowPlanKind.InMemorySort => "SORT", + SlowPlanKind.IndexScan => summary.Split(' ', 2)[0] is { Length: > 0 } head && head != "IXSCAN" ? head : "IXSCAN", + _ => summary.Length > 0 ? summary.Split(' ', 2)[0] : "—" + }; + + /// 计划标签的样式类(err / warn / ok / muted)。 + public static string PlanClass(SlowPlanKind kind) => kind switch + { + SlowPlanKind.CollectionScan => "err", + SlowPlanKind.InMemorySort => "warn", + SlowPlanKind.IndexScan => "ok", + _ => "muted" + }; +} + +/// 按形状聚合后的一行(设计稿 15 慢查询表)。 +internal sealed class ProfilerShapeRow +{ + /// 由同一形状的记录构造。 + /// 记录(任意顺序)。 + /// 全部慢操作的总耗时(算占比)。 + public ProfilerShapeRow(IReadOnlyList entries, double overallMillis) + { + Entries = entries; + Shape = entries[0].Shape; + int runs = entries.Count(static e => !e.IsGetMore); + // getMore 是同一次查询的后续批次:耗时与扫描量算进来,次数不算 —— 否则平均耗时会被一串几毫秒的批次摊薄。 + Count = runs > 0 ? runs : entries.Count; + TotalMillis = entries.Sum(static e => e.Millis); + MaxMillis = entries.Max(static e => e.Millis); + Examined = entries.Sum(static e => e.DocsExamined); + Returned = entries.Sum(static e => e.Returned); + Plan = entries.Max(static e => e.Plan); + Latest = entries.Where(static e => !e.IsGetMore).MaxBy(static e => e.Time) ?? entries.MaxBy(static e => e.Time)!; + Share = overallMillis <= 0 ? 0 : TotalMillis / overallMillis; + } + + /// 形状。 + public QueryShape Shape { get; } + + /// 记录。 + public IReadOnlyList Entries { get; } + + /// 执行次数。 + public int Count { get; } + + /// 总耗时。 + public double TotalMillis { get; } + + /// 平均耗时。 + public double AvgMillis => TotalMillis / Math.Max(1, Count); + + /// 最大耗时。 + public double MaxMillis { get; } + + /// 扫描文档总数。 + public long Examined { get; } + + /// 返回文档总数。 + public long Returned { get; } + + /// 最差的计划。 + public SlowPlanKind Plan { get; } + + /// 最近一次样本。 + public ProfileEntry Latest { get; } + + /// 占慢查询总耗时的比例。 + public double Share { get; } + + /// 首行(orders.find)。 + public string Title => Shape.Title; + + /// 次行(归一化条件)。 + public string Summary => Shape.Summary; + + /// 次数。 + public string CountText => BsonText.Grouped(Count); + + /// 平均。 + public string AvgText => ProfilerFormat.Millis(AvgMillis); + + /// 最大。 + public string MaxText => ProfilerFormat.Millis(MaxMillis); + + /// 扫描 : 返回。 + public string RatioText => ProfilerFormat.Ratio(Examined, Returned); + + /// 扫描比偏高(橙字)。 + public bool RatioWarn => ProfilerFormat.RatioHigh(Examined, Returned); + + /// 有问题的计划(平均耗时转橙)。 + public bool AvgWarn => Plan is SlowPlanKind.CollectionScan or SlowPlanKind.InMemorySort; + + /// 计划标签。 + public string PlanText => ProfilerFormat.PlanText(Plan, Latest.PlanSummary); + + /// 计划标签:红。 + public bool PlanErr => Plan == SlowPlanKind.CollectionScan; + + /// 计划标签:橙。 + public bool PlanWarn => Plan == SlowPlanKind.InMemorySort; + + /// 计划标签:绿。 + public bool PlanOk => Plan == SlowPlanKind.IndexScan; + + /// 计划标签:灰(EOF、命令之类)。 + public bool PlanOther => Plan == SlowPlanKind.Other; +} + +/// 逐条模式的一行。 +/// 记录。 +/// 所属形状。 +internal sealed record ProfilerEntryRow(ProfileEntry Entry, ProfilerShapeRow Group) +{ + /// 首行(orders.find)。 + public string Title => Entry.Shape.Title + (Entry.IsGetMore ? " · getMore" : ""); + + /// 次行:这一条的真实条件(不打码)。 + public string Summary + { + get + { + (BsonDocument filter, BsonDocument? sort) = Entry.FilterAndSort(); + string text = QueryShape.Plain(filter); + if (filter.ElementCount == 0) + { + text = "{}"; + } + return sort is { ElementCount: > 0 } ? $"{text} sort {QueryShape.Plain(sort)}" : text; + } + } + + /// 时间(本地 HH:mm:ss)。 + public string TimeText => Entry.Time.ToLocalTime().ToString("HH:mm:ss", CultureInfo.InvariantCulture); + + /// 耗时。 + public string MillisText => ProfilerFormat.Millis(Entry.Millis); + + /// 扫描 : 返回。 + public string RatioText => ProfilerFormat.Ratio(Entry.DocsExamined, Entry.Returned); + + /// 扫描比偏高。 + public bool RatioWarn => ProfilerFormat.RatioHigh(Entry.DocsExamined, Entry.Returned); + + /// 计划标签。 + public string PlanText => ProfilerFormat.PlanText(Entry.Plan, Entry.PlanSummary); + + /// 计划标签:红。 + public bool PlanErr => Entry.Plan == SlowPlanKind.CollectionScan; + + /// 计划标签:橙。 + public bool PlanWarn => Entry.Plan == SlowPlanKind.InMemorySort; + + /// 计划标签:绿。 + public bool PlanOk => Entry.Plan == SlowPlanKind.IndexScan; + + /// 计划标签:灰。 + public bool PlanOther => Entry.Plan == SlowPlanKind.Other; + + /// 有问题的计划(耗时转橙)。 + public bool MillisWarn => Entry.Plan is SlowPlanKind.CollectionScan or SlowPlanKind.InMemorySort; +} + +/// 当前操作表的一行。 +/// opid 原值(killOp 要原样传回去:单机是整数,mongos 是 shard:opid 字符串)。 +/// opid 文字。 +/// 类型(query / getmore / update / command)。 +/// 命名空间。 +/// 命令摘要。 +/// 客户端。 +/// 已运行秒数。 +/// 进度条比例(相对当前最长的那条)。 +internal sealed record ProfilerOpRow( + BsonValue OpId, + string OpIdText, + string Type, + string Namespace, + string Summary, + string Client, + double Seconds, + double Ratio) +{ + /// 运行超过 10 秒(整行淡红 + 红色进度条与按钮)。 + public bool IsLong => Seconds > 10; + + /// 已运行(41.2 s)。 + public string ElapsedText => Seconds.ToString(Seconds < 100 ? "0.0" : "0", CultureInfo.InvariantCulture) + " s"; + + /// 进度条颜色令牌。 + public string BarToken => IsLong ? "VelaError" : "MongoChart1"; +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Profiler/ProfilerTabView.axaml b/plugins/VelaShell.Plugin.Mongo/Ui/Profiler/ProfilerTabView.axaml new file mode 100644 index 0000000..437fd1e --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Profiler/ProfilerTabView.axaml @@ -0,0 +1,447 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + M10 2 h4 M12 14 l3 -3 M4 14 a8 8 0 1 0 16 0 a8 8 0 1 0 -16 0 z + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Profiler/ProfilerTabView.axaml.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Profiler/ProfilerTabView.axaml.cs new file mode 100644 index 0000000..db37151 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Profiler/ProfilerTabView.axaml.cs @@ -0,0 +1,76 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Interactivity; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 慢查询与当前操作视图(设计稿 15)。 +public sealed partial class ProfilerTabView : UserControl +{ + /// 用给定的视图模型初始化。 + internal ProfilerTabView(ProfilerTabViewModel viewModel) + : this() + { + DataContext = viewModel; + } + + /// 设计器 / headless 用的无参构造。 + public ProfilerTabView() + { + InitializeComponent(); + // slowms / sampleRate:回车或离开输入框即应用 —— 这两个值改一下就要打一次 profile 命令, + // 不该每敲一个字符就发一次。 + foreach (TextBox box in new[] { SlowMsBox, SampleRateBox }) + { + box.KeyDown += OnSettingKeyDown; + box.LostFocus += OnSettingLostFocus; + } + ActualThemeVariantChanged += (_, _) => MonitorChartColors.Sync(this); + } + + /// + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + // "已运行"进度条用 MongoChart1(设计稿 15 的蓝条),同监控页一样先把图表色同步好。 + MonitorChartColors.Sync(this); + } + + /// + /// 样本代码块是只读展示、高度按行数给足:关掉"滚过文末"与竖向滚动条, + /// 否则几行语句的小块也挂着一条滚动条。 + /// + private void OnSampleEditorAttached(object? sender, VisualTreeAttachmentEventArgs e) + { + if (sender is CodeEditor editor) + { + editor.Editor.Options.AllowScrollBelowDocument = false; + editor.Editor.VerticalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Disabled; + // CodeEditor 在构造里把字号绑到了 VelaFontSize12,AXAML 里写的字号压不过它; + // 设计稿的样本代码是 10px,挂进可视树之后再绑一次(它会把字号转给里面那个编辑器)。 + editor[!TemplatedControl.FontSizeProperty] = editor.GetResourceObservable("VelaFontSize10").ToBinding(); + // 左边还有一列诊断标记边栏(只读块里永远是空的),内边距就不再另加 8px,字与设计稿的 8px 缩进对齐。 + editor.Editor.Padding = new Thickness(0, 6, 8, 6); + } + } + + private void OnSettingKeyDown(object? sender, KeyEventArgs e) + { + if (e.Key == Key.Enter && DataContext is ProfilerTabViewModel vm) + { + vm.ApplySettingsCommand.Execute(null); + e.Handled = true; + } + } + + private void OnSettingLostFocus(object? sender, RoutedEventArgs e) + { + if (DataContext is ProfilerTabViewModel vm) + { + vm.ApplySettingsCommand.Execute(null); + } + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Profiler/ProfilerTabViewModel.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Profiler/ProfilerTabViewModel.cs new file mode 100644 index 0000000..949169a --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Profiler/ProfilerTabViewModel.cs @@ -0,0 +1,925 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Globalization; +using MongoDB.Bson; +using MongoDB.Driver; +using VelaShell.Plugin.Mongo.Analysis; +using VelaShell.Plugin.Mongo.Bson; +using VelaShell.Plugin.Mongo.Core; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 慢查询与当前操作(设计稿 15):profiler 级别 / slowms / sampleRate、$currentOp 实时列表(可 killOp)、 +/// 近 24 小时 system.profile 按查询形状聚合,以及选中形状的样本、计划与建议索引。 +/// +/// $currentOp 每 2 秒刷新一次、慢查询每 30 秒重读一次,**只在标签可见时**; +/// 切走就停 —— 与监控页同一个理由:没人看的页面不该一直打服务器。 +/// +/// +internal sealed class ProfilerTabViewModel : WorkspaceTab +{ + /// 一次最多读多少条 profile 记录(按时间倒序)。再多就只算条数,不再逐条聚合。 + internal const int ProfileLimit = 5000; + + private static readonly TimeSpan SlowWindow = TimeSpan.FromHours(24); + private static readonly TimeSpan SlowRefresh = TimeSpan.FromSeconds(30); + + private readonly MonitorPoller _poller; + private readonly SemaphoreSlim _tickGate = new(1, 1); + private bool _loaded; + private bool _disposed; + private bool _failureShown; + private int _level; + private int _slowMs = 100; + private double _sampleRate = 1; + private string _slowMsText = "100"; + private string _sampleRateText = "1.0"; + private bool _onlyLong; + private bool _ownOpsOnly; + private int _activeCount; + private string _mode = "shape"; + private string _sortKey = "avg"; + private ProfilerShapeRow? _selectedShape; + private ProfilerEntryRow? _selectedEntry; + private ProfilerDetail? _detail; + private long _profileCount; + private bool _profileDenied; + private string _slowEmptyText = ""; + private DateTime _slowLoadedAt = DateTime.MinValue; + private List _groups = []; + + /// 构造。不在这里发请求:同键标签已开着时,外壳会把新建的这个直接丢掉。 + /// 外壳服务。 + /// 库名。 + public ProfilerTabViewModel(IMongoWorkspace workspace, string database) + : base(workspace) + { + Database = database; + Title = workspace.Loc["Toolbar_SlowQueries"]; + Scope = "· " + database; + _poller = new MonitorPoller(TickAsync, TimeSpan.FromSeconds(2)); + SetLevelCommand = new AsyncCommand(SetLevelAsync); + ApplySettingsCommand = new AsyncCommand(ApplySettingsAsync); + RefreshCommand = new AsyncCommand(RefreshAsync); + ClearCommand = new AsyncCommand(ClearAsync); + KillCommand = new AsyncCommand(KillAsync); + SortCommand = new RelayCommand(key => SortKey = key); + CreateIndexCommand = new AsyncCommand(CreateIndexAsync, () => _detail?.CanCreateIndex == true); + OpenInEditorCommand = new RelayCommand(() => + { + if (_detail is { } d) + { + Workspace.OpenQuery(Database, d.Statement); + } + }); + ExplainCommand = new RelayCommand(() => + { + if (_detail is { } d) + { + Workspace.OpenQuery(Database, d.Explain, run: true); + } + }); + PropertyChanged += OnOwnPropertyChanged; + } + + /// 库名。 + public string Database { get; } + + /// + public override TabKind Kind => TabKind.Profiler; + + /// + public override string Key => $"profiler:{Database}"; + + /// + public override string IconKey => "Mongo.timer"; + + /// + public override string IconToken => "VelaWarning"; + + // ── profiler 设置 ───────────────────────────────────────────────────── + + /// 当前 profiler 级别(0 / 1 / 2)。 + public int Level + { + get => _level; + private set + { + _level = value; + // 无论值变没变都通知:分段按钮被点过之后自己已经切了勾,确认框里点了取消要把它扳回来。 + RaisePropertyChanged(); + RaisePropertyChanged(nameof(LevelKey)); + } + } + + /// 级别的字符串形式(分段按钮用 IsEqual 比较)。 + public string LevelKey => _level.ToString(CultureInfo.InvariantCulture); + + /// slowms 输入框。 + public string SlowMsText + { + get => _slowMsText; + set + { + if (SetProperty(ref _slowMsText, value)) + { + RaisePropertyChanged(nameof(SlowMsInvalid)); + } + } + } + + /// sampleRate 输入框。 + public string SampleRateText + { + get => _sampleRateText; + set + { + if (SetProperty(ref _sampleRateText, value)) + { + RaisePropertyChanged(nameof(SampleRateInvalid)); + } + } + } + + /// slowms 不是非负整数。 + public bool SlowMsInvalid => !TryParseSlowMs(_slowMsText, out _); + + /// sampleRate 不在 (0, 1]。 + public bool SampleRateInvalid => !TryParseSampleRate(_sampleRateText, out _); + + /// 切级别(参数 "0" / "1" / "2";level 2 要确认)。 + public AsyncCommand SetLevelCommand { get; } + + /// 应用 slowms / sampleRate(回车或失焦)。 + public AsyncCommand ApplySettingsCommand { get; } + + /// 刷新(profile 状态、当前操作、慢查询)。 + public AsyncCommand RefreshCommand { get; } + + /// 清空 system.profile。 + public AsyncCommand ClearCommand { get; } + + // ── 当前操作 ────────────────────────────────────────────────────────── + + /// 当前操作。 + public ObservableCollection Ops { get; } = []; + + /// 活跃操作数。 + public int ActiveCount + { + get => _activeCount; + private set + { + if (SetProperty(ref _activeCount, value)) + { + RaisePropertiesChanged(nameof(OpsHeader), nameof(HasOps)); + } + } + } + + /// 有没有操作。 + public bool HasOps => _activeCount > 0; + + /// 「$currentOp · 活跃 4 · 每 2 s 刷新」。 + public string OpsHeader => Loc.Format(_ownOpsOnly ? "Prof_OpsHeaderOwn" : "Prof_OpsHeader", _activeCount); + + /// 仅显示运行 > 1 s。 + public bool OnlyLong + { + get => _onlyLong; + set + { + if (SetProperty(ref _onlyLong, value)) + { + _ = RefreshOpsAsync(CancellationToken.None); + } + } + } + + /// 终止一个操作。 + public AsyncCommand KillCommand { get; } + + // ── 慢查询 ──────────────────────────────────────────────────────────── + + /// 按形状(shape)还是逐条(entry)。 + public string Mode + { + get => _mode; + set + { + if (value is "shape" or "entry" && SetProperty(ref _mode, value)) + { + RaisePropertiesChanged(nameof(ShapeMode), nameof(EntryMode)); + // 换模式时详情跟着那一边的选中项走;逐条那边还没选过就保留当前详情。 + if (ShapeMode && _selectedShape is { } shape) + { + ShowDetail(shape, shape.Latest); + } + else if (EntryMode && _selectedEntry is { } entry) + { + ShowDetail(entry.Group, entry.Entry); + } + } + } + } + + /// 按形状模式。 + public bool ShapeMode => _mode == "shape"; + + /// 逐条模式。 + public bool EntryMode => _mode == "entry"; + + /// 按哪一列排序(count / avg / max,都是降序)。 + public string SortKey + { + get => _sortKey; + set + { + if (value is "count" or "avg" or "max" && SetProperty(ref _sortKey, value)) + { + ApplyShapes(_groups); + } + } + } + + /// 点列头换排序。 + public RelayCommand SortCommand { get; } + + /// 形状行。 + public ObservableCollection Shapes { get; } = []; + + /// 逐条行。 + public ObservableCollection Entries { get; } = []; + + /// 选中的形状。 + public ProfilerShapeRow? SelectedShape + { + get => _selectedShape; + set + { + if (SetProperty(ref _selectedShape, value) && value is not null) + { + ShowDetail(value, value.Latest); + } + } + } + + /// 选中的一条。 + public ProfilerEntryRow? SelectedEntry + { + get => _selectedEntry; + set + { + if (SetProperty(ref _selectedEntry, value) && value is not null) + { + ShowDetail(value.Group, value.Entry); + } + } + } + + /// 右侧详情;没选中为 null。 + public ProfilerDetail? Detail + { + get => _detail; + private set + { + if (SetProperty(ref _detail, value)) + { + RaisePropertyChanged(nameof(HasDetail)); + CreateIndexCommand.RaiseCanExecuteChanged(); + } + } + } + + /// 有没有详情。 + public bool HasDetail => _detail is not null; + + /// 慢查询列表为空时的说明(profiler 没开 / 近 24 小时没有 / 没权限)。 + public string SlowEmptyText + { + get => _slowEmptyText; + private set + { + if (SetProperty(ref _slowEmptyText, value)) + { + RaisePropertyChanged(nameof(SlowEmpty)); + } + } + } + + /// 慢查询列表是空的。 + public bool SlowEmpty => _slowEmptyText.Length > 0; + + /// 近 24 小时的 profile 记录数。 + public long ProfileCount => _profileCount; + + /// 创建建议索引。 + public AsyncCommand CreateIndexCommand { get; } + + /// 在查询编辑器中打开样本语句。 + public RelayCommand OpenInEditorCommand { get; } + + /// 查看执行计划(语句 + explain("executionStats"),打开即运行)。 + public RelayCommand ExplainCommand { get; } + + // ── 生命周期 ────────────────────────────────────────────────────────── + + /// + public override async Task LoadAsync() + { + await LoadProfileStatusAsync(CancellationToken.None).ConfigureAwait(true); + await RefreshOpsAsync(CancellationToken.None).ConfigureAwait(true); + await LoadSlowAsync(CancellationToken.None).ConfigureAwait(true); + _loaded = true; + UpdatePolling(delayFirst: true); + } + + /// + protected override void OnActivated() => UpdatePolling(); + + /// + public override async Task RefreshAsync() + { + await LoadProfileStatusAsync(CancellationToken.None).ConfigureAwait(true); + await RefreshOpsAsync(CancellationToken.None).ConfigureAwait(true); + await LoadSlowAsync(CancellationToken.None).ConfigureAwait(true); + } + + /// + public override void Dispose() + { + _disposed = true; + _poller.Stop(); + PropertyChanged -= OnOwnPropertyChanged; + base.Dispose(); + } + + private void OnOwnPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(IsActive)) + { + UpdatePolling(); + } + } + + private void UpdatePolling(bool delayFirst = false) + { + if (_loaded && IsActive && !_disposed) + { + _poller.Start(delayFirst); + } + else + { + _poller.Stop(); + } + } + + private async Task TickAsync(CancellationToken token) + { + if (!await _tickGate.WaitAsync(0, CancellationToken.None).ConfigureAwait(true)) + { + return; + } + try + { + await RefreshOpsAsync(token).ConfigureAwait(true); + if (DateTime.UtcNow - _slowLoadedAt > SlowRefresh && !token.IsCancellationRequested) + { + await LoadSlowAsync(token).ConfigureAwait(true); + } + } + finally + { + _tickGate.Release(); + } + } + + private void Fail(Exception ex) + { + if (_failureShown) + { + return; + } + _failureShown = true; + Workspace.Toast(new() { Title = Loc.Format("Common_Failed", MongoConnector.Describe(ex)), Kind = ToastKind.Error }); + } + + /// + /// 本页发出的命令都带上监控标记:level 2 下 profiler 会把每条命令都记下来, + /// 不打标记的话"慢查询"里会混进本页自己每 30 秒一次的 profile 查询。 + /// + private static BsonDocument Tagged(BsonDocument command) + { + command["comment"] = MonitorOps.Comment; + return command; + } + + // ── profiler 设置 ───────────────────────────────────────────────────── + + private async Task LoadProfileStatusAsync(CancellationToken token) + { + try + { + BsonDocument r = await Workspace.Connection.RunCommandAsync(Database, Tagged(new BsonDocument("profile", -1)), token) + .ConfigureAwait(true); + ApplyStatus(r); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Fail(ex); + } + UpdateStatus(); + } + + private void ApplyStatus(BsonDocument r) + { + Level = r.GetValue("was", 0).ToInt32(); + _slowMs = r.GetValue("slowms", 100).ToInt32(); + _sampleRate = r.TryGetValue("sampleRate", out BsonValue sr) && sr.IsNumeric ? sr.ToDouble() : 1; + SlowMsText = _slowMs.ToString(CultureInfo.InvariantCulture); + SampleRateText = _sampleRate.ToString("0.0##", CultureInfo.InvariantCulture); + } + + private async Task SetLevelAsync(string key) + { + if (!int.TryParse(key, NumberStyles.Integer, CultureInfo.InvariantCulture, out int level) || level is < 0 or > 2) + { + return; + } + if (level == _level) + { + Level = _level; + return; + } + if (level == 2 && !await Workspace.ConfirmAsync(new() + { + Title = Loc["Prof_Level2Title"], + Message = Loc.Format("Prof_Level2Body", Database), + ConfirmLabel = Loc["Prof_Level2Confirm"], + IconKey = "Mongo.triangle-alert", + Danger = false + }).ConfigureAwait(true)) + { + Level = _level; + return; + } + await ApplyProfileAsync(level, _slowMs, _sampleRate).ConfigureAwait(true); + } + + private async Task ApplySettingsAsync() + { + if (!TryParseSlowMs(_slowMsText, out int slowMs) || !TryParseSampleRate(_sampleRateText, out double rate)) + { + return; + } + if (slowMs == _slowMs && Math.Abs(rate - _sampleRate) < 1e-9) + { + return; + } + await ApplyProfileAsync(_level, slowMs, rate).ConfigureAwait(true); + } + + /// + /// db.setProfilingLevel(level, { slowms, sampleRate }) 的等价命令。 + /// 注意 slowms 与 sampleRate 是**整个 mongod 实例**的设置(所有库共用),只有级别是按库的 —— + /// 输入框的提示里写明了这一点。 + /// + private async Task ApplyProfileAsync(int level, int slowMs, double sampleRate) + { + try + { + await Workspace.Connection.RunCommandAsync(Database, Tagged(new BsonDocument + { + { "profile", level }, + { "slowms", slowMs }, + { "sampleRate", sampleRate } + })).ConfigureAwait(true); + await LoadProfileStatusAsync(CancellationToken.None).ConfigureAwait(true); + Workspace.Toast(new() { Title = Loc.Format("Prof_LevelApplied", _level, _slowMs), Kind = ToastKind.Success }); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Level = _level; + Workspace.Toast(new() { Title = Loc.Format("Common_Failed", MongoConnector.Describe(ex)), Kind = ToastKind.Error }); + } + } + + /// + /// 清空 system.profile。profiler 开着时这个集合删不掉(服务器会拒绝),所以是三步: + /// 先把级别设 0 → drop → 恢复原级别与阈值。中间失败也要尽量把级别恢复回去 —— + /// 用户点的是"清空记录",不是"关掉 profiler"。 + /// + private async Task ClearAsync() + { + if (!Workspace.EnsureWritable(Database)) + { + return; + } + if (!await Workspace.ConfirmAsync(new() + { + Title = Loc["Prof_ClearTitle"], + Message = Loc.Format("Prof_ClearBody", Database, BsonText.Grouped(_profileCount), _level), + ConfirmLabel = Loc["Prof_ClearConfirm"], + IconKey = "Mongo.eraser", + TypeToConfirm = Workspace.Guard.ConfirmWrites ? "system.profile" : null + }).ConfigureAwait(true)) + { + return; + } + int original = _level; + try + { + if (original > 0) + { + await Workspace.Connection.RunCommandAsync(Database, Tagged(new BsonDocument("profile", 0))).ConfigureAwait(true); + } + await Workspace.Connection.Database(Database).DropCollectionAsync("system.profile").ConfigureAwait(true); + Workspace.Toast(new() { Title = Loc.Format("Prof_Cleared", Database), Kind = ToastKind.Success }); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Workspace.Toast(new() { Title = Loc.Format("Common_Failed", MongoConnector.Describe(ex)), Kind = ToastKind.Error }); + } + finally + { + if (original > 0) + { + try + { + await Workspace.Connection.RunCommandAsync(Database, Tagged(new BsonDocument + { + { "profile", original }, + { "slowms", _slowMs }, + { "sampleRate", _sampleRate } + })).ConfigureAwait(true); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Workspace.Toast(new() { Title = Loc.Format("Prof_RestoreFailed", original, MongoConnector.Describe(ex)), Kind = ToastKind.Error }); + } + } + } + await RefreshAsync().ConfigureAwait(true); + } + + internal static bool TryParseSlowMs(string text, out int value) => + int.TryParse(text.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out value) && value >= 0; + + internal static bool TryParseSampleRate(string text, out double value) => + double.TryParse(text.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out value) && value is > 0 and <= 1; + + // ── 当前操作 ────────────────────────────────────────────────────────── + + private async Task RefreshOpsAsync(CancellationToken token) + { + var match = new BsonDocument + { + { "active", true }, + { "op", new BsonDocument("$ne", "none") }, + // 驱动的心跳(awaitable hello)常年挂在列表里、一挂十秒,却不是任何人的负载。 + { "command.hello", new BsonDocument("$exists", false) }, + { "command.isMaster", new BsonDocument("$exists", false) }, + { "command.ismaster", new BsonDocument("$exists", false) } + }; + if (_onlyLong) + { + match["microsecs_running"] = new BsonDocument("$gte", 1_000_000); + } + IReadOnlyList ops; + try + { + (ops, _ownOpsOnly) = await MonitorOps.ListAsync(Workspace.Connection, match, token).ConfigureAwait(true); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Fail(ex); + return; + } + if (token.IsCancellationRequested || _disposed) + { + return; + } + double longest = ops.Select(Seconds).DefaultIfEmpty(0).Max(); + var rows = ops + .OrderByDescending(Seconds) + .Select(op => ToRow(op, Math.Max(longest, 1))) + .ToList(); + Ops.Clear(); + foreach (ProfilerOpRow row in rows) + { + Ops.Add(row); + } + ActiveCount = rows.Count; + RaisePropertyChanged(nameof(OpsHeader)); + } + + private static double Seconds(BsonDocument op) => + op.TryGetValue("microsecs_running", out BsonValue us) && us.IsNumeric + ? us.ToDouble() / 1_000_000 + : op.TryGetValue("secs_running", out BsonValue s) && s.IsNumeric ? s.ToDouble() : 0; + + private static ProfilerOpRow ToRow(BsonDocument op, double longest) + { + double seconds = Seconds(op); + string client = op.GetValue("client", "").ToString() ?? ""; + if (op.TryGetValue("effectiveUsers", out BsonValue users) && users is BsonArray { Count: > 0 } list + && list[0] is BsonDocument user && user.TryGetValue("user", out BsonValue name)) + { + string host = client.Contains(':') ? client[..client.LastIndexOf(':')] : client; + client = $"{name}@{host}"; + } + return new ProfilerOpRow( + op.GetValue("opid", BsonNull.Value), + MonitorOps.OpId(op), + op.GetValue("op", "").ToString() ?? "", + op.GetValue("ns", "").ToString() ?? "", + CommandSummary(op), + client.Length > 0 ? client : (op.GetValue("desc", "").ToString() ?? ""), + seconds, + seconds / longest); + } + + /// + /// 命令摘要(设计稿 15:find { customer.level: "SVIP", total: { $gte: 5000 } }、 + /// aggregate [$match, $group] · cursor 7f3a…、updateMany { expireAt: { $lt: … } }、 + /// createIndexes orders.paidAt_1)。只取能一眼认出"这是哪个查询"的部分,全文看日志。 + /// + internal static string CommandSummary(BsonDocument op) + { + BsonDocument cmd = op.GetValue("command", new BsonDocument()) as BsonDocument ?? []; + string type = op.GetValue("op", "").ToString() ?? ""; + if (cmd.Contains("q") && type is "update" or "remove") + { + bool multi = type == "update" ? cmd.GetValue("multi", false).ToBoolean() : cmd.GetValue("limit", 1).ToDouble() == 0; + string verb = type == "update" ? multi ? "updateMany" : "updateOne" : multi ? "deleteMany" : "deleteOne"; + return Line($"{verb} {Filter(cmd.GetValue("q", new BsonDocument()))}"); + } + if (cmd.ElementCount == 0) + { + return op.GetValue("desc", "").ToString() ?? ""; + } + string head = cmd.GetElement(0).Name; + switch (head) + { + case "find": + return Line($"find {Filter(cmd.GetValue("filter", new BsonDocument()))}"); + case "aggregate": + return Line($"aggregate {Stages(cmd)}"); + case "getMore": + { + BsonDocument origin = op.GetValue("cursor", new BsonDocument()) is BsonDocument c + && c.GetValue("originatingCommand", BsonNull.Value) is BsonDocument oc + ? oc + : op.GetValue("originatingCommand", new BsonDocument()) as BsonDocument ?? []; + string id = cmd.GetValue("getMore", 0).ToString() ?? ""; + string cursor = id.Length > 4 ? $"cursor {id[..4]}…" : $"cursor {id}"; + string what = origin.Contains("aggregate") ? $"aggregate {Stages(origin)}" + : origin.Contains("find") ? $"find {Filter(origin.GetValue("filter", new BsonDocument()))}" + : "getMore"; + return Line($"{what} · {cursor}"); + } + case "update" when cmd.GetValue("updates", BsonNull.Value) is BsonArray { Count: > 0 } updates && updates[0] is BsonDocument first: + return Line($"{(first.GetValue("multi", false).ToBoolean() ? "updateMany" : "updateOne")} {Filter(first.GetValue("q", new BsonDocument()))}"); + case "delete" when cmd.GetValue("deletes", BsonNull.Value) is BsonArray { Count: > 0 } deletes && deletes[0] is BsonDocument d: + return Line($"{(d.GetValue("limit", 0).ToDouble() == 0 ? "deleteMany" : "deleteOne")} {Filter(d.GetValue("q", new BsonDocument()))}"); + case "createIndexes": + { + string names = cmd.GetValue("indexes", new BsonArray()) is BsonArray specs + ? string.Join(", ", specs.OfType().Select(static s => s.GetValue("name", "").ToString())) + : ""; + return Line($"createIndexes {cmd[0]}.{names}"); + } + case "count" or "distinct": + return Line($"{head} {Filter(cmd.GetValue("query", new BsonDocument()))}"); + default: + return Line($"{head} {cmd[0]}"); + } + } + + private static string Filter(BsonValue value) => value is BsonDocument doc ? QueryShape.Plain(doc) : BsonText.Literal(value); + + private static string Stages(BsonDocument command) => + command.GetValue("pipeline", new BsonArray()) is BsonArray pipeline + ? "[" + string.Join(", ", pipeline.OfType().Where(static s => s.ElementCount > 0).Select(static s => s.GetElement(0).Name)) + "]" + : "[]"; + + private static string Line(string text) => BsonText.OneLine(text); + + /// + /// killOp。被杀的操作在客户端那边收到 Interrupted 错误;写操作已经写下去的部分不会回滚 —— + /// 这一点写进确认框,生产连接要手打 opid。 + /// + private async Task KillAsync(ProfilerOpRow row) + { + if (!await Workspace.ConfirmAsync(new() + { + Title = Loc["Prof_KillTitle"], + Message = Loc.Format("Prof_KillBody", row.OpIdText, row.Type, row.Namespace, row.ElapsedText), + ConfirmLabel = Loc["Prof_Kill"], + IconKey = "Mongo.octagon-x", + Facts = + [ + new(Loc["Prof_ColCommand"], row.Summary.Length > 60 ? row.Summary[..60] + "…" : row.Summary), + new(Loc["Prof_ColClient"], row.Client) + ], + TypeToConfirm = Workspace.Guard.ConfirmWrites ? row.OpIdText : null + }).ConfigureAwait(true)) + { + return; + } + try + { + await Workspace.Connection.RunCommandAsync("admin", Tagged(new BsonDocument { { "killOp", 1 }, { "op", row.OpId } })) + .ConfigureAwait(true); + Workspace.Toast(new() { Title = Loc.Format("Prof_Killed", row.OpIdText), Kind = ToastKind.Success }); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Workspace.Toast(new() { Title = Loc.Format("Common_Failed", MongoConnector.Describe(ex)), Kind = ToastKind.Error }); + } + await RefreshOpsAsync(CancellationToken.None).ConfigureAwait(true); + } + + // ── 慢查询 ──────────────────────────────────────────────────────────── + + /// + /// 读近 24 小时的 system.profile(按时间倒序,至多 条),客户端按形状聚合。 + /// 不用 $group 在服务器上聚:形状归一化(字面值 → ?)是递归改写整个条件文档, + /// 用聚合表达式写不出来;5000 条记录在客户端分组是毫秒级的事。 + /// + internal async Task LoadSlowAsync(CancellationToken token) + { + _slowLoadedAt = DateTime.UtcNow; + var filter = new BsonDocument + { + { "ts", new BsonDocument("$gte", DateTime.UtcNow - SlowWindow) }, + { "command.comment", new BsonDocument("$ne", MonitorOps.Comment) }, + { "command.profile", new BsonDocument("$exists", false) } + }; + List docs; + long count; + try + { + IMongoCollection profile = Workspace.Connection.Collection(Database, "system.profile"); + docs = await profile.Find(filter, new FindOptions { Comment = MonitorOps.Comment }) + .Sort(new BsonDocument("ts", -1)) + .Limit(ProfileLimit) + .Project(new BsonDocument { { "locks", 0 }, { "storage", 0 }, { "flowControl", 0 }, { "readConcern", 0 }, { "writeConcern", 0 } }) + .ToListAsync(token).ConfigureAwait(true); + count = docs.Count < ProfileLimit + ? docs.Count + : await profile.CountDocumentsAsync(filter, new CountOptions { Comment = MonitorOps.Comment }, token).ConfigureAwait(true); + _profileDenied = false; + } + catch (MongoCommandException ex) when (ex.Code == 13) + { + _profileDenied = true; + docs = []; + count = 0; + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Fail(ex); + return; + } + if (token.IsCancellationRequested || _disposed) + { + return; + } + _profileCount = count; + RaisePropertyChanged(nameof(ProfileCount)); + List entries = [.. docs.Select(ProfileEntry.Parse).Where(static e => !e.IsDiagnostic)]; + double overall = entries.Sum(static e => e.Millis); + _groups = [.. entries.GroupBy(static e => e.Shape.Key).Select(g => new ProfilerShapeRow([.. g], overall))]; + ApplyShapes(_groups); + + // 逐条:按时间倒序,复用形状行里的统计(详情要"占比")。 + Dictionary byKey = _groups.ToDictionary(static g => g.Shape.Key, StringComparer.Ordinal); + ProfileEntry? keepEntry = _selectedEntry?.Entry; + Entries.Clear(); + foreach (ProfileEntry e in entries) + { + Entries.Add(new ProfilerEntryRow(e, byKey[e.Shape.Key])); + } + if (keepEntry is not null) + { + ProfilerEntryRow? again = Entries.FirstOrDefault(r => r.Entry.Time == keepEntry.Time && r.Entry.Shape.Key == keepEntry.Shape.Key); + _selectedEntry = again; + RaisePropertyChanged(nameof(SelectedEntry)); + if (again is not null && EntryMode) + { + ShowDetail(again.Group, again.Entry); + } + } + + SlowEmptyText = _profileDenied + ? Loc["Prof_EmptyDenied"] + : _groups.Count > 0 + ? "" + : _level == 0 ? Loc["Prof_EmptyOff"] : Loc["Prof_EmptyNone"]; + if (_groups.Count == 0) + { + Detail = null; + } + UpdateStatus(); + } + + private void ApplyShapes(List groups) + { + string? keep = _selectedShape?.Shape.Key; + IEnumerable sorted = _sortKey switch + { + "count" => groups.OrderByDescending(static g => g.Count), + "max" => groups.OrderByDescending(static g => g.MaxMillis), + _ => groups.OrderByDescending(static g => g.AvgMillis) + }; + Shapes.Clear(); + foreach (ProfilerShapeRow g in sorted) + { + Shapes.Add(g); + } + ProfilerShapeRow? select = (keep is null ? null : Shapes.FirstOrDefault(s => s.Shape.Key == keep)) ?? Shapes.FirstOrDefault(); + _selectedShape = null; + SelectedShape = select; + if (select is null) + { + RaisePropertyChanged(nameof(SelectedShape)); + } + } + + private void ShowDetail(ProfilerShapeRow group, ProfileEntry sample) + { + var detail = new ProfilerDetail(group, sample, Loc); + Detail = detail; + _ = CheckExistingIndexAsync(detail); + } + + /// + /// 建议的索引是不是已经有了(同序同向的前缀,或整体反向)。有的话不再让人重复创建, + /// 改为提示"已有索引却没用上"—— 那往往是统计或计划缓存的问题,该去看执行计划。 + /// + private async Task CheckExistingIndexAsync(ProfilerDetail detail) + { + if (detail.Advice is not { } advice || !detail.CanCreateIndex) + { + return; + } + IReadOnlyList indexes = await Workspace.Connection.ListIndexesAsync(Database, detail.Collection).ConfigureAwait(true); + BsonDocument? hit = indexes.FirstOrDefault(i => i.GetValue("key", BsonNull.Value) is BsonDocument key && advice.CoveredBy(key)); + if (hit is not null && ReferenceEquals(detail, _detail)) + { + detail.AdviceText = Loc.Format("Prof_AdviceExists", hit.GetValue("name", "").ToString()); + detail.CanCreateIndex = false; + CreateIndexCommand.RaiseCanExecuteChanged(); + } + } + + private async Task CreateIndexAsync() + { + if (_detail is not { Advice: { } advice } detail || !Workspace.EnsureWritable(Database)) + { + return; + } + string ns = $"{Database}.{detail.Collection}"; + long? docs = await Workspace.Connection.EstimatedCountAsync(Database, detail.Collection).ConfigureAwait(true); + if (!await Workspace.ConfirmAsync(new() + { + Title = Loc["Prof_CreateIndexTitle"], + Message = Loc.Format("Prof_CreateIndexBody", ns), + ConfirmLabel = Loc["Prof_CreateIndex"], + IconKey = "Mongo.key-round", + Danger = false, + Facts = + [ + new(Loc["Prof_FactKeys"], advice.KeysText), + new(Loc["Prof_FactDocs"], docs is { } n ? BsonText.Grouped(n) : "—") + ], + TypeToConfirm = Workspace.Guard.ConfirmWrites ? detail.Collection : null + }).ConfigureAwait(true)) + { + return; + } + try + { + string name = await Workspace.Connection.Collection(Database, detail.Collection).Indexes + .CreateOneAsync(new CreateIndexModel(advice.Keys)).ConfigureAwait(true); + Workspace.Toast(new() { Title = Loc.Format("Prof_IndexCreated", name), Detail = ns, Kind = ToastKind.Success }); + detail.AdviceText = Loc.Format("Prof_AdviceExists", name); + detail.CanCreateIndex = false; + CreateIndexCommand.RaiseCanExecuteChanged(); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Workspace.Toast(new() { Title = Loc.Format("Common_Failed", MongoConnector.Describe(ex)), Kind = ToastKind.Error }); + } + } + + private void UpdateStatus() + { + string level = _level == 0 + ? Loc["Prof_StatusOff"] + : Loc.Format("Prof_StatusLevel", _level, _slowMs); + StatusText = Loc.Format("Prof_Status", BsonText.Grouped(_profileCount), level); + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Query/CodeExportDialogView.axaml b/plugins/VelaShell.Plugin.Mongo/Ui/Query/CodeExportDialogView.axaml new file mode 100644 index 0000000..8355bf7 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Query/CodeExportDialogView.axaml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Query/CodeExportDialogView.axaml.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Query/CodeExportDialogView.axaml.cs new file mode 100644 index 0000000..0fc3aab --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Query/CodeExportDialogView.axaml.cs @@ -0,0 +1,20 @@ +using Avalonia.Controls; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 「导出为代码」对话框的视图。 +public sealed partial class CodeExportDialogView : UserControl +{ + /// 用给定的视图模型初始化。 + internal CodeExportDialogView(CodeExportDialogViewModel viewModel) + { + InitializeComponent(); + DataContext = viewModel; + } + + /// 设计器 / headless 用的无参构造。 + public CodeExportDialogView() + { + InitializeComponent(); + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Query/CodeExportDialogViewModel.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Query/CodeExportDialogViewModel.cs new file mode 100644 index 0000000..1ab68ad --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Query/CodeExportDialogViewModel.cs @@ -0,0 +1,76 @@ +using Avalonia.Controls; +using VelaShell.Plugin.Mongo.Shell; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 「导出为代码」:当前语句 → mongosh / C# / Python / Node.js / Java 的驱动写法,顶部分段切语言, +/// 下面只读代码框,底栏一键复制。 +/// +internal sealed class CodeExportDialogViewModel : DialogViewModel, IViewFactory +{ + private readonly ShellCommand _command; + private readonly string _database; + private readonly string _connectionString; + private string _target; + private string _code = ""; + + /// 构造。 + public CodeExportDialogViewModel(IMongoWorkspace workspace, ShellCommand command, string database, string connectionString, CodeTarget target) + : base(workspace) + { + _command = command; + _database = database; + _connectionString = connectionString; + _target = target.ToString(); + Title = workspace.Loc["Query_ExportCodeTitle"]; + Subtitle = command.Collection is { } c ? $"{command.Database ?? database}.{c} · {command.Operation}" : command.Operation; + CopyCommand = new AsyncCommand(CopyAsync); + Generate(); + } + + /// + public override string IconKey => "Mongo.code-xml"; + + /// + public override double Width => 720; + + /// + public override double Height => 520; + + /// 目标语言(分段控件绑定的字符串:Mongosh / CSharp / …)。 + public string Target + { + get => _target; + set + { + if (SetProperty(ref _target, value)) + { + Generate(); + } + } + } + + /// 生成的代码。 + public string Code + { + get => _code; + private set => SetProperty(ref _code, value); + } + + /// 复制。 + public AsyncCommand CopyCommand { get; } + + private CodeTarget Parsed => Enum.TryParse(_target, out CodeTarget t) ? t : CodeTarget.CSharp; + + private void Generate() => Code = CodeExport.Generate(_command, _database, Parsed, _connectionString); + + private async Task CopyAsync() + { + await Workspace.CopyAsync(_code).ConfigureAwait(true); + Workspace.Toast(new ToastRequest { Title = Loc.Format("Query_CodeCopied", CodeExport.Name(Parsed)), Kind = ToastKind.Success }); + } + + /// + public Control CreateView() => new CodeExportDialogView(this); +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Query/ExplainControls.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Query/ExplainControls.cs new file mode 100644 index 0000000..fad89f2 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Query/ExplainControls.cs @@ -0,0 +1,162 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.VisualTree; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 4px 的比例细条(设计稿 14 阶段卡片底部的耗时条):VelaBgActive 轨 + 令牌色填充,两端圆角。 +/// 卡片宽度随阶段数变化,用 Border 的固定宽度画不出比例 —— 这里按实际宽度现算。 +/// +public sealed class RatioBar : Control +{ + /// 比例(0–1)。 + public static readonly StyledProperty RatioProperty = + AvaloniaProperty.Register(nameof(Ratio)); + + /// 填充色令牌。 + public static readonly StyledProperty TokenProperty = + AvaloniaProperty.Register(nameof(Token), "MongoChart1"); + + static RatioBar() + { + AffectsRender(RatioProperty, TokenProperty); + HeightProperty.OverrideDefaultValue(4); + } + + /// 比例。 + public double Ratio + { + get => GetValue(RatioProperty); + set => SetValue(RatioProperty, value); + } + + /// 填充色令牌。 + public string? Token + { + get => GetValue(TokenProperty); + set => SetValue(TokenProperty, value); + } + + /// + public override void Render(DrawingContext context) + { + double h = Bounds.Height; + double radius = h / 2; + context.DrawRectangle(ThemeBrushes.Get("VelaBgActive", Brushes.Gray), null, new Rect(0, 0, Bounds.Width, h), radius, radius); + double w = Math.Clamp(Ratio, 0, 1) * Bounds.Width; + if (w > 0) + { + context.DrawRectangle(ThemeBrushes.Get(Token ?? "MongoChart1", Brushes.SteelBlue), null, new Rect(0, 0, Math.Max(w, h), h), radius, radius); + } + } +} + +/// +/// 阶段流的排版(设计稿 14):卡片与箭头交替,卡片等宽分掉剩余宽度、箭头固定 40,全部垂直居中。 +/// 阶段多到每张卡不足 时按最小宽度排开(外面套横向滚动)。 +/// +/// 放在横向可滚的 里时,测量拿到的可用宽度是无穷大 —— 那样卡片就没法"等分剩余宽度"。 +/// 这里改用滚动视口的宽度(减去 )来分,视口变了再重新测量。 +/// +/// +public sealed class ExplainFlowPanel : Panel +{ + /// 卡片最小宽度。 + public const double MinCardWidth = 150; + + /// 箭头宽度。 + public const double ArrowWidth = 40; + + /// 视口里要让出来的宽度(外层的左右边距)。 + public static readonly StyledProperty ReservedWidthProperty = + AvaloniaProperty.Register(nameof(ReservedWidth)); + + private ScrollViewer? _scroller; + + /// 视口里要让出来的宽度。 + public double ReservedWidth + { + get => GetValue(ReservedWidthProperty); + set => SetValue(ReservedWidthProperty, value); + } + + /// + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + _scroller = this.FindAncestorOfType(); + if (_scroller is not null) + { + _scroller.PropertyChanged += OnScrollerChanged; + } + } + + /// + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + if (_scroller is not null) + { + _scroller.PropertyChanged -= OnScrollerChanged; + } + _scroller = null; + base.OnDetachedFromVisualTree(e); + } + + private void OnScrollerChanged(object? sender, AvaloniaPropertyChangedEventArgs e) + { + if (e.Property == ScrollViewer.ViewportProperty) + { + InvalidateMeasure(); + } + } + + private double CardWidth(double available) + { + int cards = (Children.Count + 1) / 2; + int arrows = Children.Count / 2; + if (cards == 0) + { + return 0; + } + if (double.IsInfinity(available)) + { + double viewport = _scroller?.Viewport.Width ?? 0; + available = viewport > 0 ? viewport - ReservedWidth : cards * 220 + arrows * ArrowWidth; + } + return Math.Max(MinCardWidth, (available - arrows * ArrowWidth) / cards); + } + + /// + protected override Size MeasureOverride(Size availableSize) + { + double card = CardWidth(availableSize.Width); + double height = 0; + double width = 0; + for (int i = 0; i < Children.Count; i++) + { + double w = i % 2 == 0 ? card : ArrowWidth; + Children[i].Measure(new Size(w, double.PositiveInfinity)); + height = Math.Max(height, Children[i].DesiredSize.Height); + width += w; + } + return new Size(width, height); + } + + /// + protected override Size ArrangeOverride(Size finalSize) + { + double card = CardWidth(finalSize.Width); + double x = 0; + for (int i = 0; i < Children.Count; i++) + { + double w = i % 2 == 0 ? card : ArrowWidth; + Control child = Children[i]; + double h = child.DesiredSize.Height; + child.Arrange(new Rect(x, (finalSize.Height - h) / 2, w, h)); + x += w; + } + return finalSize; + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Query/ExplainPane.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Query/ExplainPane.cs new file mode 100644 index 0000000..8c9aad7 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Query/ExplainPane.cs @@ -0,0 +1,561 @@ +using System.Globalization; +using MongoDB.Bson; +using VelaShell.Plugin.Mongo.Analysis; +using VelaShell.Plugin.Mongo.Bson; +using VelaShell.Plugin.Mongo.Shell; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 阶段流里的一张阶段卡片(设计稿 14 的 IXSCAN / FETCH / LIMIT / PROJECTION)。 +internal sealed class ExplainStageCard +{ + /// 阶段。 + public required ExplainStage Stage { get; init; } + + /// 阶段名。 + public string Name => Stage.Name; + + /// 图标。 + public required string IconKey { get; init; } + + /// 图标颜色令牌。 + public required string IconToken { get; init; } + + /// 描边颜色令牌(获胜路径绿、需要注意橙、全表扫描红)。 + public required string StrokeToken { get; init; } + + /// 本阶段耗时(6 ms)。 + public required string Time { get; init; } + + /// 说明(索引名 + 边界、filter、limitAmount…)。 + public required string Detail { get; init; } + + /// 指标行。 + public required IReadOnlyList Metrics { get; init; } + + /// 耗时占比(0–1),画在卡片底部那根细条上。 + public required double TimeRatio { get; init; } + + /// 细条颜色令牌。 + public required string BarToken { get; init; } + + /// 底部提示(312 份文档读出后被丢弃);没有为 。 + public string? Note { get; init; } + + /// 提示颜色令牌。 + public string NoteToken { get; init; } = "VelaStatusConnected"; + + /// 提示图标。 + public string NoteIcon { get; init; } = "Mongo.circle-check"; + + /// 有没有说明。 + public bool HasDetail => Detail.Length > 0; + + /// 有没有提示。 + public bool HasNote => !string.IsNullOrEmpty(Note); +} + +/// 阶段之间的箭头(标出流过去的文档数)。 +/// 数量文字。 +internal sealed record ExplainArrow(string Count); + +/// 候选计划列表的一行。 +/// 获胜。 +/// 标签文字(获胜 / 拒绝)。 +/// 阶段链。 +/// 试运行数据(101 works · 返回 100)。 +/// 打分文字。 +internal sealed record ExplainCandidateRow(bool Winner, string Tag, string Chain, string Stats, string Score) +{ + /// 有没有打分。 + public bool HasScore => Score.Length > 0; +} + +/// 树视图的一行。 +/// 深度。 +/// 阶段名。 +/// 索引名 / filter。 +/// 指标摘要。 +internal sealed record ExplainTreeRow(int Depth, string Name, string Detail, string Metrics) +{ + /// 左缩进。 + public double Indent => 12 + Depth * 18; +} + +/// 摘要里的一格统计(总耗时、返回、扫描键…)。 +/// 标签。 +/// 值。 +/// 值用警告色。 +internal sealed record ExplainStat(string Label, string Value, bool Warn = false); + +/// +/// 「执行计划」页签(设计稿 14):可视化阶段流 / 树 / 原始 JSON 三种看法、verbosity 下拉、候选计划、 +/// 右侧摘要与 ESR 优化建议。真正跑 explain 的是查询标签页 —— 这里只负责把一份 摆出来。 +/// +internal sealed class ExplainPane : QueryPane +{ + private readonly QueryTabViewModel _owner; + private ExplainPlan? _plan; + private string _verbosity = "executionStats"; + private string _viewMode = "visual"; + private bool _isBusy; + private string? _error; + private string _commandText = ""; + + /// 构造。 + public ExplainPane(QueryTabViewModel owner) + : base(owner.Loc) + { + _owner = owner; + CreateIndexCommand = new AsyncCommand(() => _plan?.Advice is { } advice ? owner.CreateSuggestedIndexAsync(advice.Keys) : Task.CompletedTask, + () => _plan?.Advice is not null); + HintCompareCommand = new AsyncCommand(owner.CompareWithHintAsync, () => _plan is not null && Command is not null); + RefreshCommand = new AsyncCommand(() => Command is null ? Task.CompletedTask : owner.ExplainAsync(Command, Database ?? owner.Database)); + } + + /// + public override QueryPaneKind PaneKind => QueryPaneKind.Explain; + + /// + public override string Header => Loc["Query_TabExplain"]; + + /// + public override string IconKey => "Mongo.gauge"; + + /// 可选的 verbosity。 + public IReadOnlyList Verbosities { get; } = ["queryPlanner", "executionStats", "allPlansExecution"]; + + /// 当前 verbosity(改了就重跑)。 + public string Verbosity + { + get => _verbosity; + set + { + if (SetProperty(ref _verbosity, value) && Command is not null) + { + RefreshCommand.Execute(null); + } + } + } + + /// 看法(visual / tree / raw)。 + public string ViewMode + { + get => _viewMode; + set + { + if (SetProperty(ref _viewMode, value)) + { + RaisePropertyChanged(nameof(IsVisual)); + RaisePropertyChanged(nameof(IsTree)); + RaisePropertyChanged(nameof(IsRaw)); + RaisePropertyChanged(nameof(ShowVisual)); + RaisePropertyChanged(nameof(ShowTree)); + RaisePropertyChanged(nameof(ShowRaw)); + RaisePropertyChanged(nameof(RawJson)); + } + } + } + + /// 可视化。 + public bool IsVisual => _viewMode == "visual"; + + /// 树。 + public bool IsTree => _viewMode == "tree"; + + /// 原始 JSON。 + public bool IsRaw => _viewMode == "raw"; + + /// 显示阶段流(有计划且是可视化看法)。 + public bool ShowVisual => HasPlan && IsVisual; + + /// 显示树。 + public bool ShowTree => HasPlan && IsTree; + + /// 显示原始 JSON。 + public bool ShowRaw => HasPlan && IsRaw; + + /// 被解释的语句。 + public ShellCommand? Command { get; private set; } + + /// 语句作用的库。 + public string? Database { get; private set; } + + /// 解读结果。 + public ExplainPlan? Plan => _plan; + + /// 有计划可看。 + public bool HasPlan => _plan is not null && !_isBusy; + + /// 空态(还没跑过)。 + public bool IsEmpty => _plan is null && !_isBusy && _error is null; + + /// 跑着。 + public bool IsBusy + { + get => _isBusy; + private set + { + if (SetProperty(ref _isBusy, value)) + { + RaiseState(); + } + } + } + + /// 失败原因。 + public string? Error + { + get => _error; + private set + { + if (SetProperty(ref _error, value)) + { + RaiseState(); + } + } + } + + /// 有没有失败。 + public bool HasError => _error is not null && !_isBusy; + + /// 标题旁的语句摘要(db.orders.find(…).sort({ createdAt: -1 }).limit(100))。 + public string CommandText + { + get => _commandText; + private set => SetProperty(ref _commandText, value); + } + + /// 阶段流(卡片与箭头交替)。 + public IReadOnlyList FlowItems { get; private set; } = []; + + /// 候选计划。 + public IReadOnlyList Candidates { get; private set; } = []; + + /// 候选计划标题旁的说明。 + public string CandidatesNote { get; private set; } = ""; + + /// 有没有候选计划。 + public bool HasCandidates => Candidates.Count > 0; + + /// 摘要的六格统计。 + public IReadOnlyList Stats { get; private set; } = []; + + /// 有优化建议。 + public bool HasAdvice => _plan?.Advice is not null; + + /// 没有建议(计划健康)。 + public bool NoAdvice => _plan is not null && _plan.Advice is null; + + /// 建议的说明。 + public string AdviceText { get; private set; } = ""; + + /// 建议的索引键({ status: 1, createdAt: -1, total: 1 })。 + public string AdviceKeys { get; private set; } = ""; + + /// 建议的预期效果。 + public string AdvicePrediction { get; private set; } = ""; + + /// 有没有预期效果。 + public bool HasPrediction => AdvicePrediction.Length > 0; + + /// 树视图的行。 + public IReadOnlyList TreeRows { get; private set; } = []; + + /// 原始 JSON(只在切到原始视图时才拼)。 + public string RawJson => IsRaw && _plan is not null ? BsonText.Pretty(_plan.Raw, EjsonMode.Relaxed) : ""; + + /// 整份原始 JSON(复制 / 导出)。 + public string? RawText => _plan is null ? null : BsonText.Pretty(_plan.Raw, EjsonMode.Relaxed); + + /// 创建建议的索引。 + public AsyncCommand CreateIndexCommand { get; } + + /// 用 hint 对比。 + public AsyncCommand HintCompareCommand { get; } + + /// 重跑。 + public AsyncCommand RefreshCommand { get; } + + /// 开始一次 explain(清掉旧的,转圈)。 + internal void Begin(ShellCommand command, string database) + { + Command = command; + Database = database; + CommandText = Summarize(command); + Error = null; + IsBusy = true; + } + + /// 失败。 + internal void Fail(string message) + { + IsBusy = false; + Error = message; + } + + /// 摆出一份执行计划。 + internal void Show(ExplainPlan plan) + { + _plan = plan; + BuildFlow(plan); + BuildCandidates(plan); + BuildStats(plan); + BuildAdvice(plan); + TreeRows = + [ + .. plan.Tree.Select(s => new ExplainTreeRow(s.Depth, s.ShortName, s.IndexName is null ? s.Detail.Split('\n')[0] : "", Metrics(s))) + ]; + Error = null; + IsBusy = false; + foreach (string name in (string[])[nameof(FlowItems), nameof(Candidates), nameof(CandidatesNote), nameof(HasCandidates), nameof(Stats), + nameof(AdviceText), nameof(AdviceKeys), nameof(AdvicePrediction), nameof(HasPrediction), nameof(HasAdvice), nameof(NoAdvice), + nameof(TreeRows), nameof(RawJson), nameof(Plan)]) + { + RaisePropertyChanged(name); + } + CreateIndexCommand.RaiseCanExecuteChanged(); + HintCompareCommand.RaiseCanExecuteChanged(); + RaiseState(); + } + + private void RaiseState() + { + RaisePropertyChanged(nameof(HasPlan)); + RaisePropertyChanged(nameof(ShowVisual)); + RaisePropertyChanged(nameof(ShowTree)); + RaisePropertyChanged(nameof(ShowRaw)); + RaisePropertyChanged(nameof(IsEmpty)); + RaisePropertyChanged(nameof(HasError)); + } + + /// 语句的一行摘要:实参里的长文档折成 …,链上的短实参原样保留。 + internal static string Summarize(ShellCommand command) + { + var b = new System.Text.StringBuilder("db"); + if (command.Collection is { } c) + { + b.Append('.').Append(c); + } + if (command.Method is { } method) + { + b.Append('.').Append(method.Name).Append('(').Append(method.Arguments.Count == 0 ? "" : "…").Append(')'); + } + foreach (ShellCall call in command.Chain) + { + string args = string.Join(", ", call.Arguments.Select(static a => BsonText.Literal(a.Value))); + b.Append('.').Append(call.Name).Append('(').Append(args.Length > 40 ? "…" : args).Append(')'); + } + return b.ToString(); + } + + private void BuildFlow(ExplainPlan plan) + { + var items = new List(); + double total = Math.Max(1, Math.Max(plan.TotalMs ?? 0, plan.Stages.Sum(static s => s.OwnMs ?? 0))); + for (int i = 0; i < plan.Stages.Count; i++) + { + ExplainStage stage = plan.Stages[i]; + if (i > 0) + { + long? flowing = plan.Stages[i - 1].NReturned; + items.Add(new ExplainArrow(flowing is { } n ? BsonText.Grouped(n) : "")); + } + items.Add(Card(plan, stage, i, total)); + } + FlowItems = items; + } + + private ExplainStageCard Card(ExplainPlan plan, ExplainStage stage, int index, double total) + { + var metrics = new List(); + string? note = null; + string noteToken = "VelaStatusConnected"; + string noteIcon = "Mongo.circle-check"; + bool warnFetch = stage.Name == "FETCH" && stage.Discarded > 0 && stage.Selectivity < 0.8; + switch (stage.Name) + { + case "IXSCAN" or "EXPRESS_IXSCAN" or "COUNT_SCAN" or "DISTINCT_SCAN": + Add(metrics, "keysExamined", stage.KeysExamined); + Add(metrics, "nReturned", stage.NReturned); + if (stage.Direction is { } direction) + { + metrics.Add(new ExplainMetric(Loc["Query_ExplainDirection"], direction)); + } + if (plan.SortFromIndex) + { + note = Loc["Query_ExplainSortFromIndex"]; + } + break; + case "COLLSCAN": + Add(metrics, "docsExamined", stage.DocsExamined); + Add(metrics, "nReturned", stage.NReturned); + if (stage.Direction is { } scanDirection) + { + metrics.Add(new ExplainMetric(Loc["Query_ExplainDirection"], scanDirection)); + } + note = Loc["Query_ExplainCollScan"]; + noteToken = "VelaError"; + noteIcon = "Mongo.circle-x"; + break; + case "FETCH": + Add(metrics, "docsExamined", stage.DocsExamined); + Add(metrics, "nReturned", stage.NReturned); + if (stage.Selectivity is { } selectivity && stage.Filter is not null) + { + metrics.Add(new ExplainMetric(Loc["Query_ExplainSelectivity"], + (selectivity * 100).ToString("0.0", CultureInfo.InvariantCulture) + "%", selectivity < 0.5)); + } + if (warnFetch) + { + note = Loc.Format("Query_ExplainDiscarded", BsonText.Grouped(stage.Discarded)); + noteToken = "VelaWarning"; + noteIcon = "Mongo.triangle-alert"; + } + break; + case "LIMIT": + Add(metrics, "nReturned", stage.NReturned); + bool early = index > 0 && plan.Stages[index - 1].IsEof == false; + metrics.Add(new ExplainMetric(Loc["Query_ExplainEarlyExit"], early ? Loc["Common_Yes"] : Loc["Common_No"])); + break; + case "SORT": + Add(metrics, "nReturned", stage.NReturned); + if (stage.Raw.GetValue("totalDataSizeSorted", null) is { IsNumeric: true } size) + { + metrics.Add(new ExplainMetric(Loc["Query_ExplainSortedBytes"], BsonText.Bytes(size.ToInt64()))); + } + metrics.Add(new ExplainMetric("usedDisk", stage.Raw.GetValue("usedDisk", false).ToBoolean() ? Loc["Common_Yes"] : Loc["Common_No"])); + note = Loc["Query_ExplainMemorySort"]; + noteToken = "VelaWarning"; + noteIcon = "Mongo.triangle-alert"; + break; + case var name when name.StartsWith("PROJECTION", StringComparison.Ordinal): + Add(metrics, "nReturned", stage.NReturned); + metrics.Add(new ExplainMetric(Loc["Query_ExplainCovered"], plan.IsCovered ? Loc["Common_Yes"] : Loc["Common_No"])); + break; + default: + Add(metrics, "nReturned", stage.NReturned); + if (stage.Raw.GetValue("usedDisk", null) is { } usedDisk) + { + metrics.Add(new ExplainMetric("usedDisk", usedDisk.ToBoolean() ? Loc["Common_Yes"] : Loc["Common_No"])); + } + else + { + Add(metrics, "works", stage.Works); + } + break; + } + (string icon, string iconToken) = stage.Name switch + { + "IXSCAN" or "EXPRESS_IXSCAN" or "COUNT_SCAN" or "DISTINCT_SCAN" or "IDHACK" => ("Mongo.key-round", "VelaStatusConnected"), + "COLLSCAN" => ("Mongo.table-2", "VelaError"), + "FETCH" => ("Mongo.file-search", warnFetch ? "VelaWarning" : "VelaInfo"), + "LIMIT" or "SKIP" => ("Mongo.list-end", "VelaInfo"), + "SORT" or "$sort" => ("Mongo.arrow-down-up", stage.Name == "SORT" ? "VelaWarning" : "VelaInfo"), + "$group" or "$bucket" or "$bucketAuto" => ("Mongo.layers", "VelaAccent"), + var n when n.StartsWith("PROJECTION", StringComparison.Ordinal) || n is "$project" or "$addFields" or "$set" => ("Mongo.columns-3", "VelaInfo"), + _ => ("Mongo.list", "VelaInfo") + }; + string stroke = stage.Tone switch + { + ExplainTone.Good => "VelaStatusConnected", + ExplainTone.Warning => "VelaWarning", + ExplainTone.Bad => "VelaError", + _ => "VelaBorderPrimary" + }; + long own = stage.OwnMs ?? 0; + return new ExplainStageCard + { + Stage = stage, + IconKey = icon, + IconToken = iconToken, + StrokeToken = stroke, + Time = stage.OwnMs is null ? "" : Loc.Format("Common_Ms", own), + Detail = stage.Detail, + Metrics = metrics, + TimeRatio = Math.Clamp(own / total, 0.03, 1), + BarToken = warnFetch || stage.Tone is ExplainTone.Warning or ExplainTone.Bad ? (stage.Tone == ExplainTone.Bad ? "VelaError" : "VelaWarning") : "MongoChart1", + Note = note, + NoteToken = noteToken, + NoteIcon = noteIcon + }; + } + + private static void Add(List metrics, string label, long? value) + { + if (value is { } v) + { + metrics.Add(new ExplainMetric(label, BsonText.Grouped(v))); + } + } + + private void BuildCandidates(ExplainPlan plan) + { + Candidates = + [ + .. plan.Candidates.Select(c => new ExplainCandidateRow( + c.Winner, + c.Winner ? Loc["Query_ExplainWinner"] : Loc["Query_ExplainRejected"], + c.Chain, + c.HasStats ? Loc.Format("Query_ExplainTrial", BsonText.Grouped(c.Works ?? 0), BsonText.Grouped(c.NReturned ?? 0)) : "", + c.Score is { } score ? "score " + score.ToString("0.0000", CultureInfo.InvariantCulture) : "")) + ]; + ExplainCandidate? winner = plan.Candidates.FirstOrDefault(static c => c.Winner); + CandidatesNote = plan.Candidates.Count <= 1 + ? Loc["Query_ExplainSingleCandidate"] + : winner?.Works is { } works + ? Loc.Format("Query_ExplainTrialNote", BsonText.Grouped(works)) + : Loc["Query_ExplainNeedAllPlans"]; + } + + private void BuildStats(ExplainPlan plan) + { + string Dash(long? value) => value is { } v ? BsonText.Grouped(v) : "—"; + double? ratio = plan.ExaminedRatio; + Stats = + [ + new ExplainStat(Loc["Query_ExplainTotal"], plan.TotalMs is { } ms ? Loc.Format("Common_Ms", ms) : "—"), + new ExplainStat(Loc["Query_ExplainReturned"], Dash(plan.NReturned)), + new ExplainStat(Loc["Query_ExplainKeys"], Dash(plan.TotalKeysExamined)), + new ExplainStat(Loc["Query_ExplainDocs"], Dash(plan.TotalDocsExamined)), + new ExplainStat(Loc["Query_ExplainRatio"], ratio is { } r ? r.ToString(r >= 10 ? "0" : "0.0", CultureInfo.InvariantCulture) + " ×" : "—", ratio > 1.05), + new ExplainStat(Loc["Query_ExplainInMemorySort"], plan.HasInMemorySort ? Loc["Common_Yes"] : Loc["Common_No"], plan.QueryLayerSort) + ]; + } + + private void BuildAdvice(ExplainPlan plan) + { + if (plan.Advice is not { } advice) + { + AdviceText = Loc["Query_AdviceNone"]; + AdviceKeys = ""; + AdvicePrediction = ""; + return; + } + AdviceText = Loc.Format(advice.ReasonKey, advice.ReasonArgument); + AdviceKeys = BsonText.Literal(advice.Keys); + AdvicePrediction = advice.ExpectedKeys is { } expected ? Loc.Format("Query_AdvicePredict", BsonText.Grouped(expected)) : ""; + } + + private string Metrics(ExplainStage stage) + { + var parts = new List(); + if (stage.NReturned is { } n) + { + parts.Add("nReturned " + BsonText.Grouped(n)); + } + if (stage.KeysExamined is { } k) + { + parts.Add("keys " + BsonText.Grouped(k)); + } + if (stage.DocsExamined is { } d) + { + parts.Add("docs " + BsonText.Grouped(d)); + } + if (stage.OwnMs is { } ms) + { + parts.Add(Loc.Format("Common_Ms", ms)); + } + return string.Join(" · ", parts); + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Query/HintCompareDialogView.axaml b/plugins/VelaShell.Plugin.Mongo/Ui/Query/HintCompareDialogView.axaml new file mode 100644 index 0000000..9c33cbf --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Query/HintCompareDialogView.axaml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Query/QueryTabView.axaml.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Query/QueryTabView.axaml.cs new file mode 100644 index 0000000..9a656ee --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Query/QueryTabView.axaml.cs @@ -0,0 +1,481 @@ +using System.ComponentModel; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Threading; +using AvaloniaEdit.Document; +using AvaloniaEdit.Rendering; +using MongoDB.Bson; +using VelaShell.Plugin.Mongo.Bson; +using VelaShell.Plugin.Mongo.Shell; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 查询编辑器的视图。代码里做的是 AXAML 做不了或做起来绕的几件事: +/// 快捷键(要抢在编辑区之前)、编辑器门面(选区 / 替换)、code lens 叠加层(跟着可视行走)、 +/// 库与 maxTimeMS 的下拉菜单、结果网格的右键菜单。 +/// +/// 编辑器与下方结果区的分界只听用户拖的那条分隔条:切到「执行计划」页不再自动把编辑器收矮 +/// (设计稿 14 画的是 230 高的编辑器)—— 结果页与执行计划页是同一块面板的两个页签, +/// 一切页高度就跳、切回来又被重置成默认值,用户刚拖好的高度就白拖了。 +/// +/// +public sealed partial class QueryTabView : UserControl, IQueryEditor +{ + private readonly QueryTabViewModel? _viewModel; + private bool _lensQueued; + + /// 用给定的视图模型初始化。 + internal QueryTabView(QueryTabViewModel viewModel) + { + InitializeComponent(); + DataContext = viewModel; + _viewModel = viewModel; + viewModel.Editor = this; + viewModel.PropertyChanged += OnViewModelChanged; + viewModel.LensChanged += QueueLenses; + + AddHandler(KeyDownEvent, OnPreviewKeyDown, RoutingStrategies.Tunnel); + DatabaseButton.Click += (_, _) => OpenDatabaseMenu(); + TimeoutButton.Click += (_, _) => OpenTimeoutMenu(); + ProblemsButton.Click += (_, _) => JumpToProblem(); + FieldList.DoubleTapped += (_, _) => + { + if (FieldList.SelectedItem is SampledField field) + { + viewModel.InsertFieldCommand.Execute(field); + } + }; + SnippetList.Tapped += (_, _) => + { + if (SnippetList.SelectedItem is QuerySnippet snippet) + { + viewModel.UseSnippetCommand.Execute(snippet); + SnippetList.SelectedItem = null; + } + }; + HistoryList.Tapped += (_, _) => + { + if (HistoryList.SelectedItem is QueryHistoryRow row) + { + viewModel.UseHistoryCommand.Execute(row); + HistoryList.SelectedItem = null; + } + }; + AddHandler(ResultGrid.CellActivatedEvent, OnCellActivated); + + // 字段页 → 编辑器的拖放:按住字段拖进编辑区,在落点插入字段路径(与双击同一件事,只是位置由鼠标定)。 + FieldList.AddHandler(PointerPressedEvent, OnFieldPressed, RoutingStrategies.Tunnel, handledEventsToo: true); + FieldList.AddHandler(PointerMovedEvent, OnFieldMoved, RoutingStrategies.Tunnel, handledEventsToo: true); + DragDrop.SetAllowDrop(EditorHost, true); + EditorHost.AddHandler(DragDrop.DragOverEvent, OnEditorDragOver, RoutingStrategies.Tunnel); + EditorHost.AddHandler(DragDrop.DropEvent, OnEditorDrop, RoutingStrategies.Tunnel); + + // 设计稿 03 的行号槽更宽、行号与代码之间隔着 20px;共享的 CodeEditor 是紧凑排法(网格的 JSON 视图、 + // 管道阶段框都用它)—— 只在查询编辑器这一个实例上前后各垫一条空白边距,不动共享控件。 + Editor.Editor.TextArea.LeftMargins.Insert(0, new Border { Width = 8 }); + Editor.Editor.TextArea.LeftMargins.Add(new Border { Width = 12 }); + + TextView view = Editor.Editor.TextArea.TextView; + view.VisualLinesChanged += (_, _) => QueueLenses(); + view.ScrollOffsetChanged += (_, _) => QueueLenses(); + Editor.CaretMoved += (_, _) => viewModel.OnCaretSettled(); + } + + /// 设计器 / headless 用的无参构造。 + public QueryTabView() + { + InitializeComponent(); + } + + /// 里面那块代码编辑器(测试与截图要直接摆弄光标、弹补全)。 + internal CodeEditor CodeEditor => Editor; + + // ── IQueryEditor ──────────────────────────────────────────────────────── + + /// + (int Offset, int Length) IQueryEditor.Selection => (Editor.Editor.SelectionStart, Editor.Editor.SelectionLength); + + /// + void IQueryEditor.Replace(int offset, int length, string text, bool literal) + { + if (!literal) + { + Editor.Replace(offset, length, text); + return; + } + TextDocument document = Editor.Editor.Document; + offset = Math.Clamp(offset, 0, document.TextLength); + length = Math.Clamp(length, 0, document.TextLength - offset); + int caret = Editor.Editor.CaretOffset; + bool whole = offset == 0 && length == document.TextLength; + document.Replace(offset, length, text); + // 整段替换(格式化、回填历史)时光标留在原处附近,而不是被顶到末尾;局部插入落在插入内容之后。 + Editor.Editor.CaretOffset = whole + ? Math.Clamp(caret, 0, document.TextLength) + : Math.Clamp(offset + text.Length, 0, document.TextLength); + } + + /// + void IQueryEditor.Focus() => Editor.FocusEditor(); + + // ── 快捷键 ────────────────────────────────────────────────────────────── + + private void OnPreviewKeyDown(object? sender, KeyEventArgs e) + { + if (_viewModel is not { } vm) + { + return; + } + bool control = e.KeyModifiers.HasFlag(KeyModifiers.Control); + bool alt = e.KeyModifiers.HasFlag(KeyModifiers.Alt); + bool shift = e.KeyModifiers.HasFlag(KeyModifiers.Shift); + switch (e.Key) + { + case Key.F5: + vm.RunCommand.Execute(null); + e.Handled = true; + break; + case Key.Enter when control: + vm.RunCurrentCommand.Execute(null); + e.Handled = true; + break; + case Key.F6: + vm.ExplainCommand.Execute(null); + e.Handled = true; + break; + case Key.F when alt && shift: + vm.FormatCommand.Execute(null); + e.Handled = true; + break; + case Key.Enter when alt: + vm.ApplyFixCommand.Execute(null); + e.Handled = true; + break; + case Key.S when control: + vm.SaveCommand.Execute(null); + e.Handled = true; + break; + case Key.Escape when vm.IsRunning: + vm.StopCommand.Execute(null); + e.Handled = true; + break; + } + } + + // ── 菜单 ──────────────────────────────────────────────────────────────── + + private void OpenDatabaseMenu() + { + if (_viewModel is not { } vm) + { + return; + } + var items = vm.Databases.Order(StringComparer.Ordinal).Select(db => (Control)new MenuItem + { + Header = db, + FontFamily = Resource("VelaUiMonoFont") ?? FontFamily.Default, + Icon = new Glyph + { + Key = db == vm.Database ? "Mongo.check" : "Mongo.database", + Size = 12, + Brush = ThemeBrushes.Get(db == vm.Database ? "VelaAccent" : "VelaWarning", Brushes.Gray) + }, + Command = vm.SelectDatabaseCommand, + CommandParameter = db + }).ToList(); + new ContextMenu { ItemsSource = items }.Open(DatabaseButton); + } + + private void OpenTimeoutMenu() + { + if (_viewModel is not { } vm) + { + return; + } + int[] presets = [5_000, 15_000, 30_000, 60_000, 120_000, 300_000, 0]; + IEnumerable values = presets.Contains(vm.MaxTimeMs) ? presets : [vm.MaxTimeMs, .. presets]; + var items = values.Select(ms => (Control)new MenuItem + { + Header = "maxTimeMS " + QueryTabViewModel.FormatDuration(ms, vm.Loc), + Icon = new Glyph + { + Key = ms == vm.MaxTimeMs ? "Mongo.check" : "Mongo.timer", + Size = 12, + Brush = ThemeBrushes.Get(ms == vm.MaxTimeMs ? "VelaAccent" : "VelaTextTertiary", Brushes.Gray) + }, + Command = vm.SetMaxTimeCommand, + CommandParameter = ms + }).ToList(); + new ContextMenu { ItemsSource = items }.Open(TimeoutButton); + } + + /// 网格:右键弹菜单(复制值 / 文档 / 筛选条件),双击或 Ctrl+C 复制单元格(行号列复制整份文档)。 + private void OnCellActivated(object? sender, ResultCellEventArgs e) + { + if (_viewModel is not { } vm || vm.SelectedPane is not QueryResultSet set || e.DocumentIndex < 0 || e.DocumentIndex >= set.Documents.Count) + { + return; + } + // 网格可能已经钻进了某个数组 / 对象:值与所属文档都以事件带来的为准,路径是从文档根算起的。 + BsonDocument document = e.Document ?? set.Documents[e.DocumentIndex]; + BsonValue? value = e.Value; + string documentText = BsonText.Pretty(document, vm.Workspace.Connection.Settings.Ejson); + if (e.Activated) + { + _ = vm.Workspace.CopyAsync(value is null ? documentText : CopyText(value)); + vm.Workspace.Toast(new ToastRequest { Title = vm.Loc["Common_Copied"], Kind = ToastKind.Success, Duration = TimeSpan.FromSeconds(2) }); + return; + } + var items = new List(); + if (value is BsonDocument or BsonArray && e.Source is ResultGrid grid && e.GridRow >= 0 && e.GridColumn >= 0) + { + (int row, int column) = (e.GridRow, e.GridColumn); + items.Add(Item(vm.Loc["Cw_MenuDrill"], "Mongo.table-2", () => + { + grid.DrillInto(row, column); + return Task.CompletedTask; + })); + items.Add(new Separator()); + } + if (value is not null && e.Column is { } path) + { + items.Add(Item(vm.Loc["Query_MenuCopyValue"], "Mongo.copy", () => vm.Workspace.CopyAsync(CopyText(value)))); + items.Add(Item(vm.Loc["Query_MenuCopyFilter"], "Mongo.funnel", + () => vm.Workspace.CopyAsync(BsonText.Literal(new BsonDocument(GridDrill.FilterPath(path), value))))); + } + items.Add(Item(vm.Loc["Query_MenuCopyDocument"], "Mongo.braces", () => vm.Workspace.CopyAsync(documentText))); + new ContextMenu { ItemsSource = items }.Open(e.Source as Control ?? this); + e.Handled = true; + } + + private static string CopyText(BsonValue value) => value.IsString ? value.AsString : BsonText.Literal(value); + + private static MenuItem Item(string header, string icon, Func action) => new() + { + Header = header, + Icon = new Glyph { Key = icon, Size = 13, Brush = ThemeBrushes.Get("VelaTextTertiary", Brushes.Gray) }, + Command = new AsyncCommand(action) + }; + + private void JumpToProblem() + { + if (_viewModel?.Diagnostics.FirstOrDefault() is not { } diagnostic) + { + return; + } + Editor.Select(diagnostic.Offset, diagnostic.Length); + int line = Editor.Editor.Document.GetLineByOffset(Math.Clamp(diagnostic.Offset, 0, Editor.Editor.Document.TextLength)).LineNumber; + Editor.ScrollToLine(line); + Editor.FocusEditor(); + } + + // ── 字段拖进编辑器 ────────────────────────────────────────────────────── + + private PointerPressedEventArgs? _fieldPress; + private Point _fieldPressPoint; + + private void OnFieldPressed(object? sender, PointerPressedEventArgs e) + { + _fieldPress = e.GetCurrentPoint(FieldList).Properties.IsLeftButtonPressed ? e : null; + _fieldPressPoint = e.GetPosition(FieldList); + } + + private async void OnFieldMoved(object? sender, PointerEventArgs e) + { + if (_fieldPress is not { } press || !e.GetCurrentPoint(FieldList).Properties.IsLeftButtonPressed) + { + return; + } + Point now = e.GetPosition(FieldList); + if (Math.Abs(now.X - _fieldPressPoint.X) + Math.Abs(now.Y - _fieldPressPoint.Y) < 6 + || (press.Source as Control)?.DataContext is not SampledField field) + { + return; + } + _fieldPress = null; + var data = new DataTransfer(); + data.Add(DataTransferItem.CreateText(field.Path)); + try + { + await DragDrop.DoDragDropAsync(press, data, DragDropEffects.Copy); + } + catch (InvalidOperationException) + { + // 平台不支持拖放(headless)时退化成什么都不做 —— 双击插入照样可用。 + } + } + + private void OnEditorDragOver(object? sender, DragEventArgs e) + { + bool text = e.DataTransfer.TryGetText() is { Length: > 0 }; + e.DragEffects = text ? DragDropEffects.Copy : DragDropEffects.None; + if (text && Editor.Editor.GetPositionFromPoint(e.GetPosition(Editor.Editor)) is { } position) + { + // 拖动时光标跟着落点走,松手前就看得出会插在哪。 + Editor.Editor.TextArea.Caret.Position = position; + } + e.Handled = true; + } + + private void OnEditorDrop(object? sender, DragEventArgs e) + { + if (e.DataTransfer.TryGetText() is not { Length: > 0 } text) + { + return; + } + TextDocument document = Editor.Editor.Document; + int offset = Editor.Editor.GetPositionFromPoint(e.GetPosition(Editor.Editor)) is { } position + ? document.GetOffset(position.Location) + : Editor.Editor.CaretOffset; + document.Insert(Math.Clamp(offset, 0, document.TextLength), text); + Editor.Editor.CaretOffset = Math.Clamp(offset + text.Length, 0, document.TextLength); + Editor.FocusEditor(); + e.DragEffects = DragDropEffects.Copy; + e.Handled = true; + } + + private void OnViewModelChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(QueryTabViewModel.Statements)) + { + QueueLenses(); + } + } + + // ── code lens ─────────────────────────────────────────────────────────── + + private void QueueLenses() + { + if (_lensQueued) + { + return; + } + _lensQueued = true; + Dispatcher.UIThread.Post(() => + { + _lensQueued = false; + UpdateLenses(); + }, DispatcherPriority.Render); + } + + /// + /// 每条查询语句上方一行「▷ 运行 · 执行计划 · 复制为 C# · 上次 38 ms · 100 条」。 + /// + /// AvaloniaEdit 没有"在两行之间插一行装饰"的机制(插进文档的行会变成用户的文本,改行高会把光标和选区一起撑高), + /// 所以 code lens 是编辑器上方的一层叠加:语句上一行是空行时画在那一行里(脚本里语句之间本来就隔着空行); + /// 否则画在语句首行的行尾。按钮可点,随滚动与改动重排。 + /// + /// + private void UpdateLenses() + { + LensLayer.Children.Clear(); + if (_viewModel is not { } vm) + { + return; + } + TextView view = Editor.Editor.TextArea.TextView; + TextDocument document = Editor.Editor.Document; + if (!view.VisualLinesValid || document.TextLength == 0) + { + return; + } + foreach (ShellStatement statement in vm.Statements) + { + if (statement.Offset >= document.TextLength || !statement.Text.StartsWith("db", StringComparison.Ordinal)) + { + continue; + } + DocumentLine first = document.GetLineByOffset(statement.Offset); + bool above = first.LineNumber > 1 && document.GetText(document.GetLineByNumber(first.LineNumber - 1)).Trim().Length == 0; + int hostLine = above ? first.LineNumber - 1 : first.LineNumber; + if (view.GetVisualLine(hostLine) is not { } visual) + { + continue; + } + double x; + if (above) + { + VisualLine statementLine = view.GetVisualLine(first.LineNumber) ?? visual; + int column = statementLine.GetVisualColumn(statement.Offset - first.Offset); + x = statementLine.GetVisualPosition(column, VisualYPosition.LineTop).X; + } + else + { + x = visual.GetTextLineVisualXPosition(visual.TextLines[^1], visual.VisualLengthWithEndOfLineMarker) + 24; + } + double y = visual.VisualTop - view.ScrollOffset.Y; + x -= view.ScrollOffset.X; + if (view.TranslatePoint(new Point(x, y), LensLayer) is not { } origin || origin.Y < -visual.Height || origin.Y > LensLayer.Bounds.Height) + { + continue; + } + StackPanel lens = BuildLens(vm, statement); + Canvas.SetLeft(lens, origin.X); + Canvas.SetTop(lens, origin.Y + Math.Max(0, (visual.Height - 16) / 2)); + LensLayer.Children.Add(lens); + } + } + + private StackPanel BuildLens(QueryTabViewModel vm, ShellStatement statement) + { + Loc loc = vm.Loc; + var panel = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 10, Height = 16 }; + (bool explainable, bool pipeline) = vm.LensShape(statement); + panel.Children.Add(LensButton("Mongo.play", loc["Query_LensRun"], vm.LensRunCommand, statement)); + if (explainable) + { + panel.Children.Add(LensButton("Mongo.gauge", loc["Query_LensExplain"], vm.LensExplainCommand, statement)); + } + if (pipeline) + { + panel.Children.Add(LensButton("Mongo.workflow", loc["Query_LensPipeline"], vm.LensPipelineCommand, statement)); + } + panel.Children.Add(LensButton("Mongo.code-xml", loc["Query_LensCopyCs"], vm.LensCopyCodeCommand, statement)); + if (vm.LensFor(statement) is { } record) + { + string ms = loc.Format("Common_Ms", record.ElapsedMs); + string text = !record.Ok ? loc.Format("Query_LensFailed", ms) + : record.Count is { } count ? loc.Format("Query_LensLast", ms, QueryTabViewModel.Count(count)) + : loc.Format("Query_LensLastPlain", ms); + panel.Children.Add(LensLabel(record.Ok ? "Mongo.circle-check" : "Mongo.circle-x", text, record.Ok ? "VelaTextMuted" : "VelaError")); + } + return panel; + } + + private Button LensButton(string icon, string text, System.Windows.Input.ICommand command, ShellStatement statement) + { + var button = new Button + { + Theme = Resource("MongoToolButton"), + Height = 16, + Padding = new Thickness(2, 0), + Command = command, + CommandParameter = statement, + Content = LensLabel(icon, text, "VelaTextMuted"), + Cursor = new Cursor(StandardCursorType.Hand) + }; + return button; + } + + private StackPanel LensLabel(string icon, string text, string token) + { + var label = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 3, VerticalAlignment = VerticalAlignment.Center }; + label.Children.Add(new Glyph { Key = icon, Size = 9, Brush = ThemeBrushes.Get(token, Brushes.Gray) }); + label.Children.Add(new TextBlock + { + Text = text, + FontFamily = Resource("VelaUiFont") ?? FontFamily.Default, + FontSize = Resource("VelaFontSize9") ?? 9, + Foreground = ThemeBrushes.Get(token, Brushes.Gray), + VerticalAlignment = VerticalAlignment.Center + }); + return label; + } + + private T? Resource(string key) => this.TryFindResource(key, out object? value) && value is T typed ? typed : default; +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Query/QueryTabViewModel.Editor.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Query/QueryTabViewModel.Editor.cs new file mode 100644 index 0000000..3723b76 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Query/QueryTabViewModel.Editor.cs @@ -0,0 +1,750 @@ +using Avalonia.Threading; +using MongoDB.Bson; +using MongoDB.Driver; +using VelaShell.Plugin.Mongo.Bson; +using VelaShell.Plugin.Mongo.Core; +using VelaShell.Plugin.Mongo.Shell; + +namespace VelaShell.Plugin.Mongo.Ui; + +internal sealed partial class QueryTabViewModel +{ + private readonly DispatcherTimer _diagnosticTimer; + private readonly Dictionary>> _collections = new(StringComparer.Ordinal); + private readonly Dictionary<(string, string), Task> _schemas = []; + private readonly Dictionary<(string, string), Task>> _indexNames = []; + private IReadOnlyList _statements = []; + private IReadOnlyList _diagnostics = []; + private Func>? _completionProvider; + + /// 当前脚本切出的语句(code lens 按它摆)。 + public IReadOnlyList Statements => _statements; + + /// 诊断(波浪线、行号旁红点、行尾提示)。 + public IReadOnlyList Diagnostics + { + get => _diagnostics; + private set + { + if (SetProperty(ref _diagnostics, value)) + { + RaisePropertyChanged(nameof(ProblemCount)); + RaisePropertyChanged(nameof(HasProblems)); + RaisePropertyChanged(nameof(ProblemText)); + } + } + } + + /// 问题数。 + public int ProblemCount => _diagnostics.Count; + + /// 有问题。 + public bool HasProblems => _diagnostics.Count > 0; + + /// 状态条右侧的 1 个问题。 + public string ProblemText => Loc.Format("Query_Problems", _diagnostics.Count); + + /// 补全来源(绑给 CodeEditor.CompletionProvider)。 + public Func> CompletionProvider => _completionProvider ??= ProvideCompletionAsync; + + /// 光标换了语句时也要重算诊断(光标所在的那条不报"还没写完"的解析错误)。 + internal void OnCaretSettled() + { + _diagnosticTimer.Stop(); + _diagnosticTimer.Start(); + } + + /// 重切语句、重算诊断。 + private void Analyze() + { + _statements = ShellScript.Split(_text); + var diagnostics = new List(); + foreach (ShellStatement statement in _statements) + { + IReadOnlyList found = ShellJson.Diagnose(statement.Text); + foreach (ShellDiagnostic d in found) + { + diagnostics.Add(new EditorDiagnostic(statement.Offset + d.Offset, d.Length, Loc.Format(d.MessageKey, d.Argument), + DiagnosticSeverity.Error, d.Fix, d.Fix is null ? null : Loc["Query_FixHint"])); + } + // 解析错误只报光标不在的语句:正在打的那条还没写完,每敲一个字都标红只会添乱。 + if (found.Count == 0 && !statement.Contains(_caretOffset) + && !ShellParser.TryParse(statement, out _, out ShellParseException? error) && error is not null) + { + diagnostics.Add(new EditorDiagnostic(statement.Offset + Math.Min(error.Offset, Math.Max(0, statement.Length - 1)), + Math.Min(error.Length, Math.Max(1, statement.Length - error.Offset)), Describe(error))); + } + } + Diagnostics = diagnostics; + RaisePropertyChanged(nameof(Statements)); + } + + /// Alt+↵:应用光标所在行的快捷修复(没有就应用全文唯一的那一个)。 + internal bool ApplyQuickFix() + { + List fixable = [.. _diagnostics.Where(static d => d.FixText is not null)]; + if (fixable.Count == 0) + { + return false; + } + int lineStart = _text.LastIndexOf('\n', Math.Clamp(_caretOffset - 1, 0, Math.Max(0, _text.Length - 1))) + 1; + int lineEnd = _text.IndexOf('\n', Math.Min(_caretOffset, _text.Length)); + lineEnd = lineEnd < 0 ? _text.Length : lineEnd; + EditorDiagnostic? target = fixable.FirstOrDefault(d => d.Offset >= lineStart && d.Offset <= lineEnd) + ?? (fixable.Count == 1 ? fixable[0] : null); + if (target is null) + { + return false; + } + ReplaceText(target.Offset, target.Length, target.FixText!, literal: true); + return true; + } + + /// 格式化全文。 + private void Format() + { + string formatted = ShellFormatter.Format(_text); + if (formatted != _text) + { + ReplaceText(0, _text.Length, formatted, literal: true); + } + } + + /// 改文本:有编辑器就走编辑器(进撤销栈、光标不乱跳),没有就直接改属性。 + private void ReplaceText(int offset, int length, string text, bool literal = false) + { + if (Editor is { } editor) + { + editor.Replace(offset, length, text, literal); + return; + } + int caret = literal ? -1 : text.IndexOf('|', StringComparison.Ordinal); + string clean = caret >= 0 ? text.Remove(caret, 1) : text; + Text = _text.Remove(offset, Math.Min(length, _text.Length - offset)).Insert(offset, clean); + } + + // ── 补全 ──────────────────────────────────────────────────────────────── + + /// 补全(设计稿 03 的双栏弹层)。 + internal async Task ProvideCompletionAsync(CompletionRequest request) + { + CompletionContext context = ShellCompletion.Analyze(request.Text, request.CaretOffset); + if (context.Slot == CompletionSlot.None) + { + return null; + } + string database = context.Database ?? DatabaseAt(request.Text, request.CaretOffset); + bool zh = Loc.IsChinese; + var items = new List(); + switch (context.Slot) + { + case CompletionSlot.Statement: + items.AddRange(Keywords()); + break; + case CompletionSlot.DbMember: + foreach (CollectionInfo info in await CollectionsAsync(database).ConfigureAwait(true)) + { + items.Add(new CompletionItem + { + Label = info.Name, + InsertText = System.Text.RegularExpressions.Regex.IsMatch(info.Name, "^[A-Za-z_$][A-Za-z0-9_$]*$") ? info.Name : $"getCollection({BsonText.Quote(info.Name)})", + IconKey = info.Kind == CollectionKind.View ? "Mongo.eye" : "Mongo.table-2", + IconToken = "VelaInfo", + Category = info.Kind == CollectionKind.View ? Loc["Query_CatView"] : Loc["Query_CatCollection"] + }); + } + items.AddRange(DatabaseMethodItems()); + break; + case CompletionSlot.CollectionMember: + items.AddRange(MongoVocabulary.Methods.Select(e => MongoVocabulary.ToCompletion(e, zh))); + break; + case CompletionSlot.CursorMember: + items.AddRange(MongoVocabulary.CursorMethods + .Where(e => context.Method != "aggregate" || e.Name is "toArray" or "explain" or "comment" or "maxTimeMS" or "hint") + .Select(e => MongoVocabulary.ToCompletion(e, zh))); + break; + case CompletionSlot.StringArgument: + items.AddRange(await StringArgumentItemsAsync(context, database).ConfigureAwait(true)); + break; + case CompletionSlot.FieldPathString: + foreach ((string path, string category, string token) in await FieldPathsAsync(context, database).ConfigureAwait(true)) + { + items.Add(new CompletionItem + { + Label = "$" + path, + InsertText = "$" + path + (context.QuoteClosed ? "" : "\""), + IconKey = "Mongo.variable", + IconToken = token, + Category = category + }); + } + break; + case CompletionSlot.Key: + items.AddRange(await KeyItemsAsync(context, database).ConfigureAwait(true)); + break; + case CompletionSlot.Value: + items.AddRange(await ValueItemsAsync(context, database).ConfigureAwait(true)); + break; + } + return items.Count == 0 + ? null + : new CompletionSet + { + Items = items, + ReplaceOffset = context.ReplaceOffset, + ReplaceLength = context.ReplaceLength, + Footer = Loc["Query_CompletionFooter"] + }; + } + + private IEnumerable Keywords() => + [ + Keyword("db", "db.", Loc["Query_KwDb"]), + Keyword("use", "use(\"|\")", Loc["Query_KwUse"]), + Keyword("show dbs", "show dbs", Loc["Query_KwShowDbs"]), + Keyword("show collections", "show collections", Loc["Query_KwShowCollections"]) + ]; + + private CompletionItem Keyword(string label, string insert, string description) => new() + { + Label = label, + InsertText = insert, + IconKey = "Mongo.square-terminal", + IconToken = "VelaAccent", + Category = Loc["Query_CatKeyword"], + Description = description + }; + + private IEnumerable DatabaseMethodItems() + { + (string Name, string Insert, string Key)[] methods = + [ + ("getCollection", "getCollection(\"|\")", "Query_DbGetCollection"), + ("getCollectionNames", "getCollectionNames()", "Query_DbGetCollectionNames"), + ("getSiblingDB", "getSiblingDB(\"|\")", "Query_DbGetSiblingDb"), + ("runCommand", "runCommand({ | })", "Query_DbRunCommand"), + ("adminCommand", "adminCommand({ | })", "Query_DbAdminCommand"), + ("stats", "stats()", "Query_DbStats"), + ("createCollection", "createCollection(\"|\")", "Query_DbCreateCollection"), + ("aggregate", "aggregate([\n { | }\n])", "Query_DbAggregate") + ]; + return methods.Select(m => new CompletionItem + { + Label = m.Name, + InsertText = m.Insert, + IconKey = "Mongo.parentheses", + IconToken = "VelaShellYellow", + Category = Loc["Query_CatDbMethod"], + Badge = Loc["Query_CatDbMethod"], + Description = Loc[m.Key], + Syntax = "db." + m.Name + "(…)" + }); + } + + private async Task> StringArgumentItemsAsync(CompletionContext context, string database) + { + string close = context.QuoteClosed ? "" : "\""; + switch (context.StringCall) + { + case "getCollection": + return (await CollectionsAsync(database).ConfigureAwait(true)).Select(c => new CompletionItem + { + Label = c.Name, + InsertText = c.Name + close, + IconKey = "Mongo.table-2", + IconToken = "VelaInfo", + Category = Loc["Query_CatCollection"] + }); + case "use" or "getSiblingDB": + return Databases.Select(d => new CompletionItem + { + Label = d, + InsertText = d + close, + IconKey = "Mongo.database", + IconToken = "VelaWarning", + Category = Loc["Query_CatDatabase"] + }); + case "hint" or "dropIndex" when context.Collection is { } collection: + return (await IndexNamesAsync(context.Database ?? database, collection).ConfigureAwait(true)).Select(n => new CompletionItem + { + Label = n, + InsertText = n + close, + IconKey = "Mongo.key-round", + IconToken = "VelaStatusConnected", + Category = Loc["Query_CatIndex"] + }); + case "distinct" when context.Collection is { } collection: + SampledSchema? schema = await SchemaAsync(database, collection).ConfigureAwait(true); + return schema is null ? [] : schema.Fields.Select(f => new CompletionItem + { + Label = f.Path, + InsertText = f.Path + close, + IconKey = "Mongo.variable", + IconToken = f.ColorToken, + Category = Loc["Query_FieldSampled"], + Badge = f.TypeText, + Description = Loc.Format("Query_FieldInfo", f.TypeText, f.RatioText) + }); + default: + return []; + } + } + + /// 键位置:按对象角色给字段、运算符、阶段名或选项名。 + private async Task> KeyItemsAsync(CompletionContext context, string database) + { + bool zh = Loc.IsChinese; + bool dollar = context.Prefix.StartsWith('$'); + var items = new List(); + switch (context.Role) + { + case ObjectRole.Stage: + { + (PipelineShape? shape, _) = await UpstreamAsync(context, database).ConfigureAwait(true); + string? chipsTitle = shape is null ? null : Loc.Format("Query_UpstreamFields", shape.Source); + IReadOnlyList chips = shape?.Fields ?? []; + foreach (VocabularyEntry entry in MongoVocabulary.Stages) + { + items.Add(WithChips(MongoVocabulary.ToCompletion(entry, zh), chipsTitle, chips)); + } + items.AddRange(StageSnippets(chipsTitle, chips)); + if (!dollar && shape is not null) + { + items.AddRange(await UpstreamItemsAsync(context, database, keyInsert: true).ConfigureAwait(true)); + } + break; + } + case ObjectRole.Filter: + if (!dollar) + { + items.AddRange(await FieldItemsAsync(context, database).ConfigureAwait(true)); + } + items.AddRange(MongoVocabulary.QueryOperators + .Where(static e => e.Name is "$and" or "$or" or "$nor" or "$expr" or "$text" or "$jsonSchema") + .Select(e => MongoVocabulary.ToCompletion(e, zh))); + break; + case ObjectRole.Operator: + items.AddRange(MongoVocabulary.QueryOperators + .Where(static e => e.Name is not ("$and" or "$or" or "$nor" or "$expr" or "$text" or "$jsonSchema")) + .Select(e => MongoVocabulary.ToCompletion(e, zh))); + break; + case ObjectRole.Update: + items.AddRange(MongoVocabulary.UpdateOperators.Select(e => MongoVocabulary.ToCompletion(e, zh))); + break; + case ObjectRole.UpdateFields or ObjectRole.Sort or ObjectRole.IndexKeys or ObjectRole.Document: + items.AddRange(await FieldItemsAsync(context, database).ConfigureAwait(true)); + break; + case ObjectRole.Projection: + if (dollar) + { + items.AddRange(MongoVocabulary.Expressions.Select(e => MongoVocabulary.ToCompletion(e, zh))); + } + else + { + items.AddRange(await FieldItemsAsync(context, database).ConfigureAwait(true)); + } + break; + case ObjectRole.Group: + items.Add(new CompletionItem + { + Label = "_id", + InsertText = "_id: \"$|\"", + IconKey = "Mongo.variable", + IconToken = "VelaShellBlue", + Category = Loc["Query_CatGroupKey"], + Description = Loc["Query_GroupIdHint"] + }); + break; + case ObjectRole.Accumulator or ObjectRole.Expression: + IEnumerable expressions = context.Role == ObjectRole.Accumulator + ? MongoVocabulary.Expressions.OrderBy(static e => e.Name is "$sum" or "$avg" or "$min" or "$max" or "$first" or "$last" or "$push" or "$addToSet" or "$count" ? 0 : 1) + : MongoVocabulary.Expressions; + items.AddRange(expressions.Select(e => MongoVocabulary.ToCompletion(e, zh))); + break; + case ObjectRole.Options: + items.AddRange(OptionItems(context)); + break; + } + return items; + } + + /// 值位置:常量与片段;管道数组里直接给带花括号的阶段。 + private async Task> ValueItemsAsync(CompletionContext context, string database) + { + bool zh = Loc.IsChinese; + switch (context.Role) + { + case ObjectRole.Pipeline: + { + (PipelineShape? shape, _) = await UpstreamAsync(context, database).ConfigureAwait(true); + string? chipsTitle = shape is null ? null : Loc.Format("Query_UpstreamFields", shape.Source); + return MongoVocabulary.Stages.Select(e => + { + CompletionItem item = MongoVocabulary.ToCompletion(e, zh); + return WithChips(Clone(item, "{ " + (item.InsertText ?? item.Label) + " }"), chipsTitle, shape?.Fields ?? []); + }); + } + case ObjectRole.Sort: + return [Constant("-1", Loc["Query_ValDesc"]), Constant("1", Loc["Query_ValAsc"])]; + case ObjectRole.IndexKeys: + return [Constant("1", Loc["Query_ValAsc"]), Constant("-1", Loc["Query_ValDesc"]), Constant("\"text\"", "text"), Constant("\"2dsphere\"", "2dsphere"), Constant("\"hashed\"", "hashed")]; + case ObjectRole.Projection when !context.Prefix.StartsWith('$'): + return [Constant("1", Loc["Query_ValInclude"]), Constant("0", Loc["Query_ValExclude"]), + .. (await FieldPathsAsync(context, database).ConfigureAwait(true)).Select(p => Constant($"\"${p.Path}\"", p.Category))]; + case ObjectRole.Group or ObjectRole.Accumulator or ObjectRole.Expression or ObjectRole.Projection: + { + var items = new List(); + foreach ((string path, string category, string token) in await FieldPathsAsync(context, database).ConfigureAwait(true)) + { + items.Add(new CompletionItem { Label = $"\"${path}\"", IconKey = "Mongo.variable", IconToken = token, Category = category }); + } + if (context.Role != ObjectRole.Group || context.FieldKey != "_id") + { + items.AddRange(MongoVocabulary.Expressions.Select(e => + { + CompletionItem item = MongoVocabulary.ToCompletion(e, zh); + return Clone(item, "{ " + (item.InsertText ?? item.Label) + " }"); + })); + } + items.Add(Constant("null", "null")); + return items; + } + case ObjectRole.Filter or ObjectRole.Operator or ObjectRole.ValueList or ObjectRole.UpdateFields or ObjectRole.Document: + return ValueSnippets(context.Role is ObjectRole.Filter); + default: + return context.Prefix.Length > 0 ? [Constant("true", "Boolean"), Constant("false", "Boolean"), Constant("null", "Null")] : []; + } + } + + private IEnumerable ValueSnippets(bool operators) + { + var items = new List(); + if (operators) + { + items.Add(Snippet("{ $gte: … }", "{ $gte: | }", Loc["Query_ValRange"])); + items.Add(Snippet("{ $in: [ … ] }", "{ $in: [ | ] }", Loc["Query_ValIn"])); + items.Add(Snippet("{ $exists: true }", "{ $exists: true }|", Loc["Query_ValExists"])); + items.Add(Snippet("{ $regex: /…/i }", "{ $regex: /|/i }", Loc["Query_ValRegex"])); + } + items.Add(Snippet("ObjectId(\"…\")", "ObjectId(\"|\")", "ObjectId")); + items.Add(Snippet("ISODate(\"…\")", $"ISODate(\"{DateTime.UtcNow:yyyy-MM-dd}|\")", "Date")); + items.Add(Snippet("NumberDecimal(\"…\")", "NumberDecimal(\"|\")", "Decimal128")); + items.Add(Constant("true", "Boolean")); + items.Add(Constant("false", "Boolean")); + items.Add(Constant("null", "Null")); + return items; + } + + private IEnumerable StageSnippets(string? chipsTitle, IReadOnlyList chips) => + [ + WithChips(Snippet(Loc["Query_SnipSortDesc"], "$sort: { |: -1 }", Loc["Query_SnipSortDescHint"], "{ $sort: { <字段>: -1 } }"), chipsTitle, chips), + WithChips(Snippet(Loc["Query_SnipMatchEq"], "$match: { |: \"\" }", Loc["Query_SnipMatchEqHint"], "{ $match: { <字段>: <值> } }"), chipsTitle, chips), + WithChips(Snippet(Loc["Query_SnipGroupCount"], "$group: { _id: \"$|\", count: { $sum: 1 } }", Loc["Query_SnipGroupCountHint"], + "{ $group: { _id: \"$<字段>\", count: { $sum: 1 } } }"), chipsTitle, chips) + ]; + + private IEnumerable OptionItems(CompletionContext context) + { + string[] names = context.StageName switch + { + "$lookup" => ["from", "localField", "foreignField", "as", "let", "pipeline"], + "$unwind" => ["path", "includeArrayIndex", "preserveNullAndEmptyArrays"], + "$sample" => ["size"], + "$bucket" => ["groupBy", "boundaries", "default", "output"], + "$bucketAuto" => ["groupBy", "buckets", "output", "granularity"], + "$merge" => ["into", "on", "whenMatched", "whenNotMatched"], + "$unionWith" => ["coll", "pipeline"], + "$graphLookup" => ["from", "startWith", "connectFromField", "connectToField", "as", "maxDepth", "depthField"], + null when context.Method is "aggregate" => ["allowDiskUse", "maxTimeMS", "hint", "collation", "comment", "batchSize", "let"], + null when context.Method is "updateOne" or "updateMany" or "replaceOne" => ["upsert", "arrayFilters", "hint", "collation"], + null when context.Method is "findOneAndUpdate" or "findOneAndReplace" => ["returnNewDocument", "upsert", "projection", "sort"], + null when context.Method is "createIndex" => ["name", "unique", "sparse", "expireAfterSeconds", "partialFilterExpression", "collation"], + null when context.Method is "insertMany" or "bulkWrite" => ["ordered"], + _ => [] + }; + return names.Select(n => new CompletionItem + { + Label = n, + InsertText = n + ": |", + IconKey = "Mongo.settings-2", + IconToken = "VelaTextTertiary", + Category = Loc["Query_CatOption"] + }); + } + + private CompletionItem Snippet(string label, string insert, string description, string? syntax = null) => new() + { + Label = label, + InsertText = insert, + IconKey = "Mongo.square-code", + IconToken = "VelaWarning", + Category = Loc["Query_CatSnippet"], + Badge = Loc["Query_CatSnippet"], + Description = description, + Syntax = syntax + }; + + private CompletionItem Constant(string label, string category) => new() + { + Label = label, + IconKey = "Mongo.variable", + IconToken = "VelaTextTertiary", + Category = category + }; + + private static CompletionItem Clone(CompletionItem item, string insert) => new() + { + Label = item.Label, + InsertText = insert, + IconKey = item.IconKey, + IconToken = item.IconToken, + Category = item.Category, + Badge = item.Badge, + Description = item.Description, + Syntax = item.Syntax, + DocsUrl = item.DocsUrl, + ChipsTitle = item.ChipsTitle, + Chips = item.Chips + }; + + private static CompletionItem WithChips(CompletionItem item, string? title, IReadOnlyList chips) => title is null || chips.Count == 0 + ? item + : new CompletionItem + { + Label = item.Label, + InsertText = item.InsertText, + IconKey = item.IconKey, + IconToken = item.IconToken, + Category = item.Category, + Badge = item.Badge, + Description = item.Description, + Syntax = item.Syntax, + DocsUrl = item.DocsUrl, + ChipsTitle = title, + Chips = chips + }; + + /// 字段键补全:管道里经过重塑的,给上游输出字段;否则给集合抽样字段。 + private async Task> FieldItemsAsync(CompletionContext context, string database) + { + if (context.InPipeline) + { + (PipelineShape? shape, _) = await UpstreamAsync(context, database).ConfigureAwait(true); + if (shape is not null) + { + return await UpstreamItemsAsync(context, database, keyInsert: true).ConfigureAwait(true); + } + } + if (context.Collection is not { } collection || await SchemaAsync(database, collection).ConfigureAwait(true) is not { } schema) + { + return []; + } + return schema.Fields.Select(f => new CompletionItem + { + Label = f.Path, + InsertText = KeyInsert(f.Path, context), + IconKey = "Mongo.variable", + IconToken = f.ColorToken, + Category = Loc["Query_FieldSampled"], + Badge = f.TypeText, + Description = Loc.Format("Query_FieldInfo", f.TypeText, f.RatioText), + Syntax = Loc.Format("Query_FieldSampleOf", collection, BsonText.Grouped(schema.Sampled)) + }); + } + + private async Task> UpstreamItemsAsync(CompletionContext context, string database, bool keyInsert) + { + (PipelineShape? shape, IReadOnlyDictionary types) = await UpstreamAsync(context, database).ConfigureAwait(true); + if (shape is null) + { + return []; + } + return shape.Fields.Select(f => + { + BsonKind kind = types.GetValueOrDefault(f, BsonKind.Missing); + return new CompletionItem + { + Label = f, + InsertText = keyInsert ? KeyInsert(f, context) : f, + IconKey = "Mongo.variable", + IconToken = kind == BsonKind.Missing ? "VelaWarning" : BsonKinds.ColorToken(kind), + Category = kind == BsonKind.Missing ? Loc["Query_UpstreamField"] : $"{Loc["Query_UpstreamField"]} · {Short(kind)}", + Description = Loc.Format("Query_UpstreamFieldHint", shape.Source) + }; + }); + } + + private static string Short(BsonKind kind) => kind == BsonKind.Decimal128 ? "Decimal" : BsonKinds.Name(kind); + + /// $字段路径 的候选:管道里经过重塑的取上游字段,否则取抽样字段。 + private async Task> FieldPathsAsync(CompletionContext context, string database) + { + if (context.InPipeline) + { + (PipelineShape? shape, IReadOnlyDictionary types) = await UpstreamAsync(context, database).ConfigureAwait(true); + if (shape is not null) + { + return [.. shape.Fields.Select(f => (f, Loc["Query_UpstreamField"], types.TryGetValue(f, out BsonKind k) ? BsonKinds.ColorToken(k) : "VelaWarning"))]; + } + } + if (context.Collection is not { } collection || await SchemaAsync(database, collection).ConfigureAwait(true) is not { } schema) + { + return []; + } + return [.. schema.Fields.Select(f => (f.Path, Loc["Query_FieldSampled"], f.ColorToken))]; + } + + private static string KeyInsert(string path, CompletionContext context) => context.Quoted + ? path + (context.QuoteClosed ? "" : "\": |") + : BsonText.FieldName(path) + ": |"; + + /// + /// 当前阶段的上游形状,外加能推断出的字段类型($group 的 $sum: "$total" → total 的抽样类型;$sum: 1 → Int32)。 + /// + private async Task<(PipelineShape? Shape, IReadOnlyDictionary Types)> UpstreamAsync(CompletionContext context, string database) + { + PipelineShape? shape = ShellCompletion.Upstream(context.PrecedingStages); + var types = new Dictionary(StringComparer.Ordinal); + if (shape is null) + { + return (null, types); + } + SampledSchema? schema = context.Collection is { } collection ? await SchemaAsync(database, collection).ConfigureAwait(true) : null; + BsonDocument? group = context.PrecedingStages + .Select(static t => ShellJson.TryParseDocument(t, out BsonDocument d, out _) ? d : null) + .LastOrDefault(static d => d?.Contains("$group") == true)?["$group"] as BsonDocument; + if (shape.Source == "$group" && group is not null) + { + foreach (BsonElement element in group) + { + types[element.Name] = element.Name == "_id" + ? Infer(element.Value, schema) + : element.Value is BsonDocument { ElementCount: > 0 } accumulator ? Infer(accumulator.GetElement(0), schema) : BsonKind.Missing; + } + } + return (shape, types); + } + + private static BsonKind Infer(BsonElement accumulator, SampledSchema? schema) => accumulator.Name switch + { + "$avg" => BsonKind.Double, + "$push" or "$addToSet" => BsonKind.Array, + "$count" => BsonKind.Int32, + "$sum" when accumulator.Value.IsNumeric => BsonKinds.Of(accumulator.Value), + _ => Infer(accumulator.Value, schema) + }; + + private static BsonKind Infer(BsonValue value, SampledSchema? schema) => value switch + { + BsonString { Value: var path } when path.StartsWith('$') => schema?.Find(path[1..])?.Kind ?? BsonKind.Missing, + BsonDocument => BsonKind.Object, + _ => BsonKinds.Of(value) + }; + + // ── 元数据缓存 ────────────────────────────────────────────────────────── + + /// 某个库的集合(先用对象树已知的,没展开过就自己列一次;缓存)。 + internal Task> CollectionsAsync(string database) + { + if (_collections.TryGetValue(database, out Task>? cached)) + { + return cached; + } + IReadOnlyList known = Workspace.CollectionsOf(database); + Task> task = known.Count > 0 ? Task.FromResult(known) : ListCollectionsAsync(database); + _collections[database] = task; + return task; + } + + private async Task> ListCollectionsAsync(string database) + { + try + { + return await Workspace.Connection.ListCollectionsAsync(database).ConfigureAwait(true); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + _collections.Remove(database); + return []; + } + } + + /// 对一个集合抽样($sample,大小取连接设置;缓存到标签关闭)。 + internal Task SchemaAsync(string database, string collection) + { + if (_schemas.TryGetValue((database, collection), out Task? cached)) + { + return cached; + } + Task task = SampleAsync(database, collection); + _schemas[(database, collection)] = task; + return task; + } + + private async Task SampleAsync(string database, string collection) + { + try + { + int size = Math.Clamp(Workspace.Connection.Settings.SampleSize, 10, 10_000); + IMongoCollection target = Workspace.Connection.Collection(database, collection); + List documents = await Task.Run(async () => + { + using IAsyncCursor cursor = await target.AggregateAsync( + new BsonDocument[] { new("$sample", new BsonDocument("size", size)) }, + new AggregateOptions { MaxTime = TimeSpan.FromSeconds(5) }).ConfigureAwait(false); + return await cursor.ToListAsync().ConfigureAwait(false); + }).ConfigureAwait(true); + return SampledSchema.Build(database, collection, documents); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + _schemas.Remove((database, collection)); + Workspace.Log.Info($"Sampling {database}.{collection} for completion failed: {ex.Message}"); + return null; + } + } + + private Task> IndexNamesAsync(string database, string collection) + { + if (_indexNames.TryGetValue((database, collection), out Task>? cached)) + { + return cached; + } + Task> task = ListIndexNamesAsync(database, collection); + _indexNames[(database, collection)] = task; + return task; + } + + private async Task> ListIndexNamesAsync(string database, string collection) + { + try + { + IReadOnlyList indexes = await Workspace.Connection.ListIndexesAsync(database, collection).ConfigureAwait(true); + return [.. indexes.Select(static i => i.GetValue("name", "").ToString() ?? "").Where(static n => n.Length > 0)]; + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + _indexNames.Remove((database, collection)); + return []; + } + } + + /// 某段文本某个位置上生效的库(补全请求带的是编辑器里那一刻的文本)。 + private string DatabaseAt(string text, int offset) + { + string database = _database; + foreach (ShellStatement statement in ShellScript.Split(text)) + { + if (statement.Offset >= offset) + { + break; + } + if (statement.Text.StartsWith("use", StringComparison.Ordinal) && ShellParser.TryParse(statement, out ShellCommand? command, out _) + && command is { Kind: ShellCommandKind.Use, Target: { } target }) + { + database = target; + } + } + return database; + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Query/QueryTabViewModel.Panels.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Query/QueryTabViewModel.Panels.cs new file mode 100644 index 0000000..ecce28e --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Query/QueryTabViewModel.Panels.cs @@ -0,0 +1,328 @@ +using System.Collections.ObjectModel; +using System.Globalization; +using Avalonia.Threading; +using MongoDB.Bson; +using VelaShell.Plugin.Mongo.Bson; +using VelaShell.Plugin.Mongo.Shell; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 片段页的一项:词汇表里的代码片段,或用户保存的查询。 +/// 名字。 +/// 一句说明 / 保存时间。 +/// 片段正文(| 是光标落点)或保存的整段脚本。 +/// 是不是保存的查询。 +internal sealed record QuerySnippet(string Label, string Description, string Text, bool IsSaved) +{ + /// 图标。 + public string IconKey => IsSaved ? "Mongo.bookmark" : "Mongo.square-code"; + + /// 图标颜色。 + public string IconToken => IsSaved ? "VelaInfo" : "VelaWarning"; + + /// 预览(单行)。 + public string Preview => QueryTabViewModel.OneLine(Text.Replace("|", "", StringComparison.Ordinal)); +} + +/// 历史页的一行。 +/// 历史记录。 +internal sealed record QueryHistoryRow(QueryHistoryEntry Entry) +{ + /// 时间(今天只给时分秒,往前给日期)。 + public string Time => Entry.At.LocalDateTime.Date == DateTime.Today + ? Entry.At.LocalDateTime.ToString("HH:mm:ss", CultureInfo.InvariantCulture) + : Entry.At.LocalDateTime.ToString("MM-dd HH:mm", CultureInfo.InvariantCulture); + + /// 库。 + public string Database => Entry.Database; + + /// 耗时。 + public string Elapsed => Entry.ElapsedMs.ToString(CultureInfo.InvariantCulture) + " ms"; + + /// 成功与否。 + public bool Ok => Entry.Ok; + + /// 状态点颜色。 + public string DotToken => Entry.Ok ? "VelaStatusConnected" : "VelaError"; + + /// 语句(单行)。 + public string Preview => QueryTabViewModel.OneLine(Entry.Text); +} + +internal sealed partial class QueryTabViewModel +{ + private readonly DispatcherTimer _helperTimer; + private (string Database, string Collection)? _helperTarget; + private IReadOnlyList _helperFields = []; + private string _helperCollection = ""; + private string _helperSampleText = ""; + private bool _helperBusy; + + /// 片段页:词汇表片段 + 保存的查询。 + public ObservableCollection Snippets { get; } = []; + + /// 历史页(新的在前)。 + public ObservableCollection History { get; } = []; + + /// 历史为空。 + public bool HistoryEmpty => History.Count == 0; + + /// 字段页的集合名。 + public string HelperCollection + { + get => _helperCollection; + private set + { + if (SetProperty(ref _helperCollection, value)) + { + RaisePropertyChanged(nameof(HasHelperCollection)); + } + } + } + + /// 有没有可抽样的集合。 + public bool HasHelperCollection => _helperCollection.Length > 0; + + /// 「抽样 1,000」。 + public string HelperSampleText + { + get => _helperSampleText; + private set => SetProperty(ref _helperSampleText, value); + } + + /// 字段页的字段。 + public IReadOnlyList HelperFields + { + get => _helperFields; + private set => SetProperty(ref _helperFields, value); + } + + /// 字段页在抽样。 + public bool HelperBusy + { + get => _helperBusy; + private set => SetProperty(ref _helperBusy, value); + } + + private void ScheduleHelper() + { + _helperTimer?.Stop(); + _helperTimer?.Start(); + OnCaretSettled(); + } + + /// + /// 字段页跟着光标走:光标所在语句作用的集合;那条语句没有集合(use 之类)就保持上一个, + /// 一开始取脚本里第一条有集合的语句。 + /// + internal async Task RefreshHelperAsync() + { + IReadOnlyList statements = ShellScript.Split(_text); + (string? collection, string? database) = Target(ShellScript.At(statements, _caretOffset)); + if (collection is null && _helperTarget is null) + { + foreach (ShellStatement statement in statements) + { + (collection, database) = Target(statement); + if (collection is not null) + { + break; + } + } + } + if (collection is null || database is null || _helperTarget == (database, collection)) + { + return; + } + _helperTarget = (database, collection); + HelperCollection = collection; + HelperBusy = true; + SampledSchema? schema = await SchemaAsync(database, collection).ConfigureAwait(true); + if (_helperTarget != (database, collection)) + { + return; + } + HelperBusy = false; + HelperFields = schema?.Fields ?? []; + HelperSampleText = schema is null ? "" : Loc.Format("Query_SampledCount", BsonText.Grouped(schema.Sampled)); + } + + private (string? Collection, string? Database) Target(ShellStatement? statement) + { + if (statement is null) + { + return (null, null); + } + (string? collection, _, string? database) = ShellCompletion.Head(statement.Text); + if (collection is null && ShellParser.TryParse(statement, out ShellCommand? command, out _) && command?.Collection is { } parsed) + { + collection = parsed; + database = command.Database; + } + return (collection, database ?? DatabaseAt(statement.Offset)); + } + + private async Task LoadSnippetsAsync() + { + IReadOnlyList saved = await Workspace.Store.LoadSavedAsync("query", Workspace.ConnectionKey).ConfigureAwait(true); + Snippets.Clear(); + foreach (SavedItem item in saved) + { + Snippets.Add(new QuerySnippet(item.Name, item.SavedAt.LocalDateTime.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture), item.Content, true)); + } + foreach (VocabularyEntry entry in MongoVocabulary.Snippets) + { + Snippets.Add(new QuerySnippet(entry.Name, entry.Describe(Loc.IsChinese), entry.InsertText, false)); + } + } + + private async Task LoadHistoryAsync() + { + IReadOnlyList entries = await Workspace.Store.LoadQueryHistoryAsync(Workspace.ConnectionKey).ConfigureAwait(true); + if (entries.Count == 0) + { + // 宿主没有时序库(headless)时历史只在本会话内:保留内存里那份。 + return; + } + History.Clear(); + foreach (QueryHistoryEntry entry in entries) + { + History.Add(new QueryHistoryRow(entry)); + } + } + + /// 字段页双击:在光标处插入字段路径。 + private void InsertField(SampledField field) + { + ReplaceText(Math.Clamp(_caretOffset, 0, _text.Length), 0, field.Path, literal: true); + Editor?.Focus(); + } + + /// + /// 用一个片段:保存的查询整段替换编辑器;集合方法片段插在光标处 —— 光标前不是 db.coll. 时先补上它。 + /// + private void UseSnippet(QuerySnippet snippet) + { + if (snippet.IsSaved) + { + _savedName = snippet.Label; + _baseline = snippet.Text; + ReplaceText(0, _text.Length, snippet.Text, literal: true); + IsModified = false; + UpdateStatus(); + return; + } + int caret = Math.Clamp(_caretOffset, 0, _text.Length); + string before = _text[..caret]; + bool afterMember = System.Text.RegularExpressions.Regex.IsMatch(before, @"db\.[\w$.]+\.\s*$|\)\.\s*$"); + string text = snippet.Text; + if (!afterMember) + { + string collection = _helperCollection.Length > 0 ? _helperCollection : "collection"; + string prefix = (before.Length > 0 && !before.EndsWith('\n') ? "\n" : "") + "db." + collection + "."; + text = prefix + text; + } + ReplaceText(caret, 0, text); + Editor?.Focus(); + } + + /// 历史页单击:回填编辑器(并切到当时的库)。 + private void UseHistory(QueryHistoryRow row) + { + ReplaceText(0, _text.Length, row.Entry.Text, literal: true); + if (row.Entry.Database.Length > 0) + { + Database = row.Entry.Database; + } + Editor?.Focus(); + } + + /// 保存:第一次问名字,之后同名覆盖。 + private async Task SaveAsync() + { + if (_savedName is null) + { + Workspace.ShowDialog(new SaveQueryDialogViewModel(Workspace, Loc.Format("Query_Title", _number), name => SaveAsAsync(name))); + return; + } + await SaveAsAsync(_savedName).ConfigureAwait(true); + } + + /// 以某个名字保存。 + internal async Task SaveAsAsync(string name) + { + await Workspace.Store.SaveItemAsync("query", Workspace.ConnectionKey, new SavedItem(name, _text, DateTimeOffset.Now)).ConfigureAwait(true); + _savedName = name; + _baseline = _text; + IsModified = false; + UpdateStatus(); + await LoadSnippetsAsync().ConfigureAwait(true); + Workspace.Toast(new ToastRequest { Title = Loc.Format("Query_Saved", name), Kind = ToastKind.Success }); + } + + // ── 导出为代码 / code lens ────────────────────────────────────────────── + + /// 示例代码里的连接串(只有端点,不带凭据)。 + private string SampleConnectionString => "mongodb://" + Workspace.Connection.Endpoint; + + private (ShellCommand? Command, string Database) CommandAt(ShellStatement? statement) + { + statement ??= ShellScript.At(ShellScript.Split(_text), _caretOffset); + if (statement is null) + { + return (null, _database); + } + if (!ShellParser.TryParse(statement, out ShellCommand? command, out ShellParseException? error)) + { + Workspace.Toast(new ToastRequest { Title = Describe(error!), Kind = ToastKind.Warning }); + return (null, _database); + } + return (command, DatabaseAt(statement.Offset)); + } + + /// 「导出为代码」:当前语句 → 选语言的对话框。 + internal void ExportCode(ShellStatement? statement, CodeTarget target) + { + (ShellCommand? command, string database) = CommandAt(statement); + if (command is null) + { + if (statement is null && ShellScript.Split(_text).Count == 0) + { + Workspace.Toast(new ToastRequest { Title = Loc["Query_NothingToExport"], Kind = ToastKind.Info }); + } + return; + } + Workspace.ShowDialog(new CodeExportDialogViewModel(Workspace, command, database, SampleConnectionString, target)); + } + + /// code lens「复制为 C#」。 + private async Task CopyAsCSharpAsync(ShellStatement statement) + { + (ShellCommand? command, string database) = CommandAt(statement); + if (command is null) + { + return; + } + await Workspace.CopyAsync(CodeExport.Generate(command, database, CodeTarget.CSharp, SampleConnectionString)).ConfigureAwait(true); + Workspace.Toast(new ToastRequest { Title = Loc.Format("Query_CodeCopied", "C#"), Kind = ToastKind.Success }); + } + + /// code lens「在管道构建器中打开」(只对 aggregate)。 + private void OpenInPipelineBuilder(ShellStatement statement) + { + (ShellCommand? command, string database) = CommandAt(statement); + if (command is { Kind: ShellCommandKind.Collection, Collection: { } collection, Method.Name: "aggregate" }) + { + Workspace.OpenPipeline(command.Database ?? database, collection, command.Method.Arg(0) as BsonArray); + } + } + + /// code lens 那一行该出现哪几个按钮。 + internal (bool Explainable, bool Pipeline) LensShape(ShellStatement statement) + { + (string? collection, string? method, _) = ShellCompletion.Head(statement.Text); + bool explainable = collection is not null && method is "find" or "findOne" or "aggregate" or "countDocuments" or "distinct" + or "updateOne" or "updateMany" or "deleteOne" or "deleteMany"; + return (explainable, method == "aggregate"); + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Query/QueryTabViewModel.Run.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Query/QueryTabViewModel.Run.cs new file mode 100644 index 0000000..728cf88 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Query/QueryTabViewModel.Run.cs @@ -0,0 +1,582 @@ +using System.Globalization; +using MongoDB.Bson; +using MongoDB.Driver; +using VelaShell.Plugin.Mongo.Analysis; +using VelaShell.Plugin.Mongo.Bson; +using VelaShell.Plugin.Mongo.Shell; +using VelaShell.Plugin.Mongo.Core; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// code lens 上「上次 38 ms · 100 条」的那份记录。 +/// 耗时。 +/// 返回的文档数;不是查询为 。 +/// 成功与否。 +internal sealed record LensRecord(long ElapsedMs, int? Count, bool Ok); + +internal sealed partial class QueryTabViewModel +{ + private readonly Dictionary _lens = new(StringComparer.Ordinal); + private CancellationTokenSource? _runCts; + private string? _runComment; + private bool _isRunning; + private string _runningText = ""; + private string? _lastRunSummary; + private string _execText = ""; + private string _execTimes = ""; + private string _planText = ""; + private string _execState = "idle"; + + /// 正在运行。 + public bool IsRunning + { + get => _isRunning; + private set + { + if (SetProperty(ref _isRunning, value)) + { + RunCommand.RaiseCanExecuteChanged(); + RunCurrentCommand.RaiseCanExecuteChanged(); + RaisePropertyChanged(nameof(StopIconToken)); + RaisePropertyChanged(nameof(ExecText)); + ExplainCommand.RaiseCanExecuteChanged(); + RaisePropertyChanged(nameof(ExecIconKey)); + RaisePropertyChanged(nameof(ExecIconToken)); + } + } + } + + /// 状态条左侧的主文字(2 条语句执行完成 / 正在执行 1/2…)。 + public string ExecText + { + get => _isRunning ? _runningText : _execText; + private set + { + _execText = value; + RaisePropertyChanged(); + } + } + + /// 各语句耗时(find 38 ms · aggregate 112 ms)。 + public string ExecTimes + { + get => _execTimes; + private set => SetProperty(ref _execTimes, value); + } + + /// 最近一条查询的计划摘要(IXSCAN status_1_createdAt_-1 · 扫描 1,284 / 返回 24)。 + public string PlanText + { + get => _planText; + private set => SetProperty(ref _planText, value); + } + + /// 停止按钮的图标颜色(运行时红)。 + public string StopIconToken => _isRunning ? "VelaError" : "VelaTextMuted"; + + /// 状态条图标。 + public string ExecIconKey => _isRunning ? "Mongo.loader-circle" : _execState switch + { + "ok" => "Mongo.circle-check", + "error" => "Mongo.circle-x", + "stopped" => "Mongo.octagon-x", + _ => "Mongo.info" + }; + + /// 状态条图标颜色。 + public string ExecIconToken => _isRunning ? "VelaAccent" : _execState switch + { + "ok" => "VelaStatusConnected", + "error" => "VelaError", + "stopped" => "VelaWarning", + _ => "VelaTextMuted" + }; + + /// 某条语句上次运行的记录(code lens 用);没跑过为 。 + internal LensRecord? LensFor(ShellStatement statement) => _lens.GetValueOrDefault(LensKey(statement.Text)); + + /// + /// code lens 记录的键:去掉空白与引号。改个缩进、给键补上引号(Alt+↵ 修复)之后,「上次 38 ms · 100 条」还认得是同一条语句。 + /// + private static string LensKey(string text) => new([.. text.Where(static c => !char.IsWhiteSpace(c) && c is not ('"' or '\''))]); + + /// 运行:全部(有选区时只跑选区)或光标所在的那条。 + public async Task RunAsync(bool all) + { + if (_isRunning) + { + return; + } + IReadOnlyList statements = ShellScript.Split(_text); + (int selOffset, int selLength) = Editor?.Selection ?? (0, 0); + IReadOnlyList targets; + if (selLength > 0 && selOffset + selLength <= _text.Length) + { + // 选区:只跑选中的那一段(按选区内容重新切分,偏移换回整段脚本坐标)。 + targets = [.. ShellScript.Split(_text.Substring(selOffset, selLength)).Select(s => s with { Offset = s.Offset + selOffset })]; + } + else if (all) + { + targets = statements; + } + else + { + targets = ShellScript.At(statements, _caretOffset) is { } current ? [current] : []; + } + await RunStatementsAsync(targets).ConfigureAwait(true); + } + + /// 逐条运行一组语句。 + internal async Task RunStatementsAsync(IReadOnlyList targets) + { + if (_isRunning) + { + return; + } + if (targets.Count == 0) + { + _execState = "idle"; + ExecText = Loc["Query_NothingToRun"]; + RaisePropertyChanged(nameof(ExecIconKey)); + return; + } + var session = new ShellSession(DatabaseAt(targets[0].Offset)); + using var cts = new CancellationTokenSource(); + _runCts = cts; + _runComment = "velashell-query-" + Guid.NewGuid().ToString("N")[..10]; + var options = new ShellRunOptions { MaxTimeMs = _maxTimeMs, Comment = _runComment }; + // 未固定的旧结果让位给这一次;固定的留着。 + foreach (QueryResultSet stale in Panes.OfType().Where(static p => !p.IsPinned).ToList()) + { + Panes.Remove(stale); + } + int nextNumber = Panes.OfType().Select(static p => p.Number).DefaultIfEmpty(0).Max() + 1; + var timings = new List(); + QueryResultSet? lastSet = null; + ShellResult? lastQuery = null; + ShellCommand? lastQueryCommand = null; + bool explained = false; + int completed = 0; + int position = 0; + string state = "ok"; + IsRunning = true; + try + { + for (int i = 0; i < targets.Count; i++) + { + ShellStatement statement = targets[i]; + position = i + 1; + _runningText = Loc.Format("Query_Running", i + 1, targets.Count); + RaisePropertyChanged(nameof(ExecText)); + string echo = OneLine(statement.Text); + ShellCommand command; + try + { + command = ShellParser.Parse(statement); + } + catch (ShellParseException ex) + { + Messages.Messages.Add(new QueryMessage(DateTime.Now, QueryMessageKind.Error, Describe(ex), echo, null)); + state = "error"; + break; + } + ShellResult result; + System.Diagnostics.Stopwatch watch = System.Diagnostics.Stopwatch.StartNew(); + try + { + ShellRunOptions run = options; + result = await Task.Run(() => _executor.ExecuteAsync(command, session, run, cts.Token), cts.Token).ConfigureAwait(true); + } + catch (OperationCanceledException) + { + Messages.Messages.Add(new QueryMessage(DateTime.Now, QueryMessageKind.Warning, Loc["Query_MsgStopped"], echo, null)); + await RecordHistoryAsync(session.Database, statement.Text, watch.ElapsedMilliseconds, ok: false).ConfigureAwait(true); + state = "stopped"; + break; + } + catch (Exception ex) when (ex is MongoException or TimeoutException or ShellExecutionException or FormatException or InvalidOperationException) + { + string message = ex is ShellExecutionException ? ex.Message : MongoConnector.Describe(ex); + Messages.Messages.Add(new QueryMessage(DateTime.Now, QueryMessageKind.Error, message, echo, Loc.Format("Common_Ms", watch.ElapsedMilliseconds))); + _lens[LensKey(statement.Text)] = new LensRecord(watch.ElapsedMilliseconds, null, false); + await RecordHistoryAsync(session.Database, statement.Text, watch.ElapsedMilliseconds, ok: false).ConfigureAwait(true); + state = "error"; + break; + } + long ms = (long)result.Elapsed.TotalMilliseconds; + string op = command.Operation; + // use 只是切换会话的库:不计入"N 条语句执行完成"、不进耗时与消息(设计稿 03 的脚本里有 use,状态条写的是 2 条)。 + if (command.Kind != ShellCommandKind.Use) + { + completed++; + timings.Add($"{op} {Loc.Format("Common_Ms", ms)}"); + _lens[LensKey(statement.Text)] = new LensRecord(ms, result.Kind == ShellResultKind.Documents ? result.Documents.Count : null, !result.Declined); + await RecordHistoryAsync(result.Database, statement.Text, ms, ok: !result.Declined).ConfigureAwait(true); + string prefix = result.Collection is { } coll ? $"{coll}.{op}" : op; + Messages.Messages.Add(new QueryMessage(DateTime.Now, + result.Declined ? QueryMessageKind.Warning : QueryMessageKind.Ok, + $"{prefix}: {result.Message}", echo, Loc.Format("Common_Ms", ms))); + } + if (result.Truncated) + { + Messages.Messages.Add(new QueryMessage(DateTime.Now, QueryMessageKind.Warning, + Loc.Format("Query_TruncatedNotice", result.Documents.Count), echo, null)); + } + + switch (result.Kind) + { + case ShellResultKind.Documents: + { + var set = new QueryResultSet(Loc, Workspace.Connection.Settings.Ejson, nextNumber++, result, statement) { ViewMode = _resultView }; + Panes.Insert(Panes.IndexOf(Explain), set); + lastSet = set; + if (command.Method?.Name is "find" or "findOne" or "aggregate" && command.Kind == ShellCommandKind.Collection + && command.Modifier("count") is null) + { + lastQuery = result; + lastQueryCommand = command; + } + break; + } + case ShellResultKind.Explain when result.Explain is { } explain: + Explain.Begin(command, result.Database); + ShowExplain(explain); + explained = true; + break; + } + if (command.Kind == ShellCommandKind.Use) + { + Database = session.Database; + } + if (result.ChangesCatalog) + { + _ = Workspace.RefreshTreeAsync(result.Database); + } + } + } + finally + { + _runCts = null; + _runComment = null; + IsRunning = false; + } + + _execState = state; + ExecText = state switch + { + "ok" when completed == 0 => Loc.Format("Query_MsgUse", session.Database), + "ok" => Loc.Format("Query_ExecDone", completed), + "stopped" => Loc.Format("Query_ExecStopped", position - 1, targets.Count), + _ => Loc.Format("Query_ExecFailed", position, targets.Count) + }; + RaisePropertyChanged(nameof(ExecIconKey)); + RaisePropertyChanged(nameof(ExecIconToken)); + ExecTimes = string.Join(" · ", timings); + RaisePropertyChanged(nameof(Statements)); + LensChanged?.Invoke(); + + if (state != "ok") + { + SelectPane(Messages); + } + else if (explained && lastSet is null) + { + SelectPane(Explain); + } + else if (lastSet is not null) + { + SelectPane(lastSet); + } + else + { + SelectPane(Messages); + } + + int rows = lastSet?.Documents.Count ?? 0; + _lastRunSummary = lastSet is null ? null : Loc.Format("Query_StatusRun", BsonText.Grouped(rows), Loc.Format("Common_Ms", (long)lastSet.Result.Elapsed.TotalMilliseconds)); + UpdateStatus(); + PlanText = ""; + if (lastQuery is not null && lastQueryCommand is not null && lastQuery.Elapsed < TimeSpan.FromSeconds(2)) + { + _ = ProbePlanAsync(lastQueryCommand, lastQuery.Database); + } + } + + /// 停止:放弃等待,并在服务器上按 comment 标记 killOp。 + public async Task StopAsync() + { + if (_runCts is not { } cts) + { + return; + } + string? comment = _runComment; + await cts.CancelAsync().ConfigureAwait(true); + if (comment is null) + { + return; + } + try + { + int killed = await Workspace.Connection.KillByCommentAsync(comment).ConfigureAwait(true); + if (killed > 0) + { + Messages.Messages.Add(new QueryMessage(DateTime.Now, QueryMessageKind.Warning, Loc.Format("Query_MsgKilled", killed), null, null)); + } + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Messages.Messages.Add(new QueryMessage(DateTime.Now, QueryMessageKind.Warning, Loc.Format("Query_MsgKillFailed", MongoConnector.Describe(ex)), null, null)); + } + } + + /// + /// 跑完一条查询后,后台用 explain 把计划摘要补到状态条上(设计稿 03 底部「IXSCAN … · 扫描 1,284 / 返回 24」)。 + /// 只对本来就快的查询做(上一跑不到 2 秒):explain executionStats 会再执行一遍,慢查询不值得为一行状态多跑一次。 + /// + private async Task ProbePlanAsync(ShellCommand command, string database) + { + try + { + var options = new ShellRunOptions { MaxTimeMs = 5000 }; + ShellResult result = await Task.Run(() => _executor.ExplainAsync(command, database, "executionStats", options, null)).ConfigureAwait(true); + if (result.Explain is not { } explain) + { + return; + } + ExplainPlan plan = ExplainPlan.Parse(explain); + long examined = Math.Max(plan.TotalKeysExamined ?? 0, plan.TotalDocsExamined ?? 0); + PlanText = plan.NReturned is { } returned + ? $"{plan.Summary} · {Loc.Format("Query_PlanScan", BsonText.Grouped(examined), BsonText.Grouped(returned))}" + : plan.Summary; + } + catch (Exception ex) when (ex is MongoException or TimeoutException or ShellExecutionException or OperationCanceledException) + { + // 状态条上少一行计划摘要而已,不打扰用户。 + } + } + + // ── 执行计划 ──────────────────────────────────────────────────────────── + + /// F6:对光标所在语句(或指定语句)跑 explain;光标不在可解释的语句上时取第一条可解释的。 + public async Task ExplainCurrentAsync(ShellStatement? statement) + { + IReadOnlyList statements = ShellScript.Split(_text); + ShellCommand? command = null; + if (statement is not null) + { + command = TryCommand(statement); + } + else + { + if (ShellScript.At(statements, _caretOffset) is { } current) + { + command = TryCommand(current); + } + if (command is null || !Explainable(command)) + { + command = statements.Select(TryCommand).FirstOrDefault(static c => c is not null && Explainable(c)); + } + } + if (command is null || !Explainable(command)) + { + Workspace.Toast(new ToastRequest { Title = Loc["Query_NothingToExplain"], Kind = ToastKind.Info }); + return; + } + await ExplainAsync(command, DatabaseAt(command.Statement.Offset)).ConfigureAwait(true); + } + + /// 对一条语句跑 explain 并显示在「执行计划」页签。 + internal async Task ExplainAsync(ShellCommand command, string database) + { + Explain.Begin(command, database); + SelectPane(Explain); + // executionStats 一律按 allPlansExecution 去要:后者是前者的超集,多出来的只是候选计划的试运行数据 + // (那本来就跑过了,不额外花服务器的时间),而候选计划列表的 works / score 只有它给得出来。 + string verbosity = Explain.Verbosity == "executionStats" ? "allPlansExecution" : Explain.Verbosity; + var options = new ShellRunOptions { MaxTimeMs = _maxTimeMs }; + try + { + ShellResult result = await Task.Run(() => _executor.ExplainAsync(command, database, verbosity, options, null)).ConfigureAwait(true); + if (result.Explain is { } explain) + { + ShowExplain(explain); + Messages.Messages.Add(new QueryMessage(DateTime.Now, QueryMessageKind.Info, result.Message, OneLine(command.Statement.Text), + Loc.Format("Common_Ms", (long)result.Elapsed.TotalMilliseconds))); + } + } + catch (Exception ex) when (ex is MongoException or TimeoutException or ShellExecutionException) + { + string message = ex is ShellExecutionException ? ex.Message : MongoConnector.Describe(ex); + Explain.Fail(message); + Messages.Messages.Add(new QueryMessage(DateTime.Now, QueryMessageKind.Error, message, OneLine(command.Statement.Text), null)); + } + } + + private void ShowExplain(BsonDocument explain) + { + ExplainPlan plan = ExplainPlan.Parse(explain); + Explain.Show(plan); + if (plan.NReturned is { } returned) + { + long examined = Math.Max(plan.TotalKeysExamined ?? 0, plan.TotalDocsExamined ?? 0); + PlanText = $"{plan.Summary} · {Loc.Format("Query_PlanScan", BsonText.Grouped(examined), BsonText.Grouped(returned))}"; + } + } + + /// code lens 的数据变了(跑完一次)。 + internal event Action? LensChanged; + + /// 「创建该索引…」:确认之后直接 createIndexes,然后重跑一次执行计划看效果。 + internal async Task CreateSuggestedIndexAsync(BsonDocument keys) + { + if (Explain.Command is not { Collection: { } collection } command) + { + return; + } + string database = Explain.Database ?? _database; + string name = ShellExecutor.IndexName(keys); + bool confirmed = await Workspace.ConfirmAsync(new ConfirmRequest + { + Title = Loc["Query_CreateIndexTitle"], + Message = Loc.Format("Query_CreateIndexBody", $"{database}.{collection}"), + ConfirmLabel = Loc["Query_CreateIndexConfirm"], + IconKey = "Mongo.key-round", + Danger = false, + Facts = + [ + new ConfirmFact(Loc["Query_ConfirmNamespace"], $"{database}.{collection}"), + new ConfirmFact(Loc["Query_CreateIndexKeys"], BsonText.Literal(keys)), + new ConfirmFact(Loc["Query_CreateIndexName"], name) + ] + }).ConfigureAwait(true); + if (!confirmed || !Workspace.EnsureWritable(database)) + { + return; + } + try + { + await Workspace.Connection.RunCommandAsync(database, new BsonDocument + { + { "createIndexes", collection }, + { "indexes", new BsonArray { new BsonDocument { { "key", keys }, { "name", name } } } } + }).ConfigureAwait(true); + Workspace.Toast(new ToastRequest + { + Title = Loc.Format("Query_IndexCreated", name), + Kind = ToastKind.Success, + ActionLabel = Loc["Query_OpenIndexes"], + Action = () => + { + Workspace.OpenDesign(database, collection, DesignPage.Indexes); + return Task.CompletedTask; + }, + Duration = TimeSpan.FromSeconds(8) + }); + Messages.Messages.Add(new QueryMessage(DateTime.Now, QueryMessageKind.Ok, Loc.Format("Query_IndexCreated", name), null, null)); + await ExplainAsync(command, database).ConfigureAwait(true); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Report(ex); + } + } + + /// 「用 hint 对比」:选一个索引,带 hint 再 explain 一次,与当前计划并排比较。 + internal Task CompareWithHintAsync() + { + if (Explain.Command is not { } command || Explain.Plan is not { } plan) + { + return Task.CompletedTask; + } + Workspace.ShowDialog(new HintCompareDialogViewModel(Workspace, _executor, command, Explain.Database ?? _database, plan, _maxTimeMs)); + return Task.CompletedTask; + } + + private static bool Explainable(ShellCommand command) => + command.Kind == ShellCommandKind.Collection && command.Method?.Name is "find" or "findOne" or "aggregate" or "countDocuments" or "distinct" + or "updateOne" or "updateMany" or "replaceOne" or "deleteOne" or "deleteMany" or "findOneAndUpdate" or "findOneAndReplace" or "findOneAndDelete"; + + private static ShellCommand? TryCommand(ShellStatement statement) => + ShellParser.TryParse(statement, out ShellCommand? command, out _) ? command : null; + + private async Task RecordHistoryAsync(string database, string text, long ms, bool ok) + { + var entry = new QueryHistoryEntry(DateTimeOffset.Now, database, text, ms, ok); + History.Insert(0, new QueryHistoryRow(entry)); + while (History.Count > 300) + { + History.RemoveAt(History.Count - 1); + } + await Workspace.Store.AppendQueryHistoryAsync(Workspace.ConnectionKey, entry).ConfigureAwait(true); + } + + /// 解析错误 → 一句给人看的话。 + private string Describe(ShellParseException ex) => Loc.Format(ex.MessageKey, ex.Argument); + + /// 语句压成一行(消息页、历史页)。 + internal static string OneLine(string text) + { + string line = string.Join(' ', text.Split((char[])['\n', '\r'], StringSplitOptions.RemoveEmptyEntries).Select(static l => l.Trim())); + return line.Length > 160 ? line[..159] + "…" : line; + } + + // ── 结果区工具 ────────────────────────────────────────────────────────── + + private async Task ExportSelectedAsync() + { + switch (_selectedPane) + { + case QueryResultSet { Result.Query: { } query } set when set.Result.Operation is "find": + Workspace.ShowDialog(new ExportWizardViewModel(Workspace, query.Database, query.Collection, query with { Limit = query.Limit }, null)); + return; + case QueryResultSet set: + await SaveJsonAsync(set.ToJson(), $"{set.Result.Collection ?? set.Result.Database}-{set.Result.Operation}.json").ConfigureAwait(true); + return; + case ExplainPane { RawText: { } raw }: + await SaveJsonAsync(raw, "explain.json").ConfigureAwait(true); + return; + } + } + + private async Task SaveJsonAsync(string json, string suggested) + { + string? path = await Workspace.PickSaveFileAsync(Loc["Query_ExportJsonTitle"], suggested, [FileKind.Json]).ConfigureAwait(true); + if (path is null) + { + return; + } + try + { + await File.WriteAllTextAsync(path, json).ConfigureAwait(true); + Workspace.Toast(new ToastRequest { Title = Loc.Format("Query_Exported", Path.GetFileName(path)), Kind = ToastKind.Success }); + } + catch (IOException ex) + { + Workspace.Toast(new ToastRequest { Title = Loc.Format("Common_Failed", ex.Message), Kind = ToastKind.Error }); + } + catch (UnauthorizedAccessException ex) + { + Workspace.Toast(new ToastRequest { Title = Loc.Format("Common_Failed", ex.Message), Kind = ToastKind.Error }); + } + } + + private async Task CopySelectedAsync() + { + string? text = _selectedPane switch + { + QueryResultSet set => set.ToJson(), + ExplainPane explain => explain.RawText, + MessagesPane messages => string.Join('\n', messages.Messages.Select(static m => $"{m.Time} {m.Text}{(m.HasStatement ? " " + m.Statement : "")}")), + _ => null + }; + if (string.IsNullOrEmpty(text)) + { + return; + } + await Workspace.CopyAsync(text).ConfigureAwait(true); + Workspace.Toast(new ToastRequest { Title = Loc["Common_Copied"], Kind = ToastKind.Success }); + } + + /// 格式化一个统计数(网格状态行、code lens)。 + internal static string Count(int value) => value.ToString("N0", CultureInfo.InvariantCulture); +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Query/QueryTabViewModel.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Query/QueryTabViewModel.cs new file mode 100644 index 0000000..692681a --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Query/QueryTabViewModel.cs @@ -0,0 +1,493 @@ +using System.Collections.ObjectModel; +using System.Globalization; +using Avalonia.Threading; +using VelaShell.Plugin.Mongo.Shell; +using VelaShell.Plugin.Mongo.Core; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 查询编辑器里那块 CodeEditor 的门面:视图实现它,视图模型借它取选区、替换文本、弹补全。 +/// 单测里没有视图,视图模型照常工作(选区为空、替换直接改 )。 +/// +internal interface IQueryEditor +{ + /// 选区(没选中时长度为 0)。 + (int Offset, int Length) Selection { get; } + + /// + /// 替换一段文本(进撤销栈)。 为 时第一个 | 是替换后的光标落点; + /// 为 时原样插入 —— 格式化、回填历史时文本里的 ||、正则的 a|b 不能被当成光标标记吃掉。 + /// + void Replace(int offset, int length, string text, bool literal); + + /// 把焦点给编辑区。 + void Focus(); +} + +/// +/// 查询编辑器(设计稿 03 / 14)。 +/// +/// 上半是 mongosh 脚本编辑器(补全、诊断、code lens)与右侧字段 / 片段 / 历史面板;下半是结果区: +/// 每条产出文档的语句一个「结果 N」页签(网格 / 树 / JSON),外加「执行计划」与「消息」。 +/// 一次运行按语句**逐条**执行、逐条计时,每条都打上同一个 comment 标记 ——「停止」靠它在服务器上 killOp。 +/// +/// +internal sealed partial class QueryTabViewModel : WorkspaceTab +{ + private readonly int _number; + private readonly ShellExecutor _executor; + private string _text; + private string _baseline; + private string _database; + private bool _runOnLoad; + private int _caretOffset; + private int _caretLine = 1; + private int _caretColumn = 1; + private int _maxTimeMs; + private string _helperTab = "fields"; + private string _resultView = "grid"; + private QueryPane? _selectedPane; + private string? _savedName; + private bool _loaded; + + /// 构造。 + /// 外壳服务。 + /// 起始库。 + /// 起始文本;没有为空编辑器。 + /// 加载后立即运行(别的分区「在新查询中打开 / 查看执行计划」用)。 + /// 编号(标题「查询 N」)。 + public QueryTabViewModel(IMongoWorkspace workspace, string database, string? text, bool run, int number) + : base(workspace) + { + _number = number; + _database = database; + _text = text ?? ""; + _baseline = _text; + _runOnLoad = run; + _maxTimeMs = workspace.Connection.Settings.MaxTimeMs; + Title = workspace.Loc.Format("Query_Title", number); + Scope = "@" + database; + _executor = new ShellExecutor(workspace.Connection, new QueryGuard(workspace), workspace.Loc); + + Messages = new MessagesPane(Loc); + Explain = new ExplainPane(this); + Panes = [Explain, Messages]; + SelectPane(Messages); + + RunCommand = new AsyncCommand(() => RunAsync(all: true), () => !IsRunning); + RunCurrentCommand = new AsyncCommand(() => RunAsync(all: false), () => !IsRunning); + // 停止不随运行状态置灰(设计稿里它常亮,只有图标在运行时变红);没在跑时点它什么也不做。 + StopCommand = new AsyncCommand(StopAsync); + History.CollectionChanged += (_, _) => RaisePropertyChanged(nameof(HistoryEmpty)); + ExplainCommand = new AsyncCommand(() => ExplainCurrentAsync(null), () => !IsRunning); + FormatCommand = new RelayCommand(Format); + SaveCommand = new AsyncCommand(SaveAsync); + HistoryCommand = new RelayCommand(() => HelperTab = "history"); + ExportCodeCommand = new RelayCommand(() => ExportCode(null, CodeTarget.CSharp)); + SelectPaneCommand = new RelayCommand(SelectPane); + ExportResultCommand = new AsyncCommand(ExportSelectedAsync, () => _selectedPane is QueryResultSet or ExplainPane { HasPlan: true }); + CopyResultCommand = new AsyncCommand(CopySelectedAsync, () => _selectedPane is not null); + TogglePinCommand = new RelayCommand(() => (_selectedPane as QueryResultSet)?.TogglePinCommand.Execute(null), () => _selectedPane is QueryResultSet); + SelectDatabaseCommand = new RelayCommand(db => Database = db); + SetMaxTimeCommand = new RelayCommand(ms => MaxTimeMs = ms); + ApplyFixCommand = new RelayCommand(() => ApplyQuickFix()); + InsertFieldCommand = new RelayCommand(InsertField); + UseSnippetCommand = new RelayCommand(UseSnippet); + UseHistoryCommand = new RelayCommand(UseHistory); + LensRunCommand = new AsyncCommand(s => RunStatementsAsync([s])); + LensExplainCommand = new AsyncCommand(s => ExplainCurrentAsync(s)); + LensCopyCodeCommand = new AsyncCommand(CopyAsCSharpAsync); + LensPipelineCommand = new RelayCommand(OpenInPipelineBuilder); + + _diagnosticTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(250) }; + _diagnosticTimer.Tick += (_, _) => + { + _diagnosticTimer.Stop(); + Analyze(); + }; + _helperTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) }; + _helperTimer.Tick += (_, _) => + { + _helperTimer.Stop(); + _ = RefreshHelperAsync(); + }; + Analyze(); + UpdateStatus(); + } + + /// 当前库(工具栏右侧的下拉;脚本里的 use 也会改它)。 + public string Database + { + get => _database; + set + { + if (!string.IsNullOrEmpty(value) && SetProperty(ref _database, value)) + { + Scope = "@" + value; + if (!Databases.Contains(value)) + { + Databases.Add(value); + } + ScheduleHelper(); + } + } + } + + /// 脚本全文。 + public string Text + { + get => _text; + set + { + if (SetProperty(ref _text, value ?? "")) + { + IsModified = _text != _baseline; + _diagnosticTimer.Stop(); + _diagnosticTimer.Start(); + UpdateStatus(); + } + } + } + + /// + public override TabKind Kind => TabKind.Query; + + /// + public override string Key => $"query:{_number}"; + + /// + public override string IconKey => "Mongo.file-code"; + + /// + public override string IconToken => "VelaAccent"; + + /// 编辑器门面(视图挂上来;单测里为 )。 + internal IQueryEditor? Editor { get; set; } + + /// 光标位置(与编辑器双向绑定)。 + public int CaretOffset + { + get => _caretOffset; + set + { + if (SetProperty(ref _caretOffset, value)) + { + ScheduleHelper(); + } + } + } + + /// 光标行(1 起)。 + public int CaretLine + { + get => _caretLine; + set + { + if (SetProperty(ref _caretLine, value)) + { + RaisePropertyChanged(nameof(CaretText)); + } + } + } + + /// 光标列(1 起)。 + public int CaretColumn + { + get => _caretColumn; + set + { + if (SetProperty(ref _caretColumn, value)) + { + RaisePropertyChanged(nameof(CaretText)); + } + } + } + + /// 状态条右侧的 Ln 12, Col 8。 + public string CaretText => string.Create(CultureInfo.InvariantCulture, $"Ln {_caretLine}, Col {_caretColumn}"); + + /// 已知的库(下拉)。 + public ObservableCollection Databases { get; } = []; + + /// 默认 maxTimeMS(工具栏芯片;取连接设置,可临时改)。 + public int MaxTimeMs + { + get => _maxTimeMs; + set + { + if (SetProperty(ref _maxTimeMs, Math.Max(0, value))) + { + RaisePropertyChanged(nameof(MaxTimeText)); + } + } + } + + /// 芯片上的字(maxTimeMS 30s)。 + public string MaxTimeText => "maxTimeMS " + FormatDuration(_maxTimeMs, Loc); + + /// 右侧面板页签(fields / snippets / history)。 + public string HelperTab + { + get => _helperTab; + set + { + if (SetProperty(ref _helperTab, value)) + { + RaisePropertyChanged(nameof(IsFieldsTab)); + RaisePropertyChanged(nameof(IsSnippetsTab)); + RaisePropertyChanged(nameof(IsHistoryTab)); + if (value == "history") + { + _ = LoadHistoryAsync(); + } + } + } + } + + /// 字段页。 + public bool IsFieldsTab => _helperTab == "fields"; + + /// 片段页。 + public bool IsSnippetsTab => _helperTab == "snippets"; + + /// 历史页。 + public bool IsHistoryTab => _helperTab == "history"; + + /// 结果的看法(grid / tree / json),所有结果页签共用。 + public string ResultView + { + get => _resultView; + set + { + if (SetProperty(ref _resultView, value)) + { + foreach (QueryResultSet set in Panes.OfType()) + { + set.ViewMode = value; + } + } + } + } + + /// 结果区页签(结果在前,执行计划与消息固定在最后)。 + public ObservableCollection Panes { get; } + + /// 执行计划页签。 + public ExplainPane Explain { get; } + + /// 消息页签。 + public MessagesPane Messages { get; } + + /// 选中的页签。 + public QueryPane? SelectedPane + { + get => _selectedPane; + private set + { + if (SetProperty(ref _selectedPane, value)) + { + RaisePropertyChanged(nameof(IsResultSelected)); + RaisePropertyChanged(nameof(IsExplainSelected)); + ExportResultCommand?.RaiseCanExecuteChanged(); + CopyResultCommand?.RaiseCanExecuteChanged(); + TogglePinCommand?.RaiseCanExecuteChanged(); + } + } + } + + /// 选中的是一份结果(网格 / 树 / JSON 切换只对结果有意义)。 + public bool IsResultSelected => _selectedPane is QueryResultSet; + + /// 选中的是执行计划。 + public bool IsExplainSelected => _selectedPane is ExplainPane; + + /// 运行全部(F5;有选区时只运行选区)。 + public AsyncCommand RunCommand { get; } + + /// 运行光标所在语句(Ctrl+↵)。 + public AsyncCommand RunCurrentCommand { get; } + + /// 停止(取消等待 + 服务器端 killOp)。 + public AsyncCommand StopCommand { get; } + + /// 执行计划(F6)。 + public AsyncCommand ExplainCommand { get; } + + /// 格式化(Alt+Shift+F)。 + public RelayCommand FormatCommand { get; } + + /// 保存。 + public AsyncCommand SaveCommand { get; } + + /// 历史(切到右侧历史页)。 + public RelayCommand HistoryCommand { get; } + + /// 导出为代码。 + public RelayCommand ExportCodeCommand { get; } + + /// 选一个结果页签。 + public RelayCommand SelectPaneCommand { get; } + + /// 导出选中的结果。 + public AsyncCommand ExportResultCommand { get; } + + /// 复制选中的结果。 + public AsyncCommand CopyResultCommand { get; } + + /// 固定 / 取消固定选中的结果。 + public RelayCommand TogglePinCommand { get; } + + /// 切换当前库。 + public RelayCommand SelectDatabaseCommand { get; } + + /// 改默认 maxTimeMS。 + public RelayCommand SetMaxTimeCommand { get; } + + /// 快捷修复(Alt+↵)。 + public RelayCommand ApplyFixCommand { get; } + + /// 插入字段路径(字段页双击)。 + public RelayCommand InsertFieldCommand { get; } + + /// 用一个片段 / 保存的查询。 + public RelayCommand UseSnippetCommand { get; } + + /// 回填一条历史。 + public RelayCommand UseHistoryCommand { get; } + + /// code lens「运行」。 + public AsyncCommand LensRunCommand { get; } + + /// code lens「执行计划」。 + public AsyncCommand LensExplainCommand { get; } + + /// code lens「复制为 C#」。 + public AsyncCommand LensCopyCodeCommand { get; } + + /// code lens「在管道构建器中打开」。 + public RelayCommand LensPipelineCommand { get; } + + /// + public override async Task LoadAsync() + { + if (_loaded) + { + return; + } + _loaded = true; + foreach (string db in Workspace.Databases) + { + if (!Databases.Contains(db)) + { + Databases.Add(db); + } + } + if (!Databases.Contains(_database)) + { + Databases.Add(_database); + } + _ = LoadDatabasesAsync(); + _ = LoadSnippetsAsync(); + _ = LoadHistoryAsync(); + _ = RefreshHelperAsync(); + if (_runOnLoad) + { + _runOnLoad = false; + await RunAsync(all: true).ConfigureAwait(true); + } + } + + /// + public override Task RefreshAsync() => RunAsync(all: true); + + /// + public override void Dispose() + { + _diagnosticTimer.Stop(); + _helperTimer.Stop(); + _runCts?.Cancel(); + base.Dispose(); + } + + private async Task LoadDatabasesAsync() + { + try + { + IReadOnlyList databases = await Workspace.Connection.ListDatabasesAsync().ConfigureAwait(true); + foreach (Core.DatabaseInfo info in databases) + { + if (!Databases.Contains(info.Name)) + { + Databases.Add(info.Name); + } + } + } + catch (Exception ex) when (ex is MongoDB.Driver.MongoException or TimeoutException) + { + // 没有 listDatabases 权限时下拉里只有对象树已知的库 —— 照样能用。 + Workspace.Log.Info($"Listing databases for the query editor failed: {ex.Message}"); + } + } + + private void SelectPane(QueryPane? pane) + { + if (pane is null) + { + return; + } + foreach (QueryPane p in Panes) + { + p.IsSelected = ReferenceEquals(p, pane); + } + SelectedPane = pane; + if (pane is QueryResultSet set) + { + set.ViewMode = _resultView; + } + } + + /// 标签页给宿主状态栏的那一行。 + private void UpdateStatus() + { + var parts = new List(); + if (_lastRunSummary is { Length: > 0 } run) + { + parts.Add(run); + } + if (IsModified || _savedName is null && _text.Length > 0) + { + parts.Add(Loc.Format("Query_StatusUnsaved", _number)); + } + else if (_savedName is { } name) + { + parts.Add(Loc.Format("Query_StatusSaved", name)); + } + StatusText = string.Join(" · ", parts); + } + + /// 毫秒 → 30s / 500ms / 5min / 不限。 + internal static string FormatDuration(int ms, Loc loc) => ms switch + { + <= 0 => loc["Query_Unlimited"], + < 1000 => ms.ToString(CultureInfo.InvariantCulture) + "ms", + _ when ms % 60_000 == 0 => (ms / 60_000).ToString(CultureInfo.InvariantCulture) + "min", + _ when ms % 1000 == 0 => (ms / 1000).ToString(CultureInfo.InvariantCulture) + "s", + _ => (ms / 1000d).ToString("0.#", CultureInfo.InvariantCulture) + "s" + }; + + /// 某个位置上生效的库:它之前最后一条 use;没有就是下拉里的当前库。 + internal string DatabaseAt(int offset) => DatabaseAt(_text, offset); + + /// 失败的统一出口:写进消息页并弹提示(AsyncCommand 会吞异常,不自己报就没人知道)。 + private void Report(Exception ex, string? statement = null) + { + string message = ex switch + { + ShellExecutionException or ShellParseException => ex.Message, + _ => MongoConnector.Describe(ex) + }; + Messages.Messages.Add(new QueryMessage(DateTime.Now, QueryMessageKind.Error, message, statement, null)); + Workspace.Toast(new ToastRequest { Title = Loc.Format("Common_Failed", message), Kind = ToastKind.Error }); + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Query/ResultGrid.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Query/ResultGrid.cs new file mode 100644 index 0000000..b7ba769 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Query/ResultGrid.cs @@ -0,0 +1,865 @@ +using System.Globalization; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.VisualTree; +using MongoDB.Bson; +using VelaShell.Plugin.Mongo.Bson; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 查询结果的只读网格(设计稿 03 下半):26px 类型化列头(字段名 + 灰色类型)、28px 行、 +/// 行号列、BSON 类型着色、数值右对齐,点列头在客户端排序;列头分隔线可拖宽、双击按内容自适应。 +/// 双击对象 / 数组单元格钻进去(Navicat 式),顶上多一条面包屑,Backspace / 点面包屑回去。 +/// +/// 自己画而不是 DataGrid:结果的列是运行时才知道的(每次查询都不一样),一千行乘十几列的单元格 +/// 做成控件树太重;这里只画视口里那几十行。放在 里用,列头钉在视口顶上。 +/// +/// +public sealed class ResultGrid : Control +{ + private const double HeaderHeight = 26; + private const double CrumbHeight = 28; + private const double RowHeight = 28; + private const double NumberWidth = 44; + private const double CellPadding = 8; + private const double GripHalf = 3; + + /// 数据源(QueryResultSet)。 + public static readonly StyledProperty SourceProperty = + AvaloniaProperty.Register(nameof(Source)); + + /// 一层:顶层是查询结果本身,往下每钻一层压一个。 + private readonly List _levels = []; + private readonly List<(Rect Bounds, int Level)> _crumbHits = []; + private Rect _backHit; + private int _hover = -1; + private string? _queryOrderColumn; + private bool _queryOrderDescending; + private ScrollViewer? _scroller; + private int _resizing = -1; + private double _resizeStartX; + private double _resizeStartWidth; + + static ResultGrid() + { + AffectsMeasure(SourceProperty); + AffectsRender(SourceProperty); + FocusableProperty.OverrideDefaultValue(true); + } + + /// 数据源。 + public object? Source + { + get => GetValue(SourceProperty); + set => SetValue(SourceProperty, value); + } + + /// 选中行(当前这一层里的行下标,已按排序换算);没有为 -1。 + public int SelectedIndex => Current is { } level && level.Selected >= 0 && level.Selected < level.Order.Length ? level.Order[level.Selected] : -1; + + /// 钻进去了几层(0 = 看的是查询结果本身)。 + public int Depth => Math.Max(0, _levels.Count - 1); + + /// 当前这一层从文档根算起的路径(顶层为空串)。 + public string CurrentPath => Current?.Path ?? ""; + + /// 当前这一层的列名(测试与无障碍用)。 + internal IReadOnlyList ColumnNames => Current?.Columns.Select(static c => c.Name).ToList() ?? []; + + /// 当前这一层第 列的宽度。 + internal double WidthOf(int column) + { + if (Current is not { } level) + { + return 0; + } + EnsureWidths(level); + return level.Widths[column]; + } + + /// 右键 / 双击 / Ctrl+C 某个单元格(冒泡,视图据此弹菜单或复制)。 + public static readonly RoutedEvent CellActivatedEvent = + RoutedEvent.Register(nameof(CellActivated), Avalonia.Interactivity.RoutingStrategies.Bubble); + + /// 右键 / 双击 / Ctrl+C 某个单元格。 + public event EventHandler? CellActivated + { + add => AddHandler(CellActivatedEvent, value); + remove => RemoveHandler(CellActivatedEvent, value); + } + + private QueryResultSet? Set => Source as QueryResultSet; + + private Level? Current => _levels.Count > 0 ? _levels[^1] : null; + + private double TopBand => (_levels.Count > 1 ? CrumbHeight : 0) + HeaderHeight; + + /// + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == SourceProperty) + { + _hover = -1; + _levels.Clear(); + if (Set is { } set) + { + _levels.Add(new Level("", "", -1, set.Documents, set.Columns, null)); + } + (_queryOrderColumn, _queryOrderDescending) = QueryOrder(Set); + } + } + + /// + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + _scroller = this.FindAncestorOfType(); + if (_scroller is not null) + { + _scroller.ScrollChanged += OnScrollChanged; + } + } + + /// + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + if (_scroller is not null) + { + _scroller.ScrollChanged -= OnScrollChanged; + } + _scroller = null; + base.OnDetachedFromVisualTree(e); + } + + private void OnScrollChanged(object? sender, ScrollChangedEventArgs e) => InvalidateVisual(); + + // ── 钻入 ──────────────────────────────────────────────────────────────── + + /// + /// 钻进当前层第 行(排序后的显示位置)的 列; + /// 不是对象 / 数组返回 false。 + /// + internal bool DrillInto(int row, int column) + { + if (Current is not { } level || Set is not { } set || row < 0 || row >= level.Order.Length || column < 0 || column >= level.Columns.Count) + { + return false; + } + int index = level.Order[row]; + ResultColumn target = level.Columns[column]; + BsonValue? value = level.ValueAt(index, target); + if (value is not (BsonDocument or BsonArray)) + { + return false; + } + int rootIndex = level.RootIndex >= 0 ? level.RootIndex : index; + string path = level.PathOf(index, target); + string label = target.IsElementValue ? $"[{index.ToString(CultureInfo.InvariantCulture)}]" : level.Path.Length == 0 || !level.IsArray + ? target.Name + : $"[{index.ToString(CultureInfo.InvariantCulture)}].{target.Name}"; + level.ScrollOffset = _scroller?.Offset ?? default; + _levels.Add(Level.Of(set.Loc, label, path, rootIndex, value)); + _hover = -1; + InvalidateMeasure(); + InvalidateVisual(); + if (_scroller is not null) + { + _scroller.Offset = default; + } + return true; + } + + /// 回上一层;已在顶层返回 false。 + internal bool DrillBack() => DrillTo(_levels.Count - 2); + + /// 回到第 层(0 = 查询结果本身)。 + internal bool DrillTo(int level) + { + if (level < 0 || level >= _levels.Count - 1) + { + return false; + } + _levels.RemoveRange(level + 1, _levels.Count - level - 1); + _hover = -1; + InvalidateMeasure(); + InvalidateVisual(); + Vector restore = _levels[^1].ScrollOffset; + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + if (_scroller is not null) + { + _scroller.Offset = restore; + } + }, Avalonia.Threading.DispatcherPriority.Loaded); + return true; + } + + // ── 度量 ──────────────────────────────────────────────────────────────── + + /// + protected override Size MeasureOverride(Size availableSize) + { + if (Current is not { } level) + { + return default; + } + EnsureWidths(level); + double width = NumberWidth + level.Widths.Sum(); + double height = TopBand + level.Documents.Count * RowHeight; + return new Size(double.IsInfinity(availableSize.Width) ? width : Math.Max(width, availableSize.Width), height); + } + + private void EnsureWidths(Level level) + { + if (level.Widths.Count == level.Columns.Count) + { + return; + } + level.Widths.Clear(); + for (int c = 0; c < level.Columns.Count; c++) + { + level.Widths.Add(Math.Clamp(ContentWidth(level, c, sample: 200, maxChars: 60), 72, 360)); + } + } + + /// 一列按内容要多宽:列头 + 前 行里最宽的那格(每格最多量 个字)。 + private double ContentWidth(Level level, int column, int sample, int maxChars) + { + Typeface mono = Mono(); + Typeface ui = Ui(); + ResultColumn col = level.Columns[column]; + double header = Text(col.Name, mono, 11, null, FontWeight.Medium).Width + 6 + Text(col.TypeName, mono, 9, null).Width + 17; + double content = 0; + int count = Math.Min(level.Documents.Count, sample); + for (int i = 0; i < count; i++) + { + BsonValue? value = level.ValueAt(i, col); + string text = BsonText.Cell(value); + double w = Text(text.Length > maxChars ? text[..maxChars] : text, value?.IsString == true ? ui : mono, 11, null).Width; + content = Math.Max(content, w); + } + return Math.Max(header, content) + CellPadding * 2 + 4; + } + + /// 双击列头分隔线:按这一列的内容(最多量前 5000 行)重设列宽。 + internal void AutoFit(int column) + { + if (Current is not { } level || column < 0 || column >= level.Columns.Count) + { + return; + } + EnsureWidths(level); + level.Widths[column] = Math.Clamp(Math.Ceiling(ContentWidth(level, column, sample: 5000, maxChars: 200)), 40, 1200); + InvalidateMeasure(); + InvalidateVisual(); + } + + // ── 绘制 ──────────────────────────────────────────────────────────────── + + /// + public override void Render(DrawingContext context) + { + if (Current is not { } level || Set is not { } set) + { + return; + } + EnsureWidths(level); + Vector offset = _scroller?.Offset ?? default; + Size viewport = _scroller?.Viewport ?? Bounds.Size; + double width = Bounds.Width; + double band = TopBand; + IBrush border = Brush("VelaBorderPrimary"); + var pen = new Pen(border, 1); + Typeface mono = Mono(); + Typeface ui = Ui(); + Typeface italic = new(mono.FontFamily, FontStyle.Italic); + + context.FillRectangle(Brush("VelaBgTerminal"), new Rect(offset.X, offset.Y, viewport.Width, viewport.Height)); + + int first = Math.Max(0, (int)(offset.Y / RowHeight) - 1); + int last = Math.Min(level.Order.Length - 1, (int)((offset.Y + viewport.Height) / RowHeight) + 1); + double right = offset.X + viewport.Width; + for (int r = first; r <= last; r++) + { + double y = band + r * RowHeight; + if (r == level.Selected) + { + context.FillRectangle(Brush("VelaBgActive"), new Rect(0, y, width, RowHeight)); + } + else if (r == _hover) + { + context.FillRectangle(Brush("VelaBgHover"), new Rect(0, y, width, RowHeight)); + } + int index = level.Order[r]; + // 行号列:顶层是 1 起的行号;子表里是数组下标(与路径 items.2 同一个数)。 + string numberText = level.IsArray ? index.ToString(CultureInfo.InvariantCulture) + : level.Path.Length > 0 ? "" : (r + 1).ToString(CultureInfo.InvariantCulture); + FormattedText number = Text(numberText, mono, 10, Brush("VelaTextMuted")); + context.DrawText(number, new Point(NumberWidth - CellPadding - number.Width, y + (RowHeight - number.Height) / 2)); + double x = NumberWidth; + for (int c = 0; c < level.Columns.Count; c++) + { + double w = level.Widths[c]; + if (x > right) + { + break; + } + if (x + w >= offset.X) + { + BsonValue? value = level.ValueAt(index, level.Columns[c]); + BsonKind kind = BsonKinds.Of(value); + string text = BsonText.Cell(value); + (Typeface face, IBrush brush) = kind switch + { + BsonKind.String => (ui, Brush("VelaTextPrimary")), + BsonKind.Null => (italic, Brush("VelaTextTertiary")), + BsonKind.Missing => (mono, Brush("VelaTextMuted")), + _ => (mono, Brush(BsonKinds.ColorToken(kind))) + }; + FormattedText cell = Text(text, face, 11, brush, maxWidth: w - CellPadding * 2); + double tx = BsonKinds.IsNumeric(kind) ? x + w - CellPadding - cell.Width : x + CellPadding; + context.DrawText(cell, new Point(tx, y + (RowHeight - cell.Height) / 2)); + } + x += w; + context.DrawLine(pen, new Point(x - 0.5, y), new Point(x - 0.5, y + RowHeight)); + } + context.DrawLine(pen, new Point(NumberWidth - 0.5, y), new Point(NumberWidth - 0.5, y + RowHeight)); + context.DrawLine(pen, new Point(0, y + RowHeight - 0.5), new Point(width, y + RowHeight - 0.5)); + } + + // 列头钉在视口顶上(最后画,盖住滚上去的行)。 + double top = offset.Y + band - HeaderHeight; + context.FillRectangle(Brush("VelaBgSurface"), new Rect(0, top, width, HeaderHeight)); + context.DrawLine(pen, new Point(0, top + HeaderHeight - 0.5), new Point(width, top + HeaderHeight - 0.5)); + context.DrawLine(pen, new Point(NumberWidth - 0.5, top), new Point(NumberWidth - 0.5, top + HeaderHeight)); + double hx = NumberWidth; + for (int c = 0; c < level.Columns.Count; c++) + { + ResultColumn column = level.Columns[c]; + double w = level.Widths[c]; + FormattedText name = Text(column.Name, mono, 11, Brush(column.IsElementValue ? "VelaTextTertiary" : "VelaTextSecondary"), FontWeight.Medium, + w - CellPadding * 2); + context.DrawText(name, new Point(hx + CellPadding, top + (HeaderHeight - name.Height) / 2)); + double after = hx + CellPadding + name.Width + 6; + FormattedText type = Text(column.TypeName, mono, 9, Brush("VelaTextMuted"), maxWidth: Math.Max(1, hx + w - after - 14)); + context.DrawText(type, new Point(after, top + (HeaderHeight - type.Height) / 2 + 1)); + bool? descending = c == level.SortColumn ? level.SortDescending + : level.SortColumn < 0 && _levels.Count == 1 && column.Name == _queryOrderColumn ? _queryOrderDescending : null; + if (descending is { } desc) + { + DrawIcon(context, desc ? "Mongo.arrow-down" : "Mongo.arrow-up", Math.Min(after + type.Width + 6, hx + w - 15), + top + (HeaderHeight - 11) / 2, 11, "VelaAccent"); + } + hx += w; + context.DrawLine(pen, new Point(hx - 0.5, top), new Point(hx - 0.5, top + HeaderHeight)); + } + + if (_levels.Count > 1) + { + DrawCrumbs(context, set.Loc, offset, viewport, pen); + } + } + + /// 面包屑:‹ #3 › items › [2] 数组 · 4 项(钉在列头上面,跟着水平滚动留在视口左缘)。 + private void DrawCrumbs(DrawingContext context, Loc loc, Vector offset, Size viewport, Pen pen) + { + _crumbHits.Clear(); + double top = offset.Y; + double left = offset.X; + context.FillRectangle(Brush("VelaBgSidebar"), new Rect(left, top, viewport.Width, CrumbHeight)); + context.DrawLine(pen, new Point(left, top + CrumbHeight - 0.5), new Point(left + viewport.Width, top + CrumbHeight - 0.5)); + Typeface mono = Mono(); + _backHit = new Rect(left + 4, top + 3, 22, 22); + DrawIcon(context, "Mongo.chevron-left", _backHit.X + 4, top + 7, 13, "VelaTextSecondary"); + double x = _backHit.Right + 6; + for (int i = 0; i < _levels.Count; i++) + { + bool isLast = i == _levels.Count - 1; + string label = i == 0 ? $"#{(_levels[1].RootIndex + 1).ToString(CultureInfo.InvariantCulture)}" : _levels[i].Label; + FormattedText text = Text(label, mono, 11, Brush(isLast ? "VelaTextPrimary" : "VelaFileDirName"), FontWeight.Medium); + context.DrawText(text, new Point(x, top + (CrumbHeight - text.Height) / 2)); + if (!isLast) + { + _crumbHits.Add((new Rect(x - 4, top + 3, text.Width + 8, 22), i)); + } + x += text.Width + 6; + if (!isLast) + { + DrawIcon(context, "Mongo.chevron-right", x, top + (CrumbHeight - 11) / 2, 11, "VelaTextMuted"); + x += 17; + } + } + string summary = _levels[^1].Summary(loc); + FormattedText tail = Text(summary, mono, 10, Brush("VelaTextTertiary")); + double tailX = left + viewport.Width - 10 - tail.Width; + if (tailX > x + 12) + { + context.DrawText(tail, new Point(tailX, top + (CrumbHeight - tail.Height) / 2)); + } + } + + private void DrawIcon(DrawingContext context, string key, double x, double y, double size, string token) + { + if (this.TryFindResource(key, out object? icon) && icon is Geometry geometry) + { + using (context.PushTransform(Matrix.CreateScale(size / 24d, size / 24d) * Matrix.CreateTranslation(x, y))) + { + context.DrawGeometry(null, new Pen(Brush(token), 2, lineCap: PenLineCap.Round, lineJoin: PenLineJoin.Round), geometry); + } + } + } + + // ── 交互 ──────────────────────────────────────────────────────────────── + + /// + protected override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(e); + Point point = e.GetPosition(this); + if (_resizing >= 0 && Current is { } level) + { + level.Widths[_resizing] = Math.Clamp(_resizeStartWidth + point.X - _resizeStartX, 40, 2000); + InvalidateMeasure(); + InvalidateVisual(); + return; + } + Cursor = GripAt(point) >= 0 ? new Cursor(StandardCursorType.SizeWestEast) + : InCrumbs(point) && (_backHit.Contains(point) || _crumbHits.Any(h => h.Bounds.Contains(point))) ? new Cursor(StandardCursorType.Hand) + : null; + int row = RowAt(point); + if (row != _hover) + { + _hover = row; + InvalidateVisual(); + } + } + + /// + protected override void OnPointerExited(PointerEventArgs e) + { + base.OnPointerExited(e); + if (_hover != -1) + { + _hover = -1; + InvalidateVisual(); + } + } + + /// + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + if (Current is not { } level || Set is null) + { + return; + } + Point point = e.GetPosition(this); + PointerPointProperties properties = e.GetCurrentPoint(this).Properties; + if (InCrumbs(point)) + { + if (properties.IsLeftButtonPressed) + { + if (_backHit.Contains(point)) + { + DrillBack(); + } + else if (_crumbHits.FirstOrDefault(h => h.Bounds.Contains(point)) is { Bounds.Width: > 0 } hit) + { + DrillTo(hit.Level); + } + } + e.Handled = true; + return; + } + double top = (_scroller?.Offset.Y ?? 0) + TopBand - HeaderHeight; + if (point.Y >= top && point.Y < top + HeaderHeight) + { + if (!properties.IsLeftButtonPressed) + { + return; + } + if (GripAt(point) is var grip and >= 0) + { + e.Handled = true; + if (e.ClickCount == 2) + { + _resizing = -1; + AutoFit(grip); + return; + } + EnsureWidths(level); + _resizing = grip; + _resizeStartX = point.X; + _resizeStartWidth = level.Widths[grip]; + e.Pointer.Capture(this); + return; + } + if (ColumnAt(point.X) is { } column) + { + Sort(level, column); + } + return; + } + int row = RowAt(point); + if (row < 0) + { + return; + } + level.Selected = row; + Focus(); + InvalidateVisual(); + int? col = ColumnAt(point.X); + if (properties.IsRightButtonPressed) + { + RaiseEvent(Activation(level, row, col, activated: false)); + e.Handled = true; + } + else if (e.ClickCount == 2) + { + // 双击对象 / 数组:钻进去;其余照旧(复制值)。 + if (col is { } c && DrillInto(row, c)) + { + e.Handled = true; + return; + } + RaiseEvent(Activation(level, row, col, activated: true)); + e.Handled = true; + } + } + + /// + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + if (_resizing >= 0) + { + _resizing = -1; + e.Pointer.Capture(null); + e.Handled = true; + } + } + + /// + protected override void OnPointerCaptureLost(PointerCaptureLostEventArgs e) + { + base.OnPointerCaptureLost(e); + _resizing = -1; + } + + /// + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + if (Current is not { } level) + { + return; + } + if (_levels.Count > 1 && (e.Key == Key.Back && e.KeyModifiers == KeyModifiers.None || e.Key == Key.Left && e.KeyModifiers == KeyModifiers.Alt)) + { + DrillBack(); + e.Handled = true; + return; + } + if (level.Order.Length == 0) + { + return; + } + int next = e.Key switch + { + Key.Down => Math.Min(level.Order.Length - 1, level.Selected + 1), + Key.Up => Math.Max(0, level.Selected - 1), + Key.Home => 0, + Key.End => level.Order.Length - 1, + _ => -2 + }; + if (next == -2) + { + if (e.Key == Key.C && e.KeyModifiers.HasFlag(KeyModifiers.Control) && level.Selected >= 0) + { + RaiseEvent(Activation(level, level.Selected, null, activated: true)); + e.Handled = true; + } + return; + } + level.Selected = next; + if (_scroller is { } scroller) + { + double band = TopBand; + double y = band + next * RowHeight; + if (y - band < scroller.Offset.Y + RowHeight) + { + scroller.Offset = scroller.Offset.WithY(Math.Max(0, y - band - RowHeight)); + } + else if (y + RowHeight > scroller.Offset.Y + scroller.Viewport.Height) + { + scroller.Offset = scroller.Offset.WithY(y + RowHeight - scroller.Viewport.Height); + } + } + InvalidateVisual(); + e.Handled = true; + } + + /// + /// 事件参数里带上真实的值、所属文档与绝对路径:钻进去之后,"第几行第几列"对外部已经没有意义。 + /// + private ResultCellEventArgs Activation(Level level, int row, int? column, bool activated) + { + int index = level.Order[row]; + int rootIndex = level.RootIndex >= 0 ? level.RootIndex : index; + BsonDocument root = Set!.Documents[rootIndex]; + ResultColumn? target = column is { } c ? level.Columns[c] : null; + string? path = target is null ? null : level.PathOf(index, target); + BsonValue? value = target is null ? null : level.ValueAt(index, target); + return new ResultCellEventArgs(CellActivatedEvent, rootIndex, path, activated) + { + Document = root, + Value = value, + GridRow = row, + GridColumn = column ?? -1 + }; + } + + private void Sort(Level level, int column) + { + level.SortDescending = level.SortColumn == column && !level.SortDescending; + level.SortColumn = column; + ResultColumn target = level.Columns[column]; + level.Order = + [ + .. (level.SortDescending + ? level.Order.OrderByDescending(i => level.ValueAt(i, target) ?? BsonMinKey.Value) + : level.Order.OrderBy(i => level.ValueAt(i, target) ?? BsonMinKey.Value)) + ]; + level.Selected = -1; + InvalidateVisual(); + } + + private bool InCrumbs(Point point) => _levels.Count > 1 && point.Y >= (_scroller?.Offset.Y ?? 0) && point.Y < (_scroller?.Offset.Y ?? 0) + CrumbHeight; + + private int RowAt(Point point) + { + double top = _scroller?.Offset.Y ?? 0; + if (Current is not { } level || point.Y < top + TopBand) + { + return -1; + } + int row = (int)((point.Y - TopBand) / RowHeight); + return row >= 0 && row < level.Order.Length ? row : -1; + } + + private int? ColumnAt(double x) + { + if (Current is not { } level) + { + return null; + } + double left = NumberWidth; + for (int c = 0; c < level.Widths.Count; c++) + { + if (x >= left && x < left + level.Widths[c]) + { + return c; + } + left += level.Widths[c]; + } + return null; + } + + /// 指针落在哪一列的右分隔线上(只认列头那一条带);不在任何分隔线上为 -1。 + private int GripAt(Point point) + { + if (Current is not { } level) + { + return -1; + } + double top = (_scroller?.Offset.Y ?? 0) + TopBand - HeaderHeight; + if (point.Y < top || point.Y >= top + HeaderHeight) + { + return -1; + } + double edge = NumberWidth; + for (int c = 0; c < level.Widths.Count; c++) + { + edge += level.Widths[c]; + if (Math.Abs(point.X - edge) <= GripHalf) + { + return c; + } + } + return -1; + } + + /// 查询本身的排序(find 的 sort、管道里最后一个 $sort):列头上画个箭头,不重排数据。 + private static (string? Column, bool Descending) QueryOrder(QueryResultSet? set) + { + BsonDocument? sort = set?.Result.Query?.Sort; + if (sort is null && set?.Result.Pipeline is { } pipeline) + { + sort = pipeline.OfType().LastOrDefault(static s => s.Contains("$sort"))?["$sort"] as BsonDocument; + } + if (sort is not { ElementCount: > 0 }) + { + return (null, false); + } + BsonElement first = sort.GetElement(0); + return (first.Name, first.Value.IsNumeric && first.Value.ToDouble() < 0); + } + + // ── 资源 ──────────────────────────────────────────────────────────────── + + private static IBrush Brush(string token) => ThemeBrushes.Get(token, Brushes.Gray); + + private Typeface Mono() => new(this.TryFindResource("VelaUiMonoFont", out object? f) && f is FontFamily family ? family : FontFamily.Default); + + private Typeface Ui() => new(this.TryFindResource("VelaUiFont", out object? f) && f is FontFamily family ? family : FontFamily.Default); + + private double FontSizeOf(double design) => + this.TryFindResource($"VelaFontSize{design.ToString(CultureInfo.InvariantCulture)}", out object? size) && size is double d ? d : design; + + private FormattedText Text(string text, Typeface face, double size, IBrush? brush, FontWeight weight = FontWeight.Normal, double maxWidth = double.NaN) + { + var typeface = weight == FontWeight.Normal ? face : new Typeface(face.FontFamily, face.Style, weight); + var formatted = new FormattedText(text, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, typeface, FontSizeOf(size), brush ?? Brushes.Gray); + if (!double.IsNaN(maxWidth) && maxWidth > 0) + { + formatted.MaxTextWidth = maxWidth; + formatted.MaxLineCount = 1; + formatted.Trimming = TextTrimming.CharacterEllipsis; + } + return formatted; + } + + /// + /// 网格的一层。顶层的行就是查询结果的文档;往下一层的"行"是某个数组的元素(或某个对象本身), + /// 标量元素放进一列「值」里 —— 只读网格不必像集合网格那样给每格算暂存身份,按值摊平就够了。 + /// + private sealed class Level + { + public Level(string label, string path, int rootIndex, IReadOnlyList documents, IReadOnlyList columns, BsonArray? array) + { + Label = label; + Path = path; + RootIndex = rootIndex; + Documents = documents; + Columns = columns; + Array = array; + Order = [.. Enumerable.Range(0, documents.Count)]; + } + + /// 面包屑上的字。 + public string Label { get; } + + /// 容器从文档根算起的路径;顶层为空串。 + public string Path { get; } + + /// 所属文档在查询结果里的下标;顶层为 -1(每行各是各的文档)。 + public int RootIndex { get; } + + /// 行。数组层里标量元素包成 { "值": x },文档元素原样。 + public IReadOnlyList Documents { get; } + + /// 列。 + public IReadOnlyList Columns { get; } + + /// 数组层的原数组;对象层与顶层为 。 + public BsonArray? Array { get; } + + /// 是数组层。 + public bool IsArray => Array is not null; + + public List Widths { get; } = []; + + public int[] Order { get; set; } + + public int Selected { get; set; } = -1; + + public int SortColumn { get; set; } = -1; + + public bool SortDescending { get; set; } + + public Vector ScrollOffset { get; set; } + + /// 第 行在 列的值。 + public BsonValue? ValueAt(int index, ResultColumn column) => + column.IsElementValue ? Array![index] is BsonDocument ? null : Array[index] : Documents[index].GetValue(column.Name, null); + + /// 第 行在 列的值从文档根算起的路径。 + public string PathOf(int index, ResultColumn column) + { + string row = IsArray ? Join(Path, index.ToString(CultureInfo.InvariantCulture)) : Path; + return column.IsElementValue ? row : Join(row, column.Name); + } + + public string Summary(Loc loc) => + loc.Format(IsArray ? "Cw_DrillArray" : "Cw_DrillObject", (IsArray ? Documents.Count : Columns.Count).ToString("N0", CultureInfo.InvariantCulture)); + + public static Level Of(Loc loc, string label, string path, int rootIndex, BsonValue container) + { + if (container is BsonArray array) + { + var documents = new List(array.Count); + bool scalars = array.Count == 0; + var scalarKinds = new Dictionary(); + foreach (BsonValue element in array) + { + if (element is BsonDocument document) + { + documents.Add(document); + } + else + { + scalars = true; + BsonKind kind = BsonKinds.Of(element); + scalarKinds[kind] = scalarKinds.GetValueOrDefault(kind) + 1; + documents.Add([]); + } + } + List columns = QueryResultSet.BuildColumns(documents); + if (scalars) + { + columns.Insert(0, new ResultColumn(loc["Cw_DrillValue"], CollectionTabViewModel.Dominant(scalarKinds)) { IsElementValue = true }); + } + return new Level(label, path, rootIndex, documents, columns, array); + } + var only = (BsonDocument)container; + return new Level(label, path, rootIndex, [only], QueryResultSet.BuildColumns([only]), null); + } + + private static string Join(string parent, string name) => parent.Length == 0 ? name : $"{parent}.{name}"; + } +} + +/// 网格单元格被激活(右键 = 菜单,双击 / Ctrl+C = 复制)。 +/// 路由事件。 +/// 所属文档在查询结果里的下标。 +/// 值从文档根算起的路径(顶层就是列名);行号列为 。 +/// 双击或 Ctrl+C(否则是右键)。 +public sealed class ResultCellEventArgs(RoutedEvent routedEvent, int documentIndex, string? column, bool activated) + : Avalonia.Interactivity.RoutedEventArgs(routedEvent) +{ + /// 所属文档在查询结果里的下标。 + public int DocumentIndex { get; } = documentIndex; + + /// 值从文档根算起的路径(顶层就是列名;钻进去后是 items.2.sku)。 + public string? Column { get; } = column; + + /// 双击或 Ctrl+C。 + public bool Activated { get; } = activated; + + /// 所属文档。 + public BsonDocument? Document { get; init; } + + /// 单元格的值;行号列或字段缺失为 。 + public BsonValue? Value { get; init; } + + /// 网格当前这一层里的显示行(给「展开查看」回调 用)。 + public int GridRow { get; init; } = -1; + + /// 网格当前这一层里的列下标;行号列为 -1。 + public int GridColumn { get; init; } = -1; +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Query/SaveQueryDialogView.axaml b/plugins/VelaShell.Plugin.Mongo/Ui/Query/SaveQueryDialogView.axaml new file mode 100644 index 0000000..21687aa --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Query/SaveQueryDialogView.axaml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Shell/ConnectionStateTabView.axaml.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Shell/ConnectionStateTabView.axaml.cs new file mode 100644 index 0000000..ac5521f --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Shell/ConnectionStateTabView.axaml.cs @@ -0,0 +1,21 @@ +using Avalonia.Controls; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 连接中 / 连接失败的占位标签(设计稿 22)。 +public sealed partial class ConnectionStateTabView : UserControl +{ + /// 用给定的视图模型初始化。 + /// 视图模型。 + internal ConnectionStateTabView(ConnectionStateTabViewModel viewModel) + { + InitializeComponent(); + DataContext = viewModel; + } + + /// 设计器用的无参构造。 + public ConnectionStateTabView() + { + InitializeComponent(); + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Shell/ConnectionStateTabViewModel.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Shell/ConnectionStateTabViewModel.cs new file mode 100644 index 0000000..db8efb2 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Shell/ConnectionStateTabViewModel.cs @@ -0,0 +1,131 @@ +using System.ComponentModel; +using System.Globalization; +using VelaShell.Plugin.Mongo.Core; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 连接中 / 连接失败的占位标签(设计稿 22「连接状态」):照宿主「先建标签、后连会话」的习惯, +/// 在标签里显示卡片,不弹模态框。连上了外壳就把它换成对象列表;没连上就留着,给「编辑连接 / 关闭标签页 / 重新连接」。 +/// +internal sealed class ConnectionStateTabViewModel : WorkspaceTab +{ + private readonly MongoWorkspaceViewModel _shell; + private string _jumpName = ""; + + /// 构造。 + /// 工作台外壳。 + /// 那条连接。 + public ConnectionStateTabViewModel(MongoWorkspaceViewModel shell, ConnectionEntry entry) + : base(shell) + { + _shell = shell; + Entry = entry; + Title = entry.Name; + entry.PropertyChanged += OnEntryChanged; + CancelCommand = new RelayCommand(() => entry.Connecting?.Cancel()); + EditCommand = new RelayCommand(() => shell.EditConnection(entry)); + ReconnectCommand = new AsyncCommand(() => shell.ConnectAsync(entry)); + TrustCommand = new AsyncCommand(() => shell.TrustAndReconnectAsync(entry)); + _ = ResolveJumpNameAsync(); + } + + /// 那条连接。 + public ConnectionEntry Entry { get; } + + /// + public override TabKind Kind => TabKind.Connection; + + /// + public override string Key => $"conn:{Entry.Profile.Id}"; + + /// + public override string IconKey => "Mongo.leaf"; + + /// + public override string IconToken => IsFailed ? "VelaError" : "VelaAccent"; + + /// 正在连(「正在连接」卡片)。 + public bool IsConnecting => Entry.State == ConnectionState.Connecting; + + /// 没连上(「无法连接」卡片)。 + public bool IsFailed => Entry.State == ConnectionState.Failed; + + /// 「正在连接 mongo-inner-01」。 + public string ConnectingTitle => Loc.Format("Conn_Connecting", Entry.Name); + + /// 「无法连接 mongo-inner-01」。 + public string FailedTitle => Loc.Format("Conn_FailedTitle", Entry.Name); + + /// + /// 卡片副标题:连接中写「MongoDB · 经 SSH 跳板 bastion-ops」,失败写全(MongoDB · 10.20.3.21:27017 · 经 bastion-ops)—— + /// 失败时用户要知道到底连的是哪儿。 + /// + public string Detail => IsConnecting + ? _jumpName.Length > 0 ? $"MongoDB · {Loc.Format("Conn_ViaJump", _jumpName)}" : Entry.Detail + : _jumpName.Length > 0 ? $"{Entry.Detail} · {Loc.Format("Conn_ViaJump", _jumpName)}" : Entry.Detail; + + /// 失败原因(等宽字的错误框里)。 + public string ErrorMessage => Entry.Failure?.Message ?? ""; + + /// 证书不受信任且拿到了证书本体:给「信任此证书并重连」。 + public bool CanTrust => Entry.Failure?.Certificate is not null; + + /// 那张证书的一行说明。 + public string CertificateText => Entry.Failure?.Certificate is { } c + ? string.Format(CultureInfo.CurrentCulture, Loc["Conn_CertLine"], c.Subject, c.Issuer, c.NotAfter, c.Thumbprint) + : ""; + + /// 取消正在进行的连接。 + public RelayCommand CancelCommand { get; } + + /// 编辑连接(设计稿 10 的对话框)。 + public RelayCommand EditCommand { get; } + + /// 重新连接。 + public AsyncCommand ReconnectCommand { get; } + + /// 信任此证书并重连。 + public AsyncCommand TrustCommand { get; } + + /// 跳板的名字要问宿主(已保存的 SSH 连接);问不到就不写。 + private async Task ResolveJumpNameAsync() + { + string id = Entry.Profile.Parsed.JumpSessionId; + if (id.Length == 0 || Entry.Profile.Parsed.Topology == MongoTopology.Srv) + { + return; + } + try + { + IReadOnlyList saved = + await _shell.Context.Sessions.ListSavedAsync().ConfigureAwait(true); + _jumpName = saved.FirstOrDefault(s => s.SavedSessionId == id)?.Name ?? ""; + RaisePropertyChanged(nameof(Detail)); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + // 列不出来(宿主不支持、权限)只是少一个名字。 + } + } + + private void OnEntryChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName is nameof(ConnectionEntry.State) or nameof(ConnectionEntry.Failure) or nameof(ConnectionEntry.Profile)) + { + Title = Entry.Name; + RaisePropertiesChanged(nameof(IsConnecting), nameof(IsFailed), nameof(ConnectingTitle), nameof(FailedTitle), + nameof(Detail), nameof(ErrorMessage), nameof(CanTrust), nameof(CertificateText), nameof(IconToken)); + } + } + + /// 关掉标签时还在连就一并取消 —— 用户不想等了。 + public override void Dispose() + { + Entry.PropertyChanged -= OnEntryChanged; + if (Entry.State == ConnectionState.Connecting) + { + Entry.Connecting?.Cancel(); + } + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Shell/DialogViewModel.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Shell/DialogViewModel.cs new file mode 100644 index 0000000..72fc21b --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Shell/DialogViewModel.cs @@ -0,0 +1,121 @@ +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 覆盖层上的对话框(编辑文档、新建集合、导入 / 导出 / 数据传输向导、确认框)。 +/// +/// 外壳画统一的外框:28px 标题栏(15px 图标 + 标题 + 等宽副标题 + 右上角 40×27 关闭方块, +/// 与宿主 DESIGN.md §4.2 的对话框标题栏同规格),内容区与底栏由派生类的视图自己画。 +/// +/// +internal abstract class DialogViewModel : ObservableObject +{ + private string _title = ""; + private string _subtitle = ""; + private readonly IMongoWorkspace? _workspace; + + /// 构造一条连接里的对话框。 + /// 这条连接的服务。 + protected DialogViewModel(IMongoWorkspace workspace) + : this((IWorkbench)workspace) + { + _workspace = workspace; + } + + /// 构造外壳自己的对话框(新建 / 编辑连接、确认框):不属于任何一条连接。 + /// 外壳服务。 + protected DialogViewModel(IWorkbench workbench) + { + Workbench = workbench; + CloseCommand = new RelayCommand(Close); + RequestCloseCommand = new AsyncCommand(RequestCloseAsync); + } + + /// 这条连接的服务。外壳自己的对话框没有连接,读它是编程错误。 + public IMongoWorkspace Workspace => + _workspace ?? throw new InvalidOperationException(GetType().Name + " does not belong to a connection."); + + /// 外壳服务。 + public IWorkbench Workbench { get; } + + /// 它属于哪条连接;外壳自己的对话框为 (断开连接时据此把它一并关掉)。 + public IMongoWorkspace? Owner => _workspace; + + /// 文案表。 + public Loc Loc => Workbench.Loc; + + /// 标题。 + public string Title + { + get => _title; + set => SetProperty(ref _title, value); + } + + /// 标题旁的等宽小字(shop.orders)。 + public string Subtitle + { + get => _subtitle; + set => SetProperty(ref _subtitle, value); + } + + /// 标题栏图标。 + public virtual string IconKey => "Mongo.leaf"; + + /// 标题栏图标颜色令牌。 + public virtual string IconToken => "VelaAccent"; + + /// 对话框宽度。 + public virtual double Width => 640; + + /// 对话框高度; = 按内容。 + public virtual double Height => double.NaN; + + /// 能不能不问就关(有未保存修改、向导执行中时为假 —— × 与 Esc 会先确认)。 + public virtual bool CanCloseWithEscape => true; + + /// 关闭命令(直接关,不问 —— 保存成功、取消按钮这类"已经决定了"的路径用)。 + public RelayCommand CloseCommand { get; } + + /// + /// "想关"命令:标题栏的 × 与 Esc 走它。先过 , + /// 有未保存的修改或正在执行时问一句,而不是一下子把用户填了半天的东西丢掉。 + /// + public AsyncCommand RequestCloseCommand { get; } + + /// 按 × / Esc 时调用。 + public async Task RequestCloseAsync() + { + if (await ConfirmCloseAsync().ConfigureAwait(true)) + { + Close(); + } + } + + /// + /// 关闭前的确认。默认: 为真就直接放行,否则弹一次"放弃并关闭?"。 + /// 派生类可以给出更具体的问法(或直接拒绝)。 + /// + protected virtual async Task ConfirmCloseAsync() + { + if (CanCloseWithEscape) + { + return true; + } + return await Workbench.ConfirmAsync(new() + { + Title = Loc["Dialog_DiscardTitle"], + Message = Loc.Format("Dialog_DiscardBody", Title), + ConfirmLabel = Loc["Dialog_DiscardConfirm"], + IconKey = "Mongo.triangle-alert", + Danger = true + }).ConfigureAwait(true); + } + + /// 关掉了(确认框靠它回填结果)。 + public event Action? Closed; + + /// 关闭。 + public void Close() => Workbench.CloseDialog(this); + + /// 外壳关掉它之后调用。 + internal virtual void OnClosed() => Closed?.Invoke(); +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Shell/Feedback.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Shell/Feedback.cs new file mode 100644 index 0000000..4bd85da --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Shell/Feedback.cs @@ -0,0 +1,261 @@ +using Avalonia.Threading; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 确认框里列出的一条事实(文档 214,006)。 +/// 标签。 +/// 值。 +internal sealed record ConfirmFact(string Label, string Value); + +/// +/// 一次确认请求(设计稿 22「保护与确认」:危险操作一律描边红按钮 + 明确后果;生产连接要手打名称)。 +/// +internal sealed record ConfirmRequest +{ + /// 标题(删除集合)。 + public required string Title { get; init; } + + /// 说明(写清后果:"将永久删除 … 及其 3 个索引。此操作无法撤销。")。 + public required string Message { get; init; } + + /// 确认按钮文字(删除集合)。 + public required string ConfirmLabel { get; init; } + + /// 标题栏图标。 + public string IconKey { get; init; } = "Mongo.trash-2"; + + /// 危险操作(确认按钮为红色描边)。 + public bool Danger { get; init; } = true; + + /// 要求手打的名称;为空即不要求。 + public string? TypeToConfirm { get; init; } + + /// 事实清单。 + public IReadOnlyList Facts { get; init; } = []; + + /// 底部的一条辅助链接文字(先用 mongodump 备份…)。 + public string? AsideLabel { get; init; } + + /// 辅助链接的动作。 + public Action? AsideAction { get; init; } +} + +/// 确认框的视图模型。 +internal sealed class ConfirmDialogViewModel : DialogViewModel +{ + private readonly TaskCompletionSource _result = new(TaskCreationOptions.RunContinuationsAsynchronously); + private string _typed = ""; + + /// 构造。 + /// 外壳服务。 + /// 请求。 + public ConfirmDialogViewModel(IWorkbench workspace, ConfirmRequest request) + : base(workspace) + { + Request = request; + Title = request.Title; + ConfirmCommand = new RelayCommand(Confirm, () => CanConfirm); + AsideCommand = new RelayCommand(() => + { + Close(); + request.AsideAction?.Invoke(); + }); + } + + /// 请求。 + public ConfirmRequest Request { get; } + + /// + public override string IconKey => Request.IconKey; + + /// + public override string IconToken => Request.Danger ? "VelaError" : "VelaAccent"; + + /// + public override double Width => 400; + + /// 要不要手打名称。 + public bool RequiresTyping => !string.IsNullOrEmpty(Request.TypeToConfirm); + + /// 手打的内容。 + public string Typed + { + get => _typed; + set + { + if (SetProperty(ref _typed, value)) + { + RaisePropertyChanged(nameof(CanConfirm)); + ConfirmCommand.RaiseCanExecuteChanged(); + } + } + } + + /// 能不能点确认。 + public bool CanConfirm => !RequiresTyping || string.Equals(Typed.Trim(), Request.TypeToConfirm, StringComparison.Ordinal); + + /// 确认。 + public RelayCommand ConfirmCommand { get; } + + /// 辅助链接。 + public RelayCommand AsideCommand { get; } + + /// 结果。 + public Task Result => _result.Task; + + private void Confirm() + { + _result.TrySetResult(true); + Close(); + } + + /// + internal override void OnClosed() + { + _result.TrySetResult(false); + base.OnClosed(); + } +} + +/// 提示的语气。 +internal enum ToastKind +{ + /// 普通。 + Info, + + /// 成功(绿勾)。 + Success, + + /// 警告。 + Warning, + + /// 错误。 + Error +} + +/// 一条右下角提示。 +internal sealed record ToastRequest +{ + /// 主文字(已提交 3 处修改)。 + public required string Title { get; init; } + + /// 副文字(1 更新 · 1 插入 · 1 删除 · 12 ms)。 + public string? Detail { get; init; } + + /// 语气。 + public ToastKind Kind { get; init; } = ToastKind.Info; + + /// 动作按钮文字(撤销);没有为 。 + public string? ActionLabel { get; init; } + + /// 动作。 + public Func? Action { get; init; } + + /// 动作按钮的图标(默认「撤销」的回退箭头;「在资源管理器中显示」之类换成别的)。 + public string ActionIconKey { get; init; } = "Mongo.undo-2"; + + /// 动作按钮上要不要带倒计时(撤销要,"查看""打开"这种不必)。 + public bool ShowCountdown { get; init; } = true; + + /// 停留多久;带动作的提示按秒倒数显示在按钮上(撤销 (9))。 + public TimeSpan Duration { get; init; } = TimeSpan.FromSeconds(4); +} + +/// 右下角提示的视图模型(带倒计时)。 +internal sealed class ToastViewModel : ObservableObject +{ + private readonly Action _dismiss; + private readonly DispatcherTimer _timer; + private int _remaining; + + /// 构造并开始倒计时。 + public ToastViewModel(ToastRequest request, Action dismiss) + { + Request = request; + _dismiss = dismiss; + _remaining = Math.Max(1, (int)Math.Round(request.Duration.TotalSeconds)); + ActionCommand = new AsyncCommand(async () => + { + Dismiss(); + if (request.Action is { } action) + { + await action().ConfigureAwait(true); + } + }); + DismissCommand = new RelayCommand(Dismiss); + _timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) }; + _timer.Tick += (_, _) => + { + Remaining--; + if (_remaining <= 0) + { + Dismiss(); + } + }; + _timer.Start(); + } + + /// 请求。 + public ToastRequest Request { get; } + + /// 主文字。 + public string Title => Request.Title; + + /// 副文字。 + public string? Detail => Request.Detail; + + /// 有没有副文字。 + public bool HasDetail => !string.IsNullOrEmpty(Request.Detail); + + /// 有没有动作。 + public bool HasAction => Request.ActionLabel is not null && Request.Action is not null; + + /// 图标。 + public string IconKey => Request.Kind switch + { + ToastKind.Success => "Mongo.circle-check", + ToastKind.Warning => "Mongo.lock", + ToastKind.Error => "Mongo.circle-x", + _ => "Mongo.info" + }; + + /// 图标颜色令牌。 + public string IconToken => Request.Kind switch + { + ToastKind.Success => "VelaStatusConnected", + ToastKind.Warning => "VelaWarning", + ToastKind.Error => "VelaError", + _ => "VelaInfo" + }; + + /// 剩余秒数。 + public int Remaining + { + get => _remaining; + private set + { + if (SetProperty(ref _remaining, value)) + { + RaisePropertyChanged(nameof(ActionText)); + } + } + } + + /// 动作按钮上的字(撤销 (9))。 + public string ActionText => Request.ShowCountdown ? $"{Request.ActionLabel} ({_remaining})" : Request.ActionLabel ?? ""; + + /// 动作按钮的图标。 + public string ActionIconKey => Request.ActionIconKey; + + /// 动作命令。 + public AsyncCommand ActionCommand { get; } + + /// 关掉。 + public RelayCommand DismissCommand { get; } + + private void Dismiss() + { + _timer.Stop(); + _dismiss(this); + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Shell/IMongoWorkspace.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Shell/IMongoWorkspace.cs new file mode 100644 index 0000000..2155d48 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Shell/IMongoWorkspace.cs @@ -0,0 +1,182 @@ +using MongoDB.Bson; +using VelaShell.Plugin.Mongo.Core; +using VelaShell.PluginSdk.Logging; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 集合设计页的子页(设计稿 07 / 08 / 16 顶部那一排)。 +public enum DesignPage +{ + /// 索引。 + Indexes, + + /// Schema 分析。 + Schema, + + /// 验证规则。 + Validation, + + /// 选项(TTL、排序规则)。 + Options, + + /// 统计。 + Stats +} + +/// 对象列表的过滤芯片(设计稿 12)。 +public enum ObjectFilter +{ + /// 全部。 + All, + + /// 集合。 + Collections, + + /// 视图。 + Views, + + /// 时序集合。 + TimeSeries, + + /// GridFS 桶。 + GridFs +} + +/// 文件选择框的一类文件。 +/// 显示名(JSON)。 +/// 通配(*.json)。 +public sealed record FileKind(string Name, params string[] Patterns) +{ + /// JSON / JSONL。 + public static FileKind Json { get; } = new("JSON", "*.json", "*.jsonl", "*.ndjson"); + + /// CSV。 + public static FileKind Csv { get; } = new("CSV", "*.csv", "*.tsv"); + + /// Excel。 + public static FileKind Excel { get; } = new("Excel", "*.xlsx"); + + /// BSON。 + public static FileKind Bson { get; } = new("BSON", "*.bson"); + + /// 脚本。 + public static FileKind Script { get; } = new("JavaScript", "*.js"); + + /// 任意文件。 + public static FileKind Any { get; } = new("*", "*"); +} + +/// +/// 工作台外壳本身的服务:覆盖层、确认框、提示、剪贴板与文件选择。不属于任何一条连接 —— +/// 新建连接的对话框、连接中 / 连接失败的占位标签只认得它。 +/// +internal interface IWorkbench +{ + /// 文案表。 + Loc Loc { get; } + + /// 持久化(历史、收藏、保存的查询与配置)。 + MongoStore Store { get; } + + /// 日志。 + IPluginLogger Log { get; } + + /// 挂一个对话框到覆盖层。 + void ShowDialog(DialogViewModel dialog); + + /// 关掉一个对话框。 + void CloseDialog(DialogViewModel dialog); + + /// 确认框(危险操作一律红色描边按钮 + 明确后果;生产连接要手打名称)。 + Task ConfirmAsync(ConfirmRequest request); + + /// 右下角的提示(可带一个动作,如「撤销」)。 + void Toast(ToastRequest toast); + + /// 复制到剪贴板。 + Task CopyAsync(string text); + + /// 选一个保存路径。 + Task PickSaveFileAsync(string title, string suggestedName, IReadOnlyList kinds); + + /// 选一个或多个要打开的文件。 + Task> PickOpenFilesAsync(string title, IReadOnlyList kinds, bool multiple = false); + + /// 选一个文件夹。 + Task PickFolderAsync(string title); + + /// 当前标签页的状态行变了(由标签页调用,外壳转给状态显示)。 + void NotifyStatusChanged(); +} + +/// +/// 一条连接交给各对象标签页与对话框的服务(工作台外壳的服务也经它转达)。 +/// +/// 对象树的根上可以挂好几条连接,每条连着的连接各有一个(); +/// 标签页与对话框拿到的就是自己那条连接的这个对象,不会拿错库。 +/// +/// +/// 标签页之间**不互相引用**:网格要"在新查询中打开",不是去找查询编辑器的实例, +/// 而是调 —— 外壳决定是开新标签还是复用。对话框同理, +/// 一律经 挂到外壳的覆盖层上。 +/// +/// +internal interface IMongoWorkspace : IWorkbench +{ + /// 连接。 + MongoConnection Connection { get; } + + /// 持久化键里用的连接标识(已保存连接的 id,不含凭据)。 + string ConnectionKey { get; } + + /// 连接名(用户给这条连接起的名字,mongo-inner-01)。 + string ConnectionName { get; } + + /// 写护栏(只读开关、写前确认、权限)。 + WriteGuard Guard { get; } + + /// + /// 写之前的统一检查:只读模式或没有写权限时弹提示并返回 。 + /// 每一条写路径都必须先过它 —— 只读是客户端护栏,漏一处就是一处"只读模式下照样写进去了"。 + /// + bool EnsureWritable(string database); + + /// 当前选中的库 / 集合(工具栏大按钮作用的对象)。 + (string? Database, string? Collection) Scope { get; } + + /// 对象树里已知的库名。 + IReadOnlyList Databases { get; } + + /// 对象树里已知的某个库的集合(没展开过的库为空)。 + IReadOnlyList CollectionsOf(string database); + + /// 打开集合工作台(网格 / 树 / JSON)。 + void OpenCollection(string database, string collection, string? filter = null); + + /// 打开一个新查询标签。 + void OpenQuery(string database, string? text = null, bool run = false); + + /// 打开聚合管道构建器。 + void OpenPipeline(string database, string collection, BsonArray? pipeline = null); + + /// 打开集合设计。 + void OpenDesign(string database, string collection, DesignPage page = DesignPage.Indexes); + + /// 打开 GridFS 桶。 + void OpenGridFs(string database, string bucket); + + /// 打开对象列表。 + void OpenObjects(string database, ObjectFilter filter = ObjectFilter.All); + + /// 打开服务器监控。 + void OpenMonitor(); + + /// 打开慢查询与当前操作。 + void OpenProfiler(string database); + + /// 打开用户与角色。 + void OpenUsers(string? database, bool roles = false); + + /// 重载对象树(建删集合、改名之后)。 + Task RefreshTreeAsync(string? database = null); +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Shell/MongoSession.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Shell/MongoSession.cs new file mode 100644 index 0000000..8f8f306 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Shell/MongoSession.cs @@ -0,0 +1,638 @@ +using System.Collections.Concurrent; +using Avalonia.Threading; +using MongoDB.Bson; +using MongoDB.Driver; +using VelaShell.Plugin.Mongo.Bson; +using VelaShell.Plugin.Mongo.Core; +using VelaShell.PluginSdk.Logging; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 一条连着的连接:驱动连接、写护栏,以及对象树上它那一支(库 → 集合 / 视图 / 桶 / 用户)。 +/// 标签页与对话框拿到的 就是它 —— 根上挂着好几条连接时, +/// 每个标签都清楚自己连的是哪一条;外壳的覆盖层、提示与剪贴板经它转给 。 +/// +internal sealed class MongoSession : IMongoWorkspace, IDisposable +{ + private readonly ConcurrentDictionary> _collections = new(StringComparer.Ordinal); + private readonly Dictionary _statsCache = new(StringComparer.Ordinal); + private readonly List _databaseNodes = []; + private readonly IDisposable _registration; + private IReadOnlyList _databases = []; + private bool _showSystemDatabases; + private bool _isAvailable = true; + private int? _latencyMs; + private int _queryCounter; + + /// 构造(连上之后)。 + /// 工作台外壳。 + /// 对象树上的那条连接。 + /// 连上的连接(连同跳板转发)。 + public MongoSession(MongoWorkspaceViewModel shell, ConnectionEntry entry, MongoLink link) + { + Shell = shell; + Entry = entry; + Link = link; + Guard = new(Connection.Settings, Connection.Privileges); + Guard.Changed += OnGuardChanged; + _latencyMs = Connection.LatencyMs; + _showSystemDatabases = Connection.Settings.ShowSystemDatabases; + Connection.Availability += OnAvailability; + Connection.LatencyChanged += OnLatency; + _registration = MongoSessionRegistry.Shared.Add(new(entry.Name, Connection)); + } + + /// 工作台外壳。 + public MongoWorkspaceViewModel Shell { get; } + + /// 对象树上的那条连接。 + public ConnectionEntry Entry { get; } + + /// 连上的连接(连同跳板转发)。 + public MongoLink Link { get; } + + /// + public MongoConnection Connection => Link.Connection; + + /// + public Loc Loc => Shell.Loc; + + /// + public MongoStore Store => Shell.Store; + + /// + public IPluginLogger Log => Shell.Log; + + /// + public string ConnectionKey => Entry.Profile.Id; + + /// + public string ConnectionName => Entry.Name; + + /// + public WriteGuard Guard { get; } + + /// 对象树上这条连接的根行。 + public TreeNode Root => Entry.Root; + + /// 服务器还在不在(驱动的心跳)。 + public bool IsAvailable => _isAvailable; + + /// 最近一次往返的延迟。 + public int? LatencyMs => _latencyMs; + + /// 新查询标签的编号(每条连接各数各的)。 + internal int NextQueryNumber() => ++_queryCounter; + + // ── 对象树:这条连接的一支 ──────────────────────────────────────────── + + /// 对象树是否列出 admin / config / local(只影响显示;连接里点名的默认库照列)。 + public bool ShowSystemDatabases + { + get => _showSystemDatabases; + set + { + if (_showSystemDatabases == value) + { + return; + } + _showSystemDatabases = value; + ApplyDatabaseFilter(); + } + } + + /// + public IReadOnlyList Databases => [.. _databases.Where(d => IsListed(d.Name)).Select(static d => d.Name)]; + + private bool IsListed(string database) => + _showSystemDatabases || !DatabaseInfo.IsSystemName(database) || database == Connection.Settings.Database; + + /// + public IReadOnlyList CollectionsOf(string database) => + _collections.TryGetValue(database, out IReadOnlyList? list) ? list : []; + + /// 连接里配了默认库就用它;没配就是第一个业务库。 + internal string? DefaultDatabase() => + Connection.Settings.Database is { Length: > 0 } configured + ? configured + : _databases.FirstOrDefault(static d => !d.IsSystem)?.Name ?? _databases.FirstOrDefault()?.Name; + + internal CollectionInfo FindCollection(string database, string collection) => + CollectionsOf(database).FirstOrDefault(c => c.Name == collection) + ?? new CollectionInfo(database, collection, CollectionKind.Collection, []); + + /// 扔掉集合统计的缓存(建删、清空、刷新之后)。 + internal void ClearStats() => _statsCache.Clear(); + + /// 重读库列表;之前展开着的库照样展开(第一次连上时展开默认库)。 + /// 任务。 + public async Task ReloadTreeAsync() + { + HashSet expanded = [.. _databaseNodes.Where(static n => n.IsExpanded).Select(static n => n.Name)]; + try + { + _databases = await Connection.ListDatabasesAsync().ConfigureAwait(true); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Toast(new() { Title = Loc.Format("Common_Failed", MongoConnector.Describe(ex)), Kind = ToastKind.Error }); + return; + } + Root.Meta = Connection.Server.Version; + _databaseNodes.Clear(); + _collections.Clear(); + string? defaultDb = DefaultDatabase(); + foreach (DatabaseInfo db in _databases) + { + _databaseNodes.Add(new TreeNode(NodeKind.Database, db.Name, 1, Root) + { + Database = db.Name, + Meta = db.SizeOnDisk > 0 ? BsonText.Bytes(db.SizeOnDisk) : "" + }); + } + ListDatabaseNodes(); + Root.IsExpanded = true; + Shell.RebuildVisible(); + foreach (TreeNode node in Root.Children.ToList()) + { + if (expanded.Contains(node.Name) || (expanded.Count == 0 && node.Name == defaultDb)) + { + await ExpandDatabaseAsync(node).ConfigureAwait(true); + } + } + } + + private void ListDatabaseNodes() + { + Root.Children.Clear(); + foreach (TreeNode node in _databaseNodes.Where(n => IsListed(n.Name))) + { + Root.Children.Add(node); + } + } + + private void ApplyDatabaseFilter() + { + ListDatabaseNodes(); + // 选中的行跟着它的库一起被藏起来了:选中退回连接那一行,底部信息区不再讲一个看不见的东西。 + if (Shell.SelectedNode is { Database.Length: > 0 } selected && selected.Owner == Entry && !IsListed(selected.Database)) + { + Shell.SelectedNode = Root; + } + Shell.RebuildVisible(); + } + + /// + public async Task RefreshTreeAsync(string? database = null) + { + await RefreshTreeCoreAsync(database).ConfigureAwait(true); + // 建、删、清空、导入之后都会走到这里:顺带告诉开着的对象列表重载, + // 而不是让每个写路径各自记得去通知它。 + foreach (string db in database is null ? Databases : [database]) + { + ObjectsChanged.Raise(this, db); + } + } + + private async Task RefreshTreeCoreAsync(string? database) + { + if (database is null || _databaseNodes.FirstOrDefault(n => n.Name == database) is not { } node) + { + await ReloadTreeAsync().ConfigureAwait(true); + return; + } + node.IsLoaded = false; + bool wasExpanded = node.IsExpanded; + node.IsExpanded = false; + if (wasExpanded) + { + await ExpandDatabaseAsync(node).ConfigureAwait(true); + } + else + { + await LoadDatabaseAsync(node).ConfigureAwait(true); + } + } + + /// 展开一个库(第一次展开时才去读它的集合)。 + /// 库那一行。 + /// 任务。 + internal async Task ExpandDatabaseAsync(TreeNode node) + { + node.IsExpanded = true; + if (node.Kind == NodeKind.Database && !node.IsLoaded) + { + node.Children.Clear(); + node.Children.Add(new TreeNode(NodeKind.Placeholder, Loc["Common_Loading"], node.Depth + 1, node)); + Shell.RebuildVisible(); + await LoadDatabaseAsync(node).ConfigureAwait(true); + } + Shell.RebuildVisible(); + } + + private async Task LoadDatabaseAsync(TreeNode dbNode) + { + string db = dbNode.Name; + IReadOnlyList all; + try + { + all = await Connection.ListCollectionsAsync(db).ConfigureAwait(true); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + dbNode.Children.Clear(); + dbNode.Children.Add(new TreeNode(NodeKind.Placeholder, MongoConnector.Describe(ex), dbNode.Depth + 1, dbNode)); + Shell.RebuildVisible(); + return; + } + _collections[db] = all; + IReadOnlyList buckets = MongoConnection.FindBuckets(db, all); + HashSet bucketCollections = [.. buckets.SelectMany(static b => new[] { b.FilesCollection, b.ChunksCollection })]; + List collections = + [ + .. all.Where(c => c.Kind is not (CollectionKind.View or CollectionKind.System) && !bucketCollections.Contains(c.Name)) + ]; + List views = [.. all.Where(static c => c.Kind == CollectionKind.View)]; + bool hasSystemJs = all.Any(static c => c.Name == "system.js"); + + int depth = dbNode.Depth + 1; + dbNode.Children.Clear(); + TreeNode collectionsFolder = Folder(dbNode, FolderKind.Collections, Loc["Tree_Collections"], collections.Count, depth); + foreach (CollectionInfo info in collections) + { + var node = new TreeNode(NodeKind.Collection, info.Name, depth + 1, collectionsFolder) { Database = db, Collection = info }; + switch (info.Kind) + { + case CollectionKind.TimeSeries: + node.Tag = Loc["Tag_TimeSeries"]; + node.TagClass = "info"; + break; + case CollectionKind.Capped: + node.Tag = Loc["Tag_Capped"]; + node.TagClass = "muted"; + break; + case CollectionKind.Clustered: + node.Tag = Loc["Tag_Clustered"]; + node.TagClass = "muted"; + break; + } + collectionsFolder.Children.Add(node); + } + TreeNode viewsFolder = Folder(dbNode, FolderKind.Views, Loc["Tree_Views"], views.Count, depth); + foreach (CollectionInfo view in views) + { + viewsFolder.Children.Add(new TreeNode(NodeKind.View, view.Name, depth + 1, viewsFolder) { Database = db, Collection = view }); + } + TreeNode bucketsFolder = Folder(dbNode, FolderKind.Buckets, Loc["Tree_Buckets"], buckets.Count, depth); + foreach (GridFsBucketInfo bucket in buckets) + { + bucketsFolder.Children.Add(new TreeNode(NodeKind.Bucket, bucket.Name, depth + 1, bucketsFolder) { Database = db, Bucket = bucket }); + } + TreeNode usersFolder = Folder(dbNode, FolderKind.Users, Loc["Tree_Users"], 0, depth); + usersFolder.Meta = ""; + TreeNode? functionsFolder = hasSystemJs ? Folder(dbNode, FolderKind.Functions, Loc["Tree_Functions"], 0, depth) : null; + dbNode.IsLoaded = true; + Shell.RebuildVisible(); + + // 计数、TTL、用户数与存储函数:并发补,补到哪行哪行就亮 —— 不让第一次展开等它们。 + _ = FillCountsAsync(collectionsFolder.Children.ToList()); + _ = FillBucketCountsAsync(bucketsFolder.Children.ToList()); + _ = FillUsersAsync(usersFolder); + if (functionsFolder is not null) + { + _ = FillFunctionsAsync(functionsFolder); + } + } + + private static TreeNode Folder(TreeNode dbNode, FolderKind kind, string label, int count, int depth) + { + var folder = new TreeNode(NodeKind.Folder, label, depth, dbNode) + { + Database = dbNode.Name, + Folder = kind, + Meta = count.ToString(System.Globalization.CultureInfo.InvariantCulture), + IsLoaded = true + }; + // 集合与视图默认展开(设计稿);用户与存储函数默认收起 —— 那两组很少是此行的目的。 + folder.IsExpanded = kind is FolderKind.Collections or FolderKind.Views or FolderKind.Buckets; + dbNode.Children.Add(folder); + return folder; + } + + private async Task FillCountsAsync(IReadOnlyList nodes) + { + using var gate = new SemaphoreSlim(6); + await Task.WhenAll(nodes.Select(async node => + { + await gate.WaitAsync().ConfigureAwait(false); + try + { + CollectionInfo info = node.Collection!; + long? count = await Connection.EstimatedCountAsync(info.Database, info.Name).ConfigureAwait(false); + if (count is { } n) + { + node.Meta = BsonText.Count(n); + } + // TTL 徽章要看索引;集合太多时只看前 60 个(徽章是提示,不值得为它打几百次往返)。 + if (info.Kind == CollectionKind.Collection && nodes.Count <= 60) + { + IReadOnlyList indexes = await Connection.ListIndexesAsync(info.Database, info.Name).ConfigureAwait(false); + if (MongoConnection.HasTtl(indexes)) + { + node.Tag = Loc["Tag_Ttl"]; + node.TagClass = "warn"; + } + } + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + // 某个集合没权限读计数:那一行不显示计数,不影响其余。 + } + finally + { + gate.Release(); + } + })).ConfigureAwait(false); + } + + private async Task FillBucketCountsAsync(IReadOnlyList nodes) + { + foreach (TreeNode node in nodes) + { + try + { + long? files = await Connection.EstimatedCountAsync(node.Database, node.Bucket!.FilesCollection).ConfigureAwait(false); + if (files is { } n) + { + node.Meta = Loc.Format("Tree_FileCount", BsonText.Grouped(n)); + } + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + } + } + } + + private async Task FillUsersAsync(TreeNode folder) + { + try + { + BsonDocument users = await Connection.RunCommandAsync(folder.Database, new BsonDocument("usersInfo", 1)).ConfigureAwait(false); + BsonDocument roles = await Connection.RunCommandAsync(folder.Database, new BsonDocument + { + { "rolesInfo", 1 }, + { "showBuiltinRoles", false } + }).ConfigureAwait(false); + List children = + [ + .. users.GetValue("users", new BsonArray()).AsBsonArray + .Select(u => new TreeNode(NodeKind.User, u["user"].AsString, folder.Depth + 1, folder) { Database = folder.Database }), + .. roles.GetValue("roles", new BsonArray()).AsBsonArray + .Select(r => new TreeNode(NodeKind.Role, r["role"].AsString, folder.Depth + 1, folder) { Database = folder.Database }) + ]; + Dispatcher.UIThread.Post(() => + { + folder.Children.Clear(); + foreach (TreeNode child in children) + { + folder.Children.Add(child); + } + folder.Meta = children.Count.ToString(System.Globalization.CultureInfo.InvariantCulture); + if (folder.IsExpanded) + { + Shell.RebuildVisible(); + } + }); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + // 没有 viewUser 权限:这一组留空,不报错 —— 大多数业务账号本来就没这个权限。 + } + } + + private async Task FillFunctionsAsync(TreeNode folder) + { + try + { + List functions = await Connection.Collection(folder.Database, "system.js") + .Find(FilterDefinition.Empty).Limit(200).ToListAsync().ConfigureAwait(false); + Dispatcher.UIThread.Post(() => + { + folder.Children.Clear(); + foreach (BsonDocument fn in functions) + { + folder.Children.Add(new TreeNode(NodeKind.Function, fn.GetValue("_id", "").ToString() ?? "", folder.Depth + 1, folder) + { + Database = folder.Database + }); + } + folder.Meta = functions.Count.ToString(System.Globalization.CultureInfo.InvariantCulture); + if (folder.IsExpanded) + { + Shell.RebuildVisible(); + } + }); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + } + } + + /// 集合统计(信息区、删除确认用;同一个集合只取一次)。 + internal async Task GetStatsCachedAsync(string database, string collection, CancellationToken cancellationToken = default) + { + string key = $"{database}.{collection}"; + if (_statsCache.TryGetValue(key, out CollectionStats? cached)) + { + return cached; + } + CollectionStats stats = await Connection.GetStatsAsync(database, collection, cancellationToken).ConfigureAwait(true); + _statsCache[key] = stats; + return stats; + } + + // ── 写护栏 ───────────────────────────────────────────────────────────── + + /// + public bool EnsureWritable(string database) + { + if (Guard.Check(database) is not { } reason) + { + return true; + } + Toast(new() + { + Title = Loc[reason], + Kind = ToastKind.Warning, + ActionLabel = Guard.IsReadOnly ? Loc["Toolbar_Unlock"] : null, + Action = Guard.IsReadOnly ? UnlockAsync : null, + Duration = TimeSpan.FromSeconds(6) + }); + return false; + } + + /// 解除只读:生产连接要先确认一句(写清楚是哪条连接)。 + /// 任务。 + internal async Task UnlockAsync() + { + if (Guard.IsProduction && !await ConfirmAsync(new() + { + Title = Loc["Toolbar_UnlockTitle"], + Message = Loc.Format("Toolbar_UnlockBody", ConnectionName), + ConfirmLabel = Loc["Toolbar_Unlock"], + IconKey = "Mongo.lock-open", + Danger = true + }).ConfigureAwait(true)) + { + return; + } + Guard.IsReadOnly = false; + } + + private void OnGuardChanged() => Shell.OnSessionChanged(this); + + private void OnAvailability(bool available) => Dispatcher.UIThread.Post(() => + { + _isAvailable = available; + Shell.OnSessionChanged(this); + }); + + private void OnLatency(int ms) => Dispatcher.UIThread.Post(() => + { + _latencyMs = ms; + Shell.OnSessionChanged(this); + }); + + // ── 范围与打开标签 ───────────────────────────────────────────────────── + + /// + public (string? Database, string? Collection) Scope + { + get + { + TreeNode? node = Shell.SelectedNode?.Owner == Entry ? Shell.SelectedNode : null; + string? db = node?.Database is { Length: > 0 } d ? d : DefaultDatabase(); + string? coll = node?.Collection?.Name ?? (node?.Bucket is { } b ? b.Name : null); + if (coll is null && ReferenceEquals(Shell.ActiveTab?.Owner, this)) + { + switch (Shell.ActiveTab) + { + case CollectionTabViewModel collection: + return (collection.Database, collection.CollectionName); + case DesignTabViewModel design: + return (design.Database, design.CollectionName); + case PipelineTabViewModel pipeline: + return (pipeline.Database, pipeline.CollectionName); + } + } + return (db, coll); + } + } + + /// + public void OpenCollection(string database, string collection, string? filter = null) + { + CollectionTabViewModel tab = Shell.Activate(new CollectionTabViewModel(this, FindCollection(database, collection), filter)); + if (filter is not null && tab.FilterText != filter) + { + tab.ApplyFilter(filter); + } + } + + /// + public void OpenQuery(string database, string? text = null, bool run = false) => + Shell.Activate(new QueryTabViewModel(this, database, text, run, NextQueryNumber())); + + /// + public void OpenPipeline(string database, string collection, BsonArray? pipeline = null) => + Shell.Activate(new PipelineTabViewModel(this, FindCollection(database, collection), pipeline)); + + /// + public void OpenDesign(string database, string collection, DesignPage page = DesignPage.Indexes) + { + DesignTabViewModel tab = Shell.Activate(new DesignTabViewModel(this, FindCollection(database, collection), page)); + tab.Page = page; + } + + /// + public void OpenGridFs(string database, string bucket) => ActivateGridFs(database, bucket); + + /// 打开(或切到)一个桶的标签并把它交回来(对象树的「上传文件…」要接着对它发起上传)。 + internal GridFsTabViewModel ActivateGridFs(string database, string bucket) => + Shell.Activate(new GridFsTabViewModel(this, new GridFsBucketInfo(database, bucket))); + + /// + public void OpenObjects(string database, ObjectFilter filter = ObjectFilter.All) + { + ObjectsTabViewModel tab = Shell.Activate(new ObjectsTabViewModel(this, database, filter)); + tab.Filter = filter; + Shell.OnTabToolChanged(); + } + + /// + public void OpenMonitor() => Shell.Activate(new MonitorTabViewModel(this)); + + /// + public void OpenProfiler(string database) => Shell.Activate(new ProfilerTabViewModel(this, database)); + + /// + public void OpenUsers(string? database, bool roles = false) + { + UsersTabViewModel tab = Shell.Activate(new UsersTabViewModel(this, database, roles)); + tab.ShowRoles = roles; + Shell.OnTabToolChanged(); + } + + /// 关掉本连接里指向某个集合的标签(删掉它之后)。 + /// 命名空间 db.coll。 + /// 留着的那个(发起删除的对象列表本身)。 + internal void CloseTabsOf(string ns, WorkspaceTab? except = null) => + Shell.CloseTabsWhere(t => ReferenceEquals(t.Owner, this) && !ReferenceEquals(t, except) + && t.Key.EndsWith($":{ns}", StringComparison.Ordinal)); + + // ── 外壳服务(转给外壳)────────────────────────────────────────────── + + /// + public void ShowDialog(DialogViewModel dialog) => Shell.ShowDialog(dialog); + + /// + public void CloseDialog(DialogViewModel dialog) => Shell.CloseDialog(dialog); + + /// + public Task ConfirmAsync(ConfirmRequest request) => Shell.ConfirmAsync(request); + + /// + public void Toast(ToastRequest toast) => Shell.Toast(toast); + + /// + public Task CopyAsync(string text) => Shell.CopyAsync(text); + + /// + public Task PickSaveFileAsync(string title, string suggestedName, IReadOnlyList kinds) => + Shell.PickSaveFileAsync(title, suggestedName, kinds); + + /// + public Task> PickOpenFilesAsync(string title, IReadOnlyList kinds, bool multiple = false) => + Shell.PickOpenFilesAsync(title, kinds, multiple); + + /// + public Task PickFolderAsync(string title) => Shell.PickFolderAsync(title); + + /// + public void NotifyStatusChanged() => Shell.NotifyStatusChanged(); + + /// 断开前摘掉事件与登记(连接本身由外壳异步关)。 + public void Dispose() + { + Guard.Changed -= OnGuardChanged; + Connection.Availability -= OnAvailability; + Connection.LatencyChanged -= OnLatency; + _registration.Dispose(); + Root.Children.Clear(); + Root.IsExpanded = false; + _databaseNodes.Clear(); + _collections.Clear(); + _statsCache.Clear(); + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Shell/MongoWorkspaceView.axaml b/plugins/VelaShell.Plugin.Mongo/Ui/Shell/MongoWorkspaceView.axaml new file mode 100644 index 0000000..091847c --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Shell/MongoWorkspaceView.axaml @@ -0,0 +1,499 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Shell/MongoWorkspaceView.axaml.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Shell/MongoWorkspaceView.axaml.cs new file mode 100644 index 0000000..305eef5 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Shell/MongoWorkspaceView.axaml.cs @@ -0,0 +1,311 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Input.Platform; +using Avalonia.Interactivity; +using Avalonia.Platform.Storage; +using Avalonia.Threading; +using Avalonia.VisualTree; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 工作台外壳的视图。只做视图该做的事:装配、键盘手势、右键菜单, +/// 以及把剪贴板与文件选择框(只有 TopLevel 才拿得到)借给视图模型。 +/// +public sealed partial class MongoWorkspaceView : UserControl, IViewServices +{ + private readonly MongoWorkspaceViewModel _viewModel; + + /// 用给定的视图模型初始化。 + /// 视图模型。 + internal MongoWorkspaceView(MongoWorkspaceViewModel viewModel) + { + _viewModel = viewModel ?? throw new ArgumentNullException(nameof(viewModel)); + InitializeComponent(); + DataContext = viewModel; + viewModel.ViewServices = this; + AddHandler(KeyDownEvent, OnKeyDown, RoutingStrategies.Tunnel); + TreeList.DoubleTapped += OnTreeDoubleTapped; + TreeList.ContextRequested += OnTreeContextRequested; + } + + /// headless 测试与设计器用的无参构造(不会被宿主走到)。 + public MongoWorkspaceView() + { + _viewModel = null!; + InitializeComponent(); + } + + private void OnTreeDoubleTapped(object? sender, TappedEventArgs e) + { + if ((e.Source as Control)?.DataContext is TreeNode node) + { + _viewModel.OpenNodeCommand.Execute(node); + } + } + + /// + /// 树的右键菜单在代码里按节点种类现建:数据库、集合、视图、桶要的菜单各不相同, + /// 而且菜单项要直达外壳的命令 —— 写在 AXAML 里就得从弹出层的独立可视树里绕回外壳的数据上下文。 + /// + private void OnTreeContextRequested(object? sender, ContextRequestedEventArgs e) + { + if ((e.Source as Control)?.DataContext is not TreeNode node) + { + return; + } + _viewModel.SelectedNode = node; + List items = TreeMenuItems(node); + if (items.Count == 0) + { + return; + } + var menu = new ContextMenu { ItemsSource = items }; + menu.Open(e.Source as Control ?? TreeList); + e.Handled = true; + } + + /// 对象树某一行的右键菜单项(不可用的命令不列;测试直接读它)。 + internal List TreeMenuItems(TreeNode node) + { + Loc loc = _viewModel.Loc; + var items = new List(); + + void Add(string label, string icon, System.Windows.Input.ICommand command, bool danger = false) + { + if (!command.CanExecute(node)) + { + return; + } + items.Add(MenuKit.Command(label, icon, command, node, danger)); + } + + void Separator() => MenuKit.Separator(items); + + switch (node.Kind) + { + case NodeKind.Collection or NodeKind.View: + Add(loc["Tree_Open"], "Mongo.table-2", _viewModel.OpenNodeCommand); + Add(loc["Tree_Design"], "Mongo.pencil-ruler", _viewModel.DesignNodeCommand); + Add(loc["Tree_Pipeline"], "Mongo.workflow", _viewModel.PipelineNodeCommand); + Add(loc["Tree_NewQuery"], "Mongo.file-code", _viewModel.QueryNodeCommand); + Separator(); + // 在哪一组上点就能在哪一组里新建:集合行给「新建集合 / 以它为源新建视图」,视图行给「新建视图」。 + if (node.Kind == NodeKind.Collection) + { + Add(loc["Tree_NewCollection"], "Mongo.plus", _viewModel.NewCollectionNodeCommand); + } + Add(loc["Tree_NewView"], "Mongo.eye", _viewModel.NewViewNodeCommand); + Separator(); + Add(loc["Tree_Import"], "Mongo.download", _viewModel.ImportNodeCommand); + Add(loc["Tree_Export"], "Mongo.upload", _viewModel.ExportNodeCommand); + Add(loc["Tree_CopyName"], "Mongo.copy", _viewModel.CopyNameCommand); + Separator(); + Add(loc["Tree_Empty"], "Mongo.eraser", _viewModel.EmptyCollectionCommand, danger: true); + Add(loc["Tree_Drop"], "Mongo.trash-2", _viewModel.DropCollectionCommand, danger: true); + break; + case NodeKind.Database: + Add(loc["Tree_Objects"], "Mongo.layout-grid", _viewModel.OpenNodeCommand); + Add(loc["Tree_NewQuery"], "Mongo.file-code", _viewModel.QueryNodeCommand); + Add(loc["Tree_Profiler"], "Mongo.timer", _viewModel.ProfilerNodeCommand); + Separator(); + Add(loc["Tree_NewCollection"], "Mongo.plus", _viewModel.NewCollectionNodeCommand); + Add(loc["Tree_NewView"], "Mongo.eye", _viewModel.NewViewNodeCommand); + Add(loc["Tree_NewBucket"], "Mongo.folder-plus", _viewModel.NewBucketNodeCommand); + Separator(); + Add(loc["Tree_Export"], "Mongo.upload", _viewModel.ExportNodeCommand); + Add(loc["Tree_CopyName"], "Mongo.copy", _viewModel.CopyNameCommand); + Add(loc["Tree_Refresh"], "Mongo.refresh-cw", _viewModel.RefreshNodeCommand); + if (_viewModel.CanOfferDropDatabase) + { + Separator(); + Add(loc["Tree_DropDb"], "Mongo.trash-2", _viewModel.DropDatabaseCommand, danger: true); + } + break; + case NodeKind.Bucket: + Add(loc["Tree_Open"], "Mongo.hard-drive", _viewModel.OpenNodeCommand); + Add(loc["Tree_UploadFiles"], "Mongo.upload", _viewModel.UploadFilesNodeCommand); + Add(loc["Tree_UploadFolder"], "Mongo.folder-up", _viewModel.UploadFolderNodeCommand); + Separator(); + Add(loc["Tree_NewBucket"], "Mongo.folder-plus", _viewModel.NewBucketNodeCommand); + Add(loc["Tree_CopyName"], "Mongo.copy", _viewModel.CopyNameCommand); + Separator(); + Add(loc["Tree_DropBucket"], "Mongo.trash-2", _viewModel.DropCollectionCommand, danger: true); + break; + case NodeKind.Folder: + // 分组文件夹上的右键:先给「在这一组里新建」,再是刷新(设计稿 01 的对象树只画了刷新,新建是后补的入口)。 + switch (node.Folder) + { + case FolderKind.Collections: + Add(loc["Tree_NewCollection"], "Mongo.plus", _viewModel.NewCollectionNodeCommand); + break; + case FolderKind.Views: + Add(loc["Tree_NewView"], "Mongo.eye", _viewModel.NewViewNodeCommand); + break; + case FolderKind.Buckets: + Add(loc["Tree_NewBucket"], "Mongo.folder-plus", _viewModel.NewBucketNodeCommand); + break; + case FolderKind.Users: + Add(loc["Tree_Open"], "Mongo.user", _viewModel.OpenNodeCommand); + break; + } + Separator(); + Add(loc["Tree_Refresh"], "Mongo.refresh-cw", _viewModel.RefreshNodeCommand); + break; + case NodeKind.Group: + Add(loc["Conn_New"], "Mongo.plus", _viewModel.NewConnectionCommand); + break; + case NodeKind.Connection when node.Owner is { State: ConnectionState.Connected }: + Add(loc["Tree_NewQuery"], "Mongo.file-code", _viewModel.QueryNodeCommand); + Add(_viewModel.SystemDatabasesTip, _viewModel.ShowSystemDatabases ? "Mongo.eye-off" : "Mongo.eye", + _viewModel.ToggleSystemDatabasesCommand); + Add(loc["Nav_Refresh"], "Mongo.refresh-cw", _viewModel.RefreshNodeCommand); + Add(loc["Nav_CollapseAll"], "Mongo.list-collapse", _viewModel.CollapseAllCommand); + Separator(); + Add(loc["Conn_Edit"], "Mongo.pencil", _viewModel.EditConnectionCommand); + Add(loc["Conn_Duplicate"], "Mongo.copy", _viewModel.DuplicateConnectionCommand); + Add(loc["Conn_Disconnect"], "Mongo.unplug", _viewModel.DisconnectNodeCommand); + Separator(); + Add(loc["Conn_Delete"], "Mongo.trash-2", _viewModel.DeleteConnectionCommand, danger: true); + break; + case NodeKind.Connection: + // 没连着(或正在连 / 没连上):先给「连接」,再是管理这条连接本身。 + Add(loc["Conn_Connect"], "Mongo.plug", _viewModel.ConnectNodeCommand); + Add(loc["Conn_Disconnect"], "Mongo.unplug", _viewModel.DisconnectNodeCommand); + Separator(); + Add(loc["Conn_Edit"], "Mongo.pencil", _viewModel.EditConnectionCommand); + Add(loc["Conn_Duplicate"], "Mongo.copy", _viewModel.DuplicateConnectionCommand); + Separator(); + Add(loc["Conn_Delete"], "Mongo.trash-2", _viewModel.DeleteConnectionCommand, danger: true); + break; + default: + Add(loc["Tree_Open"], "Mongo.external-link", _viewModel.OpenNodeCommand); + Add(loc["Tree_CopyName"], "Mongo.copy", _viewModel.CopyNameCommand); + break; + } + // 末组的项全都不可用时会剩一条孤零零的分隔线收尾。 + if (items.Count > 0 && items[^1] is Separator) + { + items.RemoveAt(items.Count - 1); + } + return items; + } + + private void OnScrimPressed(object? sender, PointerPressedEventArgs e) + { + // 点遮罩不关对话框:向导填了一半、编辑器改了一半时误点一下就全没了,代价太大。 + e.Handled = true; + } + + /// + /// 面板级快捷键:Ctrl+F 筛选对象树、Ctrl+W 关标签、Ctrl+T 新查询、F5 刷新当前标签、Esc 关对话框。 + /// 在输入框里时不抢字符键。 + /// + private void OnKeyDown(object? sender, KeyEventArgs e) + { + bool control = e.KeyModifiers.HasFlag(KeyModifiers.Control); + if (_viewModel.Dialog is { } dialog) + { + if (e.Key == Key.Escape && !e.Handled) + { + // 不直接关:有未保存修改 / 正在执行时 RequestClose 会先问一句。 + dialog.RequestCloseCommand.Execute(null); + e.Handled = true; + } + return; + } + switch (e.Key) + { + case Key.F when control && e.KeyModifiers.HasFlag(KeyModifiers.Shift): + case Key.F when control && IsWithin(TreeList): + TreeFilterBox.Focus(); + TreeFilterBox.SelectAll(); + e.Handled = true; + return; + case Key.W when control && _viewModel.ActiveTab is { } tab: + _ = _viewModel.CloseTabAsync(tab); + e.Handled = true; + return; + case Key.T when control: + _viewModel.NewQueryCommand.Execute(null); + e.Handled = true; + return; + case Key.F5 when _viewModel.ActiveTab is { } active && active is not QueryTabViewModel: + _ = active.RefreshAsync(); + e.Handled = true; + return; + case Key.Enter when ReferenceEquals(e.Source, TreeList) || IsWithin(TreeList): + if (_viewModel.SelectedNode is { } node && e.Source is not TextBox) + { + _viewModel.OpenNodeCommand.Execute(node); + e.Handled = true; + } + return; + default: + return; + } + } + + private static bool IsWithin(Control container) => + TopLevel.GetTopLevel(container)?.FocusManager?.GetFocusedElement() is Control focused + && (ReferenceEquals(focused, container) || container.IsVisualAncestorOf(focused)); + + // ── IViewServices ─────────────────────────────────────────────────────── + + /// + Task IViewServices.CopyAsync(string text) => + Dispatcher.UIThread.InvokeAsync(async () => + { + if (TopLevel.GetTopLevel(this)?.Clipboard is { } clipboard) + { + await clipboard.SetTextAsync(text).ConfigureAwait(true); + } + }); + + /// + async Task IViewServices.PickSaveFileAsync(string title, string suggestedName, IReadOnlyList kinds) + { + if (TopLevel.GetTopLevel(this)?.StorageProvider is not { CanSave: true } storage) + { + return null; + } + IStorageFile? file = await storage.SaveFilePickerAsync(new FilePickerSaveOptions + { + Title = title, + SuggestedFileName = suggestedName, + FileTypeChoices = [.. kinds.Select(static k => new FilePickerFileType(k.Name) { Patterns = k.Patterns })] + }).ConfigureAwait(true); + return file?.TryGetLocalPath(); + } + + /// + async Task> IViewServices.PickOpenFilesAsync(string title, IReadOnlyList kinds, bool multiple) + { + if (TopLevel.GetTopLevel(this)?.StorageProvider is not { CanOpen: true } storage) + { + return []; + } + IReadOnlyList files = await storage.OpenFilePickerAsync(new FilePickerOpenOptions + { + Title = title, + AllowMultiple = multiple, + FileTypeFilter = [.. kinds.Select(static k => new FilePickerFileType(k.Name) { Patterns = k.Patterns })] + }).ConfigureAwait(true); + return [.. files.Select(static f => f.TryGetLocalPath()).Where(static p => p is not null).Select(static p => p!)]; + } + + /// + async Task IViewServices.PickFolderAsync(string title) + { + if (TopLevel.GetTopLevel(this)?.StorageProvider is not { CanPickFolder: true } storage) + { + return null; + } + IReadOnlyList folders = await storage.OpenFolderPickerAsync(new FolderPickerOpenOptions + { + Title = title, + AllowMultiple = false + }).ConfigureAwait(true); + return folders.FirstOrDefault()?.TryGetLocalPath(); + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Shell/MongoWorkspaceViewModel.Commands.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Shell/MongoWorkspaceViewModel.Commands.cs new file mode 100644 index 0000000..49e5645 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Shell/MongoWorkspaceViewModel.Commands.cs @@ -0,0 +1,473 @@ +using MongoDB.Bson; +using MongoDB.Driver; +using VelaShell.Plugin.Mongo.Bson; +using VelaShell.Plugin.Mongo.Core; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 工具栏与对象树的命令。工具栏作用于当前连接(); +/// 树上的右键命令作用于那一行所在的连接 —— 根上挂着好几条连接时,两者不一定是同一条。 +/// +internal sealed partial class MongoWorkspaceViewModel +{ + /// 新建连接(工具栏最左的「连接」、对象树表头的 +)。 + public RelayCommand NewConnectionCommand { get; private set; } = null!; + + /// 新查询。 + public RelayCommand NewQueryCommand { get; private set; } = null!; + + /// 聚合。 + public RelayCommand AggregateCommand { get; private set; } = null!; + + /// 集合。 + public RelayCommand CollectionsCommand { get; private set; } = null!; + + /// 视图。 + public RelayCommand ViewsCommand { get; private set; } = null!; + + /// 索引。 + public RelayCommand IndexesCommand { get; private set; } = null!; + + /// GridFS。 + public RelayCommand GridFsCommand { get; private set; } = null!; + + /// 用户。 + public RelayCommand UsersCommand { get; private set; } = null!; + + /// 角色。 + public RelayCommand RolesCommand { get; private set; } = null!; + + /// 导入。 + public RelayCommand ImportCommand { get; private set; } = null!; + + /// 导出。 + public RelayCommand ExportCommand { get; private set; } = null!; + + /// 转储。 + public RelayCommand DumpCommand { get; private set; } = null!; + + /// 数据传输。 + public RelayCommand TransferCommand { get; private set; } = null!; + + /// 服务器监控。 + public RelayCommand MonitorCommand { get; private set; } = null!; + + /// 慢查询。 + public RelayCommand ProfilerCommand { get; private set; } = null!; + + /// 只读开关(解锁生产连接要确认)。 + public AsyncCommand ToggleReadOnlyCommand { get; private set; } = null!; + + /// 在这一行所在的库里新建集合(「集合」分组、集合行、库行的右键)。 + public RelayCommand NewCollectionNodeCommand { get; private set; } = null!; + + /// 新建视图(「视图」分组、视图行、集合行的右键;从集合 / 视图上点时源集合预填成它)。 + public RelayCommand NewViewNodeCommand { get; private set; } = null!; + + /// 新建 GridFS 存储桶(「GridFS 存储桶」分组、桶行、库行的右键)。 + public RelayCommand NewBucketNodeCommand { get; private set; } = null!; + + /// 上传文件到这个桶(桶行右键:打开桶的标签再弹文件选择框)。 + public AsyncCommand UploadFilesNodeCommand { get; private set; } = null!; + + /// 上传文件夹到这个桶。 + public AsyncCommand UploadFolderNodeCommand { get; private set; } = null!; + + /// 刷新当前连接的树。 + public AsyncCommand RefreshTreeCommand { get; private set; } = null!; + + /// 当前连接列不列系统库。 + public RelayCommand ToggleSystemDatabasesCommand { get; private set; } = null!; + + /// 收起全部库。 + public RelayCommand CollapseAllCommand { get; private set; } = null!; + + /// 点箭头。 + public AsyncCommand ToggleNodeCommand { get; private set; } = null!; + + /// 双击 / 回车。 + public AsyncCommand OpenNodeCommand { get; private set; } = null!; + + /// 连接(连接行右键)。 + public AsyncCommand ConnectNodeCommand { get; private set; } = null!; + + /// 断开(连接行右键)。 + public AsyncCommand DisconnectNodeCommand { get; private set; } = null!; + + /// 编辑连接(连接行右键)。 + public RelayCommand EditConnectionCommand { get; private set; } = null!; + + /// 复制一条连接(连接行右键)。 + public RelayCommand DuplicateConnectionCommand { get; private set; } = null!; + + /// 删除连接(连接行右键)。 + public AsyncCommand DeleteConnectionCommand { get; private set; } = null!; + + /// 设计。 + public RelayCommand DesignNodeCommand { get; private set; } = null!; + + /// 聚合。 + public RelayCommand PipelineNodeCommand { get; private set; } = null!; + + /// 新查询。 + public RelayCommand QueryNodeCommand { get; private set; } = null!; + + /// 导出。 + public RelayCommand ExportNodeCommand { get; private set; } = null!; + + /// 导入。 + public RelayCommand ImportNodeCommand { get; private set; } = null!; + + /// 复制名称。 + public AsyncCommand CopyNameCommand { get; private set; } = null!; + + /// 清空集合。 + public AsyncCommand EmptyCollectionCommand { get; private set; } = null!; + + /// 删除集合 / 视图 / GridFS 存储桶。 + public AsyncCommand DropCollectionCommand { get; private set; } = null!; + + /// 删除数据库。 + public AsyncCommand DropDatabaseCommand { get; private set; } = null!; + + /// 刷新这一支。 + public AsyncCommand RefreshNodeCommand { get; private set; } = null!; + + /// 慢查询。 + public RelayCommand ProfilerNodeCommand { get; private set; } = null!; + + private void InitializeCommands() + { + NewConnectionCommand = new(() => NewConnection()); + NewQueryCommand = new(() => WithDatabase((s, db) => s.OpenQuery(db))); + AggregateCommand = new(() => WithCollection((s, db, coll) => s.OpenPipeline(db, coll))); + CollectionsCommand = new(() => WithSession(s => + { + (string? db, string? coll) = s.Scope; + if (db is not null && coll is not null && SelectedNode?.Kind is NodeKind.Collection or NodeKind.View) + { + s.OpenCollection(db, coll); + } + else if (db is not null) + { + s.OpenObjects(db, ObjectFilter.Collections); + } + })); + ViewsCommand = new(() => WithDatabase((s, db) => s.OpenObjects(db, ObjectFilter.Views))); + IndexesCommand = new(() => WithCollection((s, db, coll) => s.OpenDesign(db, coll, DesignPage.Indexes))); + GridFsCommand = new(() => WithDatabase((s, db) => + { + string? bucket = SelectedNode?.Owner == s.Entry ? SelectedNode?.Bucket?.Name : null; + bucket ??= MongoConnection.FindBuckets(db, s.CollectionsOf(db)).FirstOrDefault()?.Name; + if (bucket is null) + { + s.OpenObjects(db, ObjectFilter.GridFs); + return; + } + s.OpenGridFs(db, bucket); + })); + UsersCommand = new(() => WithSession(s => s.OpenUsers(s.Scope.Database))); + RolesCommand = new(() => WithSession(s => s.OpenUsers(s.Scope.Database, roles: true))); + ImportCommand = new(() => WithCollection((s, db, coll) => ShowDialog(new ImportWizardViewModel(s, db, coll)))); + ExportCommand = new(() => WithDatabase((s, db) => + ShowDialog(new ExportWizardViewModel(s, db, s.Scope.Collection, CurrentQueryFor(s, db, s.Scope.Collection), null)))); + DumpCommand = new(() => WithDatabase((s, db) => ShowDialog(new ExportWizardViewModel(s, db, s.Scope.Collection, null, ExportFormat.BsonDump)))); + TransferCommand = new(() => WithDatabase((s, db) => ShowDialog(new TransferWizardViewModel(s, db)))); + MonitorCommand = new(() => WithSession(s => s.OpenMonitor())); + ProfilerCommand = new(() => WithDatabase((s, db) => s.OpenProfiler(db))); + ToggleReadOnlyCommand = new(async () => + { + if (_currentSession is not { } session) + { + return; + } + if (session.Guard.IsReadOnly) + { + await session.UnlockAsync().ConfigureAwait(true); + } + else + { + session.Guard.IsReadOnly = true; + } + }); + + NewCollectionNodeCommand = new(node => ShowDialog(new NewCollectionDialogViewModel(node.Session!, node.Database)), + static node => node is { Session: not null, Database.Length: > 0 }); + NewViewNodeCommand = new(node => ShowDialog(new NewCollectionDialogViewModel(node.Session!, node.Database, NewCollectionKind.View, + node.Kind is NodeKind.Collection or NodeKind.View ? node.Name : null)), + static node => node is { Session: not null, Database.Length: > 0 }); + NewBucketNodeCommand = new(node => + { + if (node.Session is { } session && session.EnsureWritable(node.Database)) + { + ShowDialog(new GridFsNewBucketDialogViewModel(session, node.Database)); + } + }, + static node => node is { Session: not null, Database.Length: > 0 }); + UploadFilesNodeCommand = new(node => UploadToBucketAsync(node, folder: false), static node => node is { Kind: NodeKind.Bucket, Session: not null }); + UploadFolderNodeCommand = new(node => UploadToBucketAsync(node, folder: true), static node => node is { Kind: NodeKind.Bucket, Session: not null }); + RefreshTreeCommand = new(async () => + { + if (_currentSession is { } session) + { + session.ClearStats(); + await session.ReloadTreeAsync().ConfigureAwait(true); + } + }); + ToggleSystemDatabasesCommand = new(() => ShowSystemDatabases = !ShowSystemDatabases); + CollapseAllCommand = new(CollapseAll); + ToggleNodeCommand = new(ToggleAsync); + OpenNodeCommand = new(OpenNodeAsync); + + ConnectNodeCommand = new(node => node.Owner is { } entry ? ConnectAsync(entry) : Task.CompletedTask, + static node => node.Owner is { State: ConnectionState.Disconnected or ConnectionState.Failed }); + DisconnectNodeCommand = new(node => node.Owner is { } entry ? DisconnectAsync(entry) : Task.CompletedTask, + static node => node.Owner is { State: ConnectionState.Connected or ConnectionState.Connecting }); + EditConnectionCommand = new(node => EditConnection(node.Owner!), static node => node.Owner is not null); + DuplicateConnectionCommand = new(node => DuplicateConnection(node.Owner!), static node => node.Owner is not null); + DeleteConnectionCommand = new(node => node.Owner is { } entry ? DeleteConnectionAsync(entry) : Task.CompletedTask, + static node => node.Owner is not null); + + DesignNodeCommand = new(node => node.Session?.OpenDesign(node.Database, node.Name), static node => node is { Kind: NodeKind.Collection, Session: not null }); + PipelineNodeCommand = new(node => node.Session?.OpenPipeline(node.Database, node.Name), + static node => node is { Kind: NodeKind.Collection or NodeKind.View, Session: not null }); + QueryNodeCommand = new(node => node.Session?.OpenQuery( + node.Database.Length > 0 ? node.Database : node.Session.DefaultDatabase() ?? "test", + node.Kind is NodeKind.Collection or NodeKind.View ? $"db.{ShellCollectionRef(node.Name)}.find({{}}).limit(50)" : null), + static node => node.Session is not null); + ExportNodeCommand = new(node => ShowDialog(new ExportWizardViewModel(node.Session!, node.Database, + node.Kind is NodeKind.Collection or NodeKind.View ? node.Name : null, null, null)), + static node => node is { Session: not null, Database.Length: > 0 }); + ImportNodeCommand = new(node => ShowDialog(new ImportWizardViewModel(node.Session!, node.Database, node.Name)), + static node => node is { Kind: NodeKind.Collection, Session: not null }); + CopyNameCommand = new(node => CopyAsync(node.Namespace ?? node.Name)); + EmptyCollectionCommand = new(EmptyCollectionAsync, static node => node is { Kind: NodeKind.Collection, Session: not null }); + DropCollectionCommand = new(DropCollectionAsync, + static node => node is { Kind: NodeKind.Collection or NodeKind.View or NodeKind.Bucket, Session: not null }); + DropDatabaseCommand = new(DropDatabaseAsync, + static node => node is { Kind: NodeKind.Database, Session: { } session } && !session.Guard.DisableDropDatabase); + RefreshNodeCommand = new(node => node.Session?.RefreshTreeAsync(node.Database.Length > 0 ? node.Database : null) ?? Task.CompletedTask, + static node => node.Session is not null); + ProfilerNodeCommand = new(node => node.Session?.OpenProfiler(node.Database), static node => node is { Session: not null, Database.Length: > 0 }); + } + + private FindRequest? CurrentQueryFor(MongoSession session, string database, string? collection) => + collection is null + ? null + : Tabs.OfType() + .FirstOrDefault(t => ReferenceEquals(t.Owner, session) && t.Database == database && t.CollectionName == collection)?.CurrentRequest; + + /// 没有当前连接时,工具栏的按钮说一句「先连一条」,而不是什么都不发生。 + private void WithSession(Action action) + { + if (_currentSession is { } session) + { + action(session); + return; + } + Toast(new() { Title = Loc[Connections.Count == 0 ? "Conn_NoneYet" : "Conn_PickFirst"], Kind = ToastKind.Info }); + } + + private void WithDatabase(Action action) => WithSession(s => + { + if (s.Scope.Database is { } db) + { + action(s, db); + } + }); + + private void WithCollection(Action action) => WithSession(s => + { + (string? db, string? coll) = s.Scope; + if (db is null) + { + return; + } + // 没选中集合:落到该库的第一个集合,而不是一个"请先选择"的空弹窗 —— 选错了换一个比弹窗快。 + coll ??= s.CollectionsOf(db).FirstOrDefault(static c => c.Kind is CollectionKind.Collection or CollectionKind.TimeSeries)?.Name; + if (coll is null) + { + Toast(new() { Title = Loc["Toolbar_PickCollection"], Kind = ToastKind.Info }); + return; + } + action(s, db, coll); + }); + + private async Task EmptyCollectionAsync(TreeNode node) + { + if (node.Session is not { } session || !session.EnsureWritable(node.Database)) + { + return; + } + long? count = await session.Connection.EstimatedCountAsync(node.Database, node.Name).ConfigureAwait(true); + if (!await ConfirmAsync(new() + { + Title = Loc["Tree_EmptyTitle"], + Message = Loc.Format("Tree_EmptyBody", node.Namespace, BsonText.Grouped(count ?? 0)), + ConfirmLabel = Loc["Tree_EmptyConfirm"], + IconKey = "Mongo.eraser", + TypeToConfirm = session.Guard.ConfirmWrites ? node.Name : null + }).ConfigureAwait(true)) + { + return; + } + try + { + DeleteResult result = await session.Connection.Collection(node.Database, node.Name) + .DeleteManyAsync(FilterDefinition.Empty).ConfigureAwait(true); + Toast(new() { Title = Loc.Format("Tree_EmptyDone", BsonText.Grouped(result.DeletedCount)), Kind = ToastKind.Success }); + session.ClearStats(); + await session.RefreshTreeAsync(node.Database).ConfigureAwait(true); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Toast(new() { Title = Loc.Format("Common_Failed", MongoConnector.Describe(ex)), Kind = ToastKind.Error }); + } + } + + /// 桶行右键的「上传文件… / 上传文件夹…」:打开(或切到)桶的标签,再走标签里同一条上传路径。 + private async Task UploadToBucketAsync(TreeNode node, bool folder) + { + if (node is not { Session: { } session, Bucket: { } bucket }) + { + return; + } + GridFsTabViewModel tab = session.ActivateGridFs(bucket.Database, bucket.Name); + await (folder ? tab.UploadFolderCommand : tab.UploadFilesCommand).ExecuteAsync().ConfigureAwait(true); + } + + internal async Task DropCollectionAsync(TreeNode node) + { + if (node.Session is not { } session || !session.EnsureWritable(node.Database)) + { + return; + } + if (node.Bucket is { } bucket) + { + await DropBucketAsync(session, node, bucket).ConfigureAwait(true); + return; + } + var facts = new List(); + int indexCount = 0; + if (node.Kind == NodeKind.Collection) + { + try + { + CollectionStats stats = await session.Connection.GetStatsAsync(node.Database, node.Name).ConfigureAwait(true); + indexCount = stats.IndexCount; + facts.Add(new(Loc["Detail_Documents"], BsonText.Grouped(stats.Count))); + facts.Add(new(Loc["Confirm_DataSize"], BsonText.Bytes(stats.Size))); + BsonDocument? last = await session.Connection.Collection(node.Database, node.Name) + .Find(FilterDefinition.Empty) + .Sort(new BsonDocument("$natural", -1)).Limit(1).FirstOrDefaultAsync().ConfigureAwait(true); + if (last?.GetValue("_id", BsonNull.Value) is BsonObjectId id) + { + facts.Add(new(Loc["Confirm_LastWrite"], id.Value.CreationTime.ToLocalTime().ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture))); + } + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + } + } + bool confirmed = await ConfirmAsync(new() + { + Title = node.Kind == NodeKind.View ? Loc["Confirm_DropViewTitle"] : Loc["Confirm_DropTitle"], + Message = Loc.Format(node.Kind == NodeKind.View ? "Confirm_DropViewBody" : "Confirm_DropBody", node.Namespace, indexCount), + ConfirmLabel = node.Kind == NodeKind.View ? Loc["Confirm_DropViewTitle"] : Loc["Confirm_DropTitle"], + Facts = facts, + // 名称确认在两种情况下都要:生产连接(写前确认),或这次要删的东西有数据。 + TypeToConfirm = session.Guard.ConfirmWrites || facts.Count > 0 ? node.Name : null, + AsideLabel = node.Kind == NodeKind.Collection ? Loc["Confirm_BackupFirst"] : null, + AsideAction = () => ShowDialog(new ExportWizardViewModel(session, node.Database, node.Name, null, ExportFormat.BsonDump)) + }).ConfigureAwait(true); + if (!confirmed) + { + return; + } + try + { + await session.Connection.Database(node.Database).DropCollectionAsync(node.Name).ConfigureAwait(true); + Toast(new() { Title = Loc.Format("Confirm_Dropped", node.Namespace), Kind = ToastKind.Success }); + session.CloseTabsOf($"{node.Database}.{node.Name}"); + session.ClearStats(); + await session.RefreshTreeAsync(node.Database).ConfigureAwait(true); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Toast(new() { Title = Loc.Format("Common_Failed", MongoConnector.Describe(ex)), Kind = ToastKind.Error }); + } + } + + /// 删除一个桶 = 删掉 .files 与 .chunks 两个集合(与对象列表里删桶同一口径)。 + private async Task DropBucketAsync(MongoSession session, TreeNode node, GridFsBucketInfo bucket) + { + var facts = new List(); + long files = 0; + try + { + CollectionStats filesStats = await session.Connection.GetStatsAsync(bucket.Database, bucket.FilesCollection).ConfigureAwait(true); + CollectionStats chunksStats = await session.Connection.GetStatsAsync(bucket.Database, bucket.ChunksCollection).ConfigureAwait(true); + files = filesStats.Count; + facts.Add(new(Loc["Obj_FactFiles"], BsonText.Grouped(files))); + facts.Add(new(Loc["Confirm_DataSize"], BsonText.Bytes(chunksStats.Size))); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + } + bool confirmed = await ConfirmAsync(new() + { + Title = Loc["Obj_DropBucketTitle"], + Message = Loc.Format("Obj_DropBucketBody", node.Namespace), + ConfirmLabel = Loc["Obj_DropBucketTitle"], + Facts = facts, + TypeToConfirm = session.Guard.ConfirmWrites || files > 0 ? node.Name : null + }).ConfigureAwait(true); + if (!confirmed) + { + return; + } + try + { + IMongoDatabase database = session.Connection.Database(bucket.Database); + await database.DropCollectionAsync(bucket.FilesCollection).ConfigureAwait(true); + await database.DropCollectionAsync(bucket.ChunksCollection).ConfigureAwait(true); + Toast(new() { Title = Loc.Format("Confirm_Dropped", node.Namespace), Kind = ToastKind.Success }); + session.CloseTabsOf(node.Namespace!); + session.ClearStats(); + await session.RefreshTreeAsync(bucket.Database).ConfigureAwait(true); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Toast(new() { Title = Loc.Format("Common_Failed", MongoConnector.Describe(ex)), Kind = ToastKind.Error }); + } + } + + private async Task DropDatabaseAsync(TreeNode node) + { + if (node.Session is not { } session || session.Guard.DisableDropDatabase || !session.EnsureWritable(node.Database)) + { + return; + } + if (!await ConfirmAsync(new() + { + Title = Loc["Confirm_DropDbTitle"], + Message = Loc.Format("Confirm_DropDbBody", node.Name), + ConfirmLabel = Loc["Confirm_DropDbTitle"], + TypeToConfirm = node.Name + }).ConfigureAwait(true)) + { + return; + } + try + { + await session.Connection.Client.DropDatabaseAsync(node.Name).ConfigureAwait(true); + Toast(new() { Title = Loc.Format("Confirm_Dropped", node.Name), Kind = ToastKind.Success }); + await session.ReloadTreeAsync().ConfigureAwait(true); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Toast(new() { Title = Loc.Format("Common_Failed", MongoConnector.Describe(ex)), Kind = ToastKind.Error }); + } + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Shell/MongoWorkspaceViewModel.Connections.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Shell/MongoWorkspaceViewModel.Connections.cs new file mode 100644 index 0000000..5a21976 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Shell/MongoWorkspaceViewModel.Connections.cs @@ -0,0 +1,336 @@ +using VelaShell.Plugin.Mongo.Core; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 连接的生命周期:读出已保存的连接、连 / 断、新建 / 编辑 / 复制 / 删除、信任证书后重连。 +/// 连的过程照宿主「先建标签、后连会话」的习惯:先开一个占位标签(设计稿 22 的「正在连接」卡片), +/// 连上了换成对象列表,没连上就在原地变成「无法连接」卡片 —— 不弹模态框。 +/// +internal sealed partial class MongoWorkspaceViewModel +{ + /// 读出已保存的连接,挂到对象树根上(常用的在上面)。不自动连 —— 连哪条由用户决定。 + /// 任务。 + public async Task InitializeAsync() + { + IReadOnlyList profiles; + try + { + profiles = await Profiles.LoadAsync().ConfigureAwait(true); + } + catch (Exception ex) + { + Log.Error("Loading saved MongoDB connections failed.", ex); + Toast(new() { Title = Loc.Format("Conn_LoadFailed", MongoConnector.Describe(ex)), Kind = ToastKind.Error }); + profiles = []; + } + foreach (MongoProfile profile in profiles + .OrderByDescending(static p => p.LastConnectedAt ?? DateTimeOffset.MinValue) + .ThenBy(static p => p.Name, StringComparer.CurrentCultureIgnoreCase)) + { + if (Connections.All(c => c.Profile.Id != profile.Id)) + { + Connections.Add(Track(new ConnectionEntry(profile, Loc))); + } + } + RaisePropertyChanged(nameof(HasNoConnections)); + RebuildVisible(); + if (SelectedNode is null && Connections.FirstOrDefault() is { } first) + { + SelectedNode = first.Root; + } + } + + private ConnectionEntry Track(ConnectionEntry entry) + { + entry.StateChanged += RefreshDetailsFor; + return entry; + } + + /// 新建连接(设计稿 10)。 + internal void NewConnection() => + ShowDialog(new ConnectionDialogViewModel(this, new MongoProfile { Name = NextName("mongo") }, isNew: true)); + + /// 编辑一条连接。口令不回填到框里 —— 留空表示不改。 + /// 连接。 + internal void EditConnection(ConnectionEntry entry) => + ShowDialog(new ConnectionDialogViewModel(this, entry.Profile.Clone(), isNew: false)); + + /// 复制一条连接(新 id、名字加「副本」,口令一起带过去),在对话框里改完再存。 + /// 连接。 + internal void DuplicateConnection(ConnectionEntry entry) + { + MongoProfile copy = entry.Profile.Clone(); + copy.Id = Guid.NewGuid().ToString("N"); + copy.Name = NextName(Loc.Format("Conn_CopyName", entry.Name)); + copy.LastConnectedAt = null; + ShowDialog(new ConnectionDialogViewModel(this, copy, isNew: true, passwordFrom: entry.Profile.Id)); + } + + private string NextName(string stem) + { + if (Connections.All(c => !string.Equals(c.Name, stem, StringComparison.OrdinalIgnoreCase))) + { + return stem; + } + for (int i = 2; ; i++) + { + string candidate = $"{stem}-{i}"; + if (Connections.All(c => !string.Equals(c.Name, candidate, StringComparison.OrdinalIgnoreCase))) + { + return candidate; + } + } + } + + /// + /// 对话框点了「仅保存」或「保存并连接」:落盘,挂到树上(或换掉旧的那条),要连就连。 + /// 改的是连着的那条时先断开再连 —— 设置变了,旧连接不再代表它。 + /// + /// 连接。 + /// 口令; = 不改已存的。 + /// 存完就连。 + /// 任务。 + internal async Task SaveProfileAsync(MongoProfile profile, string? password, bool connect) + { + await Profiles.SaveAsync(profile, password).ConfigureAwait(true); + ConnectionEntry? entry = Connections.FirstOrDefault(c => c.Profile.Id == profile.Id); + bool wasConnected = entry?.State == ConnectionState.Connected; + if (entry is null) + { + entry = Track(new ConnectionEntry(profile.Clone(), Loc)); + Connections.Insert(0, entry); + RaisePropertyChanged(nameof(HasNoConnections)); + } + else + { + entry.Profile = profile.Clone(); + } + RebuildVisible(); + SelectedNode = entry.Root; + if (connect) + { + if (wasConnected) + { + await DisconnectAsync(entry).ConfigureAwait(true); + } + await ConnectAsync(entry).ConfigureAwait(true); + } + else if (wasConnected) + { + Toast(new() { Title = Loc["Conn_SavedReconnectHint"], Kind = ToastKind.Info }); + } + } + + /// 连一条。已连着就选中它;正在连就什么都不做。 + /// 连接。 + /// 任务。 + internal async Task ConnectAsync(ConnectionEntry entry) + { + if (entry.State == ConnectionState.Connecting) + { + return; + } + if (entry.State == ConnectionState.Connected) + { + 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)); + MongoLink link; + try + { + string password = await Profiles.GetPasswordAsync(entry.Profile.Id, cts.Token).ConfigureAwait(true); + link = await Connector.ConnectAsync(entry.Profile, password, cts.Token).ConfigureAwait(true); + } + catch (OperationCanceledException) + { + entry.State = ConnectionState.Disconnected; + CloseTabsWhere(t => ReferenceEquals(t, stateTab)); + return; + } + catch (Exception ex) + { + entry.Failure = ex as MongoConnectException ?? new MongoConnectException(MongoConnector.Describe(ex), ConnectFailureKind.Network, ex); + entry.State = ConnectionState.Failed; + Log.Info($"Connecting '{entry.Name}' failed: {entry.Failure.Message}"); + return; + } + finally + { + entry.Connecting = null; + } + if (!Connections.Contains(entry)) + { + // 连的这段时间里这条连接被删了。 + await link.DisposeAsync().ConfigureAwait(true); + return; + } + CloseTabsWhere(t => ReferenceEquals(t, stateTab)); + await AttachAsync(entry, link).ConfigureAwait(true); + entry.Profile.LastConnectedAt = DateTimeOffset.Now; + try + { + await Profiles.SaveAsync(entry.Profile, null).ConfigureAwait(true); + } + catch (Exception ex) + { + // 只是记一下"最近连过",存不下不值得打扰用户。 + Log.Info($"Recording the last connection time failed: {ex.Message}"); + } + } + + /// 连上之后:建会话、读库列表、展开默认库,开它的对象列表。 + /// 连接。 + /// 连上的一条。 + /// 会话。 + internal async Task AttachAsync(ConnectionEntry entry, MongoLink link) + { + var session = new MongoSession(this, entry, link); + entry.Session = session; + entry.State = ConnectionState.Connected; + await session.ReloadTreeAsync().ConfigureAwait(true); + string? db = session.DefaultDatabase(); + if (db is not null && session.Root.Children.FirstOrDefault(n => n.Name == db) is { } dbNode) + { + SelectedNode = dbNode.Children.FirstOrDefault(static f => f.Folder == FolderKind.Collections)?.Children.FirstOrDefault() ?? dbNode; + } + else + { + SelectedNode = entry.Root; + } + if (db is not null) + { + session.OpenObjects(db); + } + UpdateCurrentSession(); + return session; + } + + /// + /// 单测与截图用:把一条已经连上的连接挂到树上(不经存储、不建跳板)。 + /// + /// 连接。 + /// 连上的连接。 + /// 会话。 + internal Task AttachAsync(MongoProfile profile, MongoConnection connection) + { + var entry = Track(new ConnectionEntry(profile, Loc)); + Connections.Add(entry); + RaisePropertyChanged(nameof(HasNoConnections)); + RebuildVisible(); + return AttachAsync(entry, new MongoLink(connection, null)); + } + + /// 断开一条:它的标签先过「有未提交的修改」那一问,任何一个不肯关就不断。正在连就取消。 + /// 连接。 + /// 是否断开了。 + internal async Task DisconnectAsync(ConnectionEntry entry) + { + if (entry.State == ConnectionState.Connecting) + { + entry.Connecting?.Cancel(); + return true; + } + if (entry.Session is not { } session) + { + entry.State = ConnectionState.Disconnected; + entry.Failure = null; + CloseTabsWhere(t => t is ConnectionStateTabViewModel state && ReferenceEquals(state.Entry, entry)); + return true; + } + foreach (WorkspaceTab tab in Tabs.Where(t => ReferenceEquals(t.Owner, session)).ToList()) + { + if (!tab.CanClose || !await tab.ConfirmCloseAsync().ConfigureAwait(true)) + { + ActiveTab = tab; + return false; + } + } + CloseTabsWhere(t => ReferenceEquals(t.Owner, session)); + if (Dialog is { } open && open.Owner is { } owner && ReferenceEquals(owner, session)) + { + CloseDialog(open); + } + entry.Session = null; + session.Dispose(); + entry.State = ConnectionState.Disconnected; + if (SelectedNode?.Owner == entry) + { + SelectedNode = entry.Root; + } + RebuildVisible(); + UpdateCurrentSession(); + await session.Link.DisposeAsync().ConfigureAwait(true); + Log.Info($"MongoDB connection '{entry.Name}' closed."); + return true; + } + + /// 删掉一条连接(连同存着的口令)。连着就先断开。 + /// 连接。 + /// 任务。 + internal async Task DeleteConnectionAsync(ConnectionEntry entry) + { + if (!await ConfirmAsync(new() + { + Title = Loc["Conn_DeleteTitle"], + Message = Loc.Format("Conn_DeleteBody", entry.Name), + ConfirmLabel = Loc["Conn_DeleteConfirm"], + IconKey = "Mongo.trash-2", + Danger = true + }).ConfigureAwait(true)) + { + return; + } + if (!await DisconnectAsync(entry).ConfigureAwait(true)) + { + return; + } + try + { + await Profiles.DeleteAsync(entry.Profile.Id).ConfigureAwait(true); + } + catch (Exception ex) + { + Toast(new() { Title = Loc.Format("Common_Failed", MongoConnector.Describe(ex)), Kind = ToastKind.Error }); + return; + } + CloseTabsWhere(t => t is ConnectionStateTabViewModel state && ReferenceEquals(state.Entry, entry)); + Connections.Remove(entry); + if (SelectedNode?.Owner == entry) + { + SelectedNode = Connections.FirstOrDefault()?.Root; + } + RaisePropertyChanged(nameof(HasNoConnections)); + RebuildVisible(); + } + + /// 证书不受信任:记下这张证书的指纹(只信这一张),然后重连。 + /// 连接。 + /// 任务。 + internal async Task TrustAndReconnectAsync(ConnectionEntry entry) + { + if (entry.Failure?.Certificate is not { } certificate) + { + return; + } + MongoProfile updated = entry.Profile.Clone(); + updated.Set(MongoSettings.KeyTrustedThumbprint, certificate.Thumbprint); + try + { + await Profiles.SaveAsync(updated, null).ConfigureAwait(true); + } + catch (Exception ex) + { + Toast(new() { Title = Loc.Format("Common_Failed", MongoConnector.Describe(ex)), Kind = ToastKind.Error }); + return; + } + entry.Profile = updated; + entry.State = ConnectionState.Disconnected; + await ConnectAsync(entry).ConfigureAwait(true); + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Shell/MongoWorkspaceViewModel.Tree.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Shell/MongoWorkspaceViewModel.Tree.cs new file mode 100644 index 0000000..972c56c --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Shell/MongoWorkspaceViewModel.Tree.cs @@ -0,0 +1,433 @@ +using System.Collections.ObjectModel; +using MongoDB.Bson; +using MongoDB.Driver; +using VelaShell.Plugin.Mongo.Bson; +using VelaShell.Plugin.Mongo.Core; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 信息区的一行。 +/// 左边的标签。 +/// 右边的值。 +internal sealed record DetailRow(string Label, string Value); + +/// +/// 外壳的对象树:根上是全部已保存的连接,连着的那几条下面挂各自的库(那一支由 维护)。 +/// 这里只管拍平成可见行、选中、筛选与底部信息区。 +/// +internal sealed partial class MongoWorkspaceViewModel +{ + private TreeNode? _selectedNode; + private string _treeFilter = ""; + private string _detailTitle = ""; + private string _detailKind = ""; + private CancellationTokenSource? _detailLoad; + private readonly Dictionary _groups = new(StringComparer.CurrentCultureIgnoreCase); + + /// 拍平后的可见行(虚拟化的 ListBox 直接绑它)。 + public ObservableCollection VisibleNodes { get; } = []; + + /// 选中的行(底部信息区与工具栏的作用范围跟着它)。 + public TreeNode? SelectedNode + { + get => _selectedNode; + set + { + if (SetProperty(ref _selectedNode, value)) + { + UpdateCurrentSession(); + RaisePropertiesChanged(nameof(StatusScope), nameof(StatusDetail)); + _ = LoadDetailsAsync(value); + } + } + } + + /// 对象树筛选(只按名字;连接行永远留着)。 + public string TreeFilter + { + get => _treeFilter; + set + { + if (SetProperty(ref _treeFilter, value)) + { + RebuildVisible(); + } + } + } + + /// 信息区标题。 + public string DetailTitle + { + get => _detailTitle; + private set => SetProperty(ref _detailTitle, value); + } + + /// 信息区标题右边的小字。 + public string DetailKind + { + get => _detailKind; + private set => SetProperty(ref _detailKind, value); + } + + /// 信息区的行。 + public ObservableCollection DetailRows { get; } = []; + + /// 当前连接是否列出系统库(眼睛按钮)。 + public bool ShowSystemDatabases + { + get => _currentSession?.ShowSystemDatabases ?? false; + set + { + if (_currentSession is { } session && session.ShowSystemDatabases != value) + { + session.ShowSystemDatabases = value; + RaisePropertiesChanged(nameof(ShowSystemDatabases), nameof(SystemDatabasesTip)); + } + } + } + + /// 眼睛按钮的提示。 + public string SystemDatabasesTip => Loc[ShowSystemDatabases ? "Nav_HideSystemDbs" : "Nav_ShowSystemDbs"]; + + private void DisposeTree() + { + _detailLoad?.Cancel(); + _detailLoad?.Dispose(); + } + + /// 重新拍平可见行。只在真变了时替换 —— 整表重设会让列表丢掉滚动位置。 + internal void RebuildVisible() + { + TreeNode? selected = _selectedNode; + var rows = new List(); + string filter = _treeFilter.Trim(); + // 不分组的连接在最上面;分了组的按组名排,每组一行可收起的节标题(设计稿 10 的「分组」)。 + foreach (ConnectionEntry entry in Connections.Where(static c => c.Profile.Group.Length == 0)) + { + AddConnection(entry, rows); + } + foreach (IGrouping group in Connections + .Where(static c => c.Profile.Group.Length > 0) + .GroupBy(static c => c.Profile.Group, StringComparer.CurrentCultureIgnoreCase) + .OrderBy(static g => g.Key, StringComparer.CurrentCultureIgnoreCase)) + { + if (!_groups.TryGetValue(group.Key, out TreeNode? header)) + { + header = new TreeNode(NodeKind.Group, group.Key, 0, null) { IsLoaded = true, IsExpanded = true }; + _groups[group.Key] = header; + } + header.Meta = group.Count().ToString(System.Globalization.CultureInfo.InvariantCulture); + var below = new List(); + foreach (ConnectionEntry entry in group) + { + AddConnection(entry, below); + } + if (filter.Length == 0 || below.Count > 0 || header.Name.Contains(filter, StringComparison.OrdinalIgnoreCase)) + { + rows.Add(header); + if (header.IsExpanded) + { + rows.AddRange(below); + } + } + } + + void AddConnection(ConnectionEntry entry, List into) + { + if (filter.Length == 0) + { + Walk(entry.Root, into); + } + else + { + WalkFiltered(entry.Root, into, filter); + } + } + if (rows.SequenceEqual(VisibleNodes)) + { + return; + } + VisibleNodes.Clear(); + foreach (TreeNode row in rows) + { + VisibleNodes.Add(row); + } + if (selected is not null && rows.Contains(selected)) + { + RaisePropertyChanged(nameof(SelectedNode)); + } + } + + private static void Walk(TreeNode node, List rows) + { + rows.Add(node); + if (!node.IsExpanded) + { + return; + } + foreach (TreeNode child in node.Children) + { + Walk(child, rows); + } + } + + private static bool WalkFiltered(TreeNode node, List rows, string filter) + { + bool self = node.Kind != NodeKind.Placeholder && node.Name.Contains(filter, StringComparison.OrdinalIgnoreCase); + var below = new List(); + bool any = false; + foreach (TreeNode child in node.Children) + { + any |= WalkFiltered(child, below, filter); + } + if (self || any || node.Kind == NodeKind.Connection) + { + rows.Add(node); + rows.AddRange(below); + return true; + } + return false; + } + + /// 切到一个标签时,在树上选中它对应的那一行(那条连接没展开到那儿就不动)。 + internal void RevealInTree(WorkspaceTab? tab) + { + if (tab?.Owner is not MongoSession session) + { + return; + } + (string? db, string? name) = tab switch + { + CollectionTabViewModel c => (c.Database, c.CollectionName), + DesignTabViewModel d => (d.Database, d.CollectionName), + PipelineTabViewModel p => (p.Database, p.CollectionName), + GridFsTabViewModel g => (g.Bucket.Database, g.Bucket.Name), + ObjectsTabViewModel o => (o.Database, null), + ProfilerTabViewModel pr => (pr.Database, null), + _ => (null, null) + }; + if (db is null || session.Root.Children.FirstOrDefault(n => n.Name == db) is not { } dbNode) + { + return; + } + TreeNode? target = dbNode; + if (name is not null) + { + target = dbNode.Children + .SelectMany(static folder => folder.Children) + .FirstOrDefault(n => n.Name == name && n.Kind is NodeKind.Collection or NodeKind.View or NodeKind.Bucket); + } + if (target is not null && !ReferenceEquals(target, _selectedNode) && VisibleNodes.Contains(target)) + { + SelectedNode = target; + } + } + + internal void CollapseAll() + { + foreach (ConnectionEntry entry in Connections) + { + foreach (TreeNode db in entry.Root.Children) + { + db.IsExpanded = false; + } + } + RebuildVisible(); + } + + /// 点箭头:连接行没连着就去连;连着的收起 / 展开。 + public async Task ToggleAsync(TreeNode node) + { + if (!node.IsExpandable) + { + return; + } + if (node.Kind == NodeKind.Connection && node.Owner is { } entry && entry.State != ConnectionState.Connected) + { + await ConnectAsync(entry).ConfigureAwait(true); + return; + } + if (node.IsExpanded) + { + node.IsExpanded = false; + RebuildVisible(); + return; + } + await ExpandAsync(node).ConfigureAwait(true); + } + + private async Task ExpandAsync(TreeNode node) + { + if (node.Kind == NodeKind.Database && node.Session is { } session) + { + await session.ExpandDatabaseAsync(node).ConfigureAwait(true); + return; + } + node.IsExpanded = true; + RebuildVisible(); + } + + /// 双击 / 回车:连接行连上(或展开),对象行打开对应的标签。 + internal async Task OpenNodeAsync(TreeNode node) + { + if (node.Kind == NodeKind.Group) + { + await ToggleAsync(node).ConfigureAwait(true); + return; + } + if (node.Kind == NodeKind.Connection) + { + if (node.Owner is { State: not ConnectionState.Connected } entry) + { + await ConnectAsync(entry).ConfigureAwait(true); + } + else + { + await ToggleAsync(node).ConfigureAwait(true); + } + return; + } + if (node.Session is not { } session) + { + return; + } + switch (node.Kind) + { + case NodeKind.Collection or NodeKind.View: + session.OpenCollection(node.Database, node.Collection!.Name); + break; + case NodeKind.Bucket: + session.OpenGridFs(node.Database, node.Bucket!.Name); + break; + case NodeKind.Database: + session.OpenObjects(node.Database); + if (!node.IsExpanded) + { + await ExpandAsync(node).ConfigureAwait(true); + } + break; + case NodeKind.Folder when node.Folder == FolderKind.Users: + session.OpenUsers(node.Database); + break; + case NodeKind.User: + session.OpenUsers(node.Database); + break; + case NodeKind.Role: + session.OpenUsers(node.Database, roles: true); + break; + case NodeKind.Function: + session.OpenQuery(node.Database, $"db.system.js.findOne({{ _id: {BsonText.Quote(node.Name)} }})", run: true); + break; + case NodeKind.Folder: + await ToggleAsync(node).ConfigureAwait(true); + break; + } + } + + /// 一条连接的状态变了(连上、没连上、断开):选中的正是它那一行就重读底部信息区。 + internal void RefreshDetailsFor(ConnectionEntry entry) + { + if (_selectedNode is { Kind: NodeKind.Connection } node && node.Owner == entry) + { + _ = LoadDetailsAsync(node); + } + } + + private async Task LoadDetailsAsync(TreeNode? node) + { + _detailLoad?.Cancel(); + _detailLoad?.Dispose(); + var cts = new CancellationTokenSource(); + _detailLoad = cts; + DetailRows.Clear(); + if (node is null) + { + DetailTitle = ""; + DetailKind = ""; + return; + } + DetailTitle = node.Name; + if (node.Kind == NodeKind.Connection && node.Owner is { } owner && owner.Session is null) + { + // 没连着的连接:信息区讲它连的是哪儿,不去连。 + DetailKind = Loc[owner.State == ConnectionState.Failed ? "Conn_StateFailed" : "Conn_StateOffline"]; + DetailRows.Add(new(Loc["Detail_Endpoint"], owner.Detail.Replace("MongoDB · ", "", StringComparison.Ordinal))); + DetailRows.Add(new(Loc["Detail_User"], owner.Profile.Username is { Length: > 0 } u ? u : Loc["Detail_Anonymous"])); + return; + } + if (node.Session is not { } session) + { + DetailKind = ""; + return; + } + try + { + switch (node.Kind) + { + case NodeKind.Collection: + { + DetailKind = "WiredTiger"; + CollectionStats stats = await session.GetStatsCachedAsync(node.Database, node.Name, cts.Token).ConfigureAwait(true); + if (cts.IsCancellationRequested) + { + return; + } + DetailRows.Add(new(Loc["Detail_Documents"], BsonText.Grouped(stats.Count))); + DetailRows.Add(new(Loc["Detail_StorageIndex"], $"{BsonText.Bytes(stats.Size)} / {BsonText.Bytes(stats.TotalIndexSize)}")); + DetailRows.Add(new(Loc["Detail_Indexes"], Loc.Format("Detail_IndexSummary", stats.IndexCount, BsonText.Bytes(stats.AvgObjSize)))); + break; + } + case NodeKind.View: + DetailKind = Loc["Detail_View"]; + DetailRows.Add(new(Loc["Detail_ViewOn"], node.Collection?.ViewOn ?? "—")); + DetailRows.Add(new(Loc["Detail_Stages"], (node.Collection?.Pipeline?.Count ?? 0).ToString(System.Globalization.CultureInfo.InvariantCulture))); + break; + case NodeKind.Database: + { + DetailKind = Loc["Detail_Database"]; + BsonDocument stats = await session.Connection.GetDatabaseStatsAsync(node.Name, cts.Token).ConfigureAwait(true); + if (cts.IsCancellationRequested) + { + return; + } + IReadOnlyList all = session.CollectionsOf(node.Name); + int views = all.Count(static c => c.Kind == CollectionKind.View); + int buckets = MongoConnection.FindBuckets(node.Name, all).Count; + DetailRows.Add(new(Loc["Detail_CollectionsViews"], $"{stats.GetValue("collections", 0).ToInt64() - (buckets * 2)} / {views}")); + DetailRows.Add(new(Loc["Detail_DataIndex"], + $"{BsonText.Bytes(stats.GetValue("dataSize", 0).ToInt64())} / {BsonText.Bytes(stats.GetValue("indexSize", 0).ToInt64())}")); + DetailRows.Add(new("GridFS", Loc.Format("Detail_Buckets", buckets))); + break; + } + case NodeKind.Bucket: + { + DetailKind = Loc["Detail_Bucket"]; + GridFsBucketInfo bucket = node.Bucket!; + CollectionStats files = await session.GetStatsCachedAsync(node.Database, bucket.FilesCollection, cts.Token).ConfigureAwait(true); + CollectionStats chunks = await session.GetStatsCachedAsync(node.Database, bucket.ChunksCollection, cts.Token).ConfigureAwait(true); + if (cts.IsCancellationRequested) + { + return; + } + DetailRows.Add(new(Loc["Detail_Files"], BsonText.Grouped(files.Count))); + DetailRows.Add(new(Loc["Detail_TotalSize"], BsonText.Bytes(chunks.Size))); + DetailRows.Add(new(Loc["Detail_Chunks"], $"{BsonText.Grouped(chunks.Count)} × 255 KB")); + break; + } + case NodeKind.Connection: + DetailKind = session.Connection.Server.Badge; + DetailRows.Add(new(Loc["Detail_Version"], session.Connection.Server.Version)); + DetailRows.Add(new(Loc["Detail_Endpoint"], session.Connection.Endpoint)); + DetailRows.Add(new(Loc["Detail_User"], session.Connection.Privileges.User is { Length: > 0 } u ? u : Loc["Detail_Anonymous"])); + break; + default: + DetailKind = ""; + break; + } + } + catch (Exception ex) when (ex is MongoException or TimeoutException or OperationCanceledException) + { + // 信息区是锦上添花:取不到就留空。 + } + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Shell/MongoWorkspaceViewModel.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Shell/MongoWorkspaceViewModel.cs new file mode 100644 index 0000000..529bd2b --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Shell/MongoWorkspaceViewModel.cs @@ -0,0 +1,487 @@ +using System.Collections.ObjectModel; +using Avalonia.Threading; +using VelaShell.Plugin.Mongo.Bson; +using VelaShell.Plugin.Mongo.Core; +using VelaShell.PluginSdk; +using VelaShell.PluginSdk.Logging; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 剪贴板与文件选择框只有 TopLevel 才拿得到,由视图注入。 +internal interface IViewServices +{ + /// 复制到剪贴板。 + Task CopyAsync(string text); + + /// 选保存路径。 + Task PickSaveFileAsync(string title, string suggestedName, IReadOnlyList kinds); + + /// 选要打开的文件。 + Task> PickOpenFilesAsync(string title, IReadOnlyList kinds, bool multiple); + + /// 选文件夹。 + Task PickFolderAsync(string title); +} + +/// +/// MongoDB 工作台的外壳(从命令面板打开的那一个面板,与 Docker 面板同一个路数)。 +/// +/// 连接由插件自己管:对象树的根上是全部已保存的连接(Navicat 的习惯),双击连上,连着的那条下面挂库; +/// 标签页属于各自的连接(),工具栏与状态条跟着当前标签 / 选中行所在的那条连接走。 +/// 新建 / 编辑连接是设计稿 10 的对话框,连接中 / 连接失败是设计稿 22 的占位标签。 +/// +/// +internal sealed partial class MongoWorkspaceViewModel : ObservableObject, IWorkbench, IDisposable +{ + private readonly IPluginContext _context; + private int _released; + private bool _disposed; + private WorkspaceTab? _activeTab; + private DialogViewModel? _dialog; + private MongoSession? _currentSession; + + /// 构造。 + /// 文案表。 + /// 插件上下文。 + /// 插件私有的持久化。 + public MongoWorkspaceViewModel(Loc loc, IPluginContext context, MongoStore store) + { + Loc = loc; + _context = context; + Store = store; + Log = context.Log; + Profiles = new MongoProfileStore(context); + Connector = new MongoConnector(context, loc); + InitializeCommands(); + } + + /// + public Loc Loc { get; } + + /// + public MongoStore Store { get; } + + /// + public IPluginLogger Log { get; } + + /// 已保存连接的读写。 + internal MongoProfileStore Profiles { get; } + + /// 连接与测试。 + internal MongoConnector Connector { get; } + + /// 插件上下文(连接对话框要列宿主里已保存的 SSH 连接)。 + internal IPluginContext Context => _context; + + /// 视图注入的剪贴板与文件选择。 + internal IViewServices? ViewServices { get; set; } + + /// 对象树根上的连接。 + public ObservableCollection Connections { get; } = []; + + /// 一条已保存的连接都没有(内容区给「新建连接」的空状态)。 + public bool HasNoConnections => Connections.Count == 0; + + /// + /// 当前连接:活动标签所属的那条;没有标签时是选中行所在的那条。 + /// 工具栏的大按钮、只读开关、服务器徽章与状态条都作用于它。 + /// + public MongoSession? CurrentSession + { + get => _currentSession; + private set + { + if (!SetProperty(ref _currentSession, value)) + { + return; + } + RaiseSessionProperties(); + } + } + + /// 有当前连接(工具栏右侧的只读开关与服务器徽章只在这时出现)。 + public bool HasSession => _currentSession is not null; + + private void UpdateCurrentSession() => + CurrentSession = _activeTab?.Owner as MongoSession + ?? SelectedNode?.Session + ?? (_activeTab is null ? Connections.Select(static c => c.Session).OfType().FirstOrDefault() : null); + + /// 某条连接的护栏、可用性或延迟变了;是当前连接就刷新工具栏与状态条。 + internal void OnSessionChanged(MongoSession session) + { + if (ReferenceEquals(session, _currentSession)) + { + RaiseSessionProperties(); + } + } + + private void RaiseSessionProperties() => RaisePropertiesChanged( + nameof(HasSession), nameof(IsReadOnly), nameof(ReadOnlyLabel), nameof(ServerBadge), nameof(ServerDetail), + nameof(ServerDotClass), nameof(BannerMessage), nameof(HasBanner), nameof(CanOfferDropDatabase), + nameof(ShowSystemDatabases), nameof(SystemDatabasesTip), nameof(StatusConnection), nameof(StatusScope), nameof(StatusDetail), nameof(StatusDotClass)); + + // ── 工具栏右侧:只读开关 + 服务器徽章 ────────────────────────────────── + + /// 当前连接是否只读(开关双向绑定;解锁生产连接要确认,走 )。 + public bool IsReadOnly + { + get => _currentSession?.Guard.IsReadOnly ?? false; + set + { + if (_currentSession is { } session) + { + session.Guard.IsReadOnly = value; + } + } + } + + /// 开关上的字:「读写」/「只读」。 + public string ReadOnlyLabel => IsReadOnly ? Loc["Toolbar_ReadOnlyOn"] : Loc["Toolbar_ReadWrite"]; + + /// 服务器徽章(rs0 · PRIMARY)。 + public string ServerBadge => _currentSession?.Connection.Server.Badge ?? ""; + + /// 徽章下面那行小字(版本 · 地址 · 延迟)。 + public string ServerDetail + { + get + { + if (_currentSession is not { } session) + { + return ""; + } + ServerInfo server = session.Connection.Server; + string address = server.Me is { Length: > 0 } me ? me : session.Connection.Endpoint; + return $"MongoDB {server.Version} · {address}" + (session.LatencyMs is { } ms ? $" · {ms} ms" : ""); + } + } + + /// 徽章前的色点:连着且可写绿、只读成员橙、断了红。 + public string ServerDotClass => _currentSession is not { } session + ? "off" + : !session.IsAvailable ? "err" : session.Connection.Server.IsWritable ? "ok" : "warn"; + + /// 当前连接断了时内容区顶上的横幅。 + public string BannerMessage => _currentSession is { IsAvailable: false } ? Loc["Workspace_Disconnected"] : ""; + + /// 有横幅。 + public bool HasBanner => BannerMessage.Length > 0; + + /// 对象树的右键菜单要不要给「删除数据库」(连接里禁用了就不给)。 + public bool CanOfferDropDatabase => _currentSession is { } session && !session.Guard.DisableDropDatabase; + + // ── 状态条(设计稿底部那一行:连接 · 范围 · 当前标签的状态)────────────── + + /// 状态条左边的连接名。 + public string StatusConnection => _currentSession?.ConnectionName ?? ""; + + /// 状态条上连接名前的色点。 + public string StatusDotClass => ServerDotClass; + + /// 状态条上的范围(shop.orders)。 + public string StatusScope + { + get + { + if (_currentSession is not { } session) + { + return ""; + } + (string? db, string? coll) = session.Scope; + return db is null ? "" : coll is null ? db : $"{db}.{coll}"; + } + } + + /// 当前标签自己的状态(shop.orders · 50 行 · 12 ms · IXSCAN)。 + public string StatusText => _activeTab?.StatusText is { Length: > 0 } text ? text : ""; + + /// 状态条连接名后面那一段:标签有自己的状态就用它(那里已经带着范围),没有就只写范围。 + public string StatusDetail => StatusText.Length > 0 ? StatusText : StatusScope; + + /// + public void NotifyStatusChanged() => RaisePropertiesChanged(nameof(StatusText), nameof(StatusScope), nameof(StatusDetail)); + + // ── 标签页 ───────────────────────────────────────────────────────────── + + /// 对象标签。 + public ObservableCollection Tabs { get; } = []; + + /// 活动标签。 + public WorkspaceTab? ActiveTab + { + get => _activeTab; + set + { + WorkspaceTab? previous = _activeTab; + if (!SetProperty(ref _activeTab, value)) + { + return; + } + if (previous is not null) + { + previous.IsActive = false; + } + if (value is not null) + { + value.IsActive = true; + } + RaisePropertiesChanged(nameof(ActiveTool), nameof(HasTabs)); + UpdateCurrentSession(); + NotifyStatusChanged(); + RevealInTree(value); + } + } + + /// 有标签(没有时内容区给空状态)。 + public bool HasTabs => Tabs.Count > 0; + + /// 工具栏上哪个大按钮亮着。 + public string ActiveTool => _activeTab switch + { + null => "", + ObjectsTabViewModel { Filter: ObjectFilter.Views } => "views", + ObjectsTabViewModel => "collections", + CollectionTabViewModel => "collections", + QueryTabViewModel => "query", + PipelineTabViewModel => "aggregate", + GridFsTabViewModel => "gridfs", + DesignTabViewModel { Page: DesignPage.Indexes } => "indexes", + DesignTabViewModel => "collections", + MonitorTabViewModel => "monitor", + ProfilerTabViewModel => "profiler", + UsersTabViewModel { ShowRoles: true } => "roles", + UsersTabViewModel => "users", + _ => "" + }; + + internal void OnTabToolChanged() => RaisePropertyChanged(nameof(ActiveTool)); + + /// + /// 挂上一个标签:同一条连接里同键的已经开着就切过去(新建的那个扔掉)。新标签开在当前标签右边(Navicat 的习惯)。 + /// + internal T Activate(T tab) where T : WorkspaceTab + { + if (Tabs.FirstOrDefault(t => t.Key == tab.Key && ReferenceEquals(t.Owner, tab.Owner)) is T existing) + { + tab.Dispose(); + ActiveTab = existing; + return existing; + } + tab.Closer = CloseTabAsync; + int index = _activeTab is null ? Tabs.Count : Tabs.IndexOf(_activeTab) + 1; + Tabs.Insert(Math.Clamp(index, 0, Tabs.Count), tab); + ActiveTab = tab; + RaisePropertyChanged(nameof(HasTabs)); + _ = LoadTabAsync(tab); + return tab; + } + + private async Task LoadTabAsync(WorkspaceTab tab) + { + try + { + await tab.LoadAsync().ConfigureAwait(true); + } + catch (Exception ex) + { + Log.Error($"Loading tab '{tab.Header}' failed.", ex); + Toast(new() { Title = Loc.Format("Common_Failed", MongoConnector.Describe(ex)), Kind = ToastKind.Error }); + } + } + + /// 关一个标签(有未提交的修改先问)。 + internal async Task CloseTabAsync(WorkspaceTab tab) + { + if (!tab.CanClose || !await tab.ConfirmCloseAsync().ConfigureAwait(true)) + { + return; + } + RemoveTab(tab); + } + + private void RemoveTab(WorkspaceTab tab) + { + int index = Tabs.IndexOf(tab); + if (index < 0) + { + return; + } + Tabs.RemoveAt(index); + if (ReferenceEquals(ActiveTab, tab)) + { + ActiveTab = Tabs.Count == 0 ? null : Tabs[Math.Clamp(index - 1, 0, Tabs.Count - 1)]; + } + tab.Dispose(); + RaisePropertyChanged(nameof(HasTabs)); + UpdateCurrentSession(); + } + + /// 不问就关掉一批标签(它们指向的东西已经不在了)。 + internal void CloseTabsWhere(Func predicate) + { + foreach (WorkspaceTab tab in Tabs.Where(predicate).ToList()) + { + RemoveTab(tab); + } + } + + // ── 覆盖层 ───────────────────────────────────────────────────────────── + + /// 当前对话框。 + public DialogViewModel? Dialog + { + get => _dialog; + private set + { + if (SetProperty(ref _dialog, value)) + { + RaisePropertyChanged(nameof(HasDialog)); + } + } + } + + /// 有对话框(覆盖层与遮罩)。 + public bool HasDialog => _dialog is not null; + + /// 右下角的提示。 + public ObservableCollection Toasts { get; } = []; + + /// + public void ShowDialog(DialogViewModel dialog) + { + if (Dialog is { } open && !ReferenceEquals(open, dialog)) + { + CloseDialog(open); + } + Dialog = dialog; + } + + /// + public void CloseDialog(DialogViewModel dialog) + { + if (!ReferenceEquals(Dialog, dialog)) + { + return; + } + Dialog = null; + dialog.OnClosed(); + (dialog as IDisposable)?.Dispose(); + } + + /// + public Task ConfirmAsync(ConfirmRequest request) + { + var dialog = new ConfirmDialogViewModel(this, request); + // 确认框可能是从另一个对话框里弹出来的(向导执行前、连接对话框里删连接):先记住它,确认完再放回去。 + DialogViewModel? underneath = Dialog; + Dialog = dialog; + return AwaitAndRestore(); + + async Task AwaitAndRestore() + { + bool result = await dialog.Result.ConfigureAwait(true); + if (underneath is not null && Dialog is null) + { + Dialog = underneath; + } + return result; + } + } + + /// + public void Toast(ToastRequest toast) => Dispatcher.UIThread.Post(() => + { + // 最多叠三条:更多的提示只会把内容区挡住,而早的那几条已经过时了。 + while (Toasts.Count >= 3) + { + Toasts.RemoveAt(0); + } + Toasts.Add(new(toast, t => Toasts.Remove(t))); + }); + + /// + public Task CopyAsync(string text) => ViewServices?.CopyAsync(text) ?? Task.CompletedTask; + + /// + public Task PickSaveFileAsync(string title, string suggestedName, IReadOnlyList kinds) => + ViewServices?.PickSaveFileAsync(title, suggestedName, kinds) ?? Task.FromResult(null); + + /// + public Task> PickOpenFilesAsync(string title, IReadOnlyList kinds, bool multiple = false) => + ViewServices?.PickOpenFilesAsync(title, kinds, multiple) ?? Task.FromResult>([]); + + /// + public Task PickFolderAsync(string title) => + ViewServices?.PickFolderAsync(title) ?? Task.FromResult(null); + + /// 集合名在 mongosh 里的写法:合法标识符直接写,否则 getCollection("…")。 + internal static string ShellCollectionRef(string collection) => + System.Text.RegularExpressions.Regex.IsMatch(collection, "^[A-Za-z_$][A-Za-z0-9_$]*$") + ? collection + : $"getCollection({BsonText.Quote(collection)})"; + + internal static string Count(long n) => BsonText.Grouped(n); + + /// + /// 放掉服务器那一侧:在连的取消,连着的断开(驱动连接 + 跳板转发)。**不碰任何界面对象**, + /// 所以哪个线程上都能调 —— 宿主退出时面板的 Closed 是在线程池上触发的,那时 UI 线程正被宿主同步等着, + /// 界面那一半()排不上,但连接与 SSH 转发不能因此漏关。只生效一次。 + /// + /// 全部断开之后完成;不抛。 + internal Task ReleaseConnectionsAsync() + { + if (Interlocked.Exchange(ref _released, 1) != 0) + { + return Task.CompletedTask; + } + var closing = new List(); + foreach (ConnectionEntry entry in Connections.ToArray()) + { + entry.Connecting?.Cancel(); + if (entry.Session is { } session) + { + closing.Add(CloseQuietlyAsync(session.Link)); + } + } + return Task.WhenAll(closing); + + static async Task CloseQuietlyAsync(MongoLink link) + { + try + { + await link.DisposeAsync().ConfigureAwait(false); + } +#pragma warning disable CA1031 // 收尾路径:驱动、套接字、跳板各有各的异常,哪一种都不该让停用半途而废。 + catch (Exception) +#pragma warning restore CA1031 + { + } + } + } + + /// 关掉全部标签与连接(面板关了)。只能在 UI 线程上调(标签、对象树都绑在界面上);重复调用无害。 + public void Dispose() + { + if (_disposed) + { + return; + } + _disposed = true; + _ = ReleaseConnectionsAsync(); + foreach (WorkspaceTab tab in Tabs) + { + tab.Dispose(); + } + Tabs.Clear(); + DisposeTree(); + foreach (ConnectionEntry entry in Connections) + { + if (entry.Session is { } session) + { + entry.Session = null; + session.Dispose(); + } + } + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Shell/TreeNode.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Shell/TreeNode.cs new file mode 100644 index 0000000..b74e2c3 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Shell/TreeNode.cs @@ -0,0 +1,227 @@ +using System.Collections.ObjectModel; +using VelaShell.Plugin.Mongo.Core; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 对象树节点的种类。 +internal enum NodeKind +{ + /// 连接分组(对象树根上的一节,不属于任何连接)。 + Group, + + /// 连接(根)。 + Connection, + + /// 数据库。 + Database, + + /// 分组文件夹(集合 / 视图 / GridFS 存储桶 / 用户与角色 / 存储函数)。 + Folder, + + /// 集合(含时序、固定、聚簇)。 + Collection, + + /// 视图。 + View, + + /// GridFS 桶。 + Bucket, + + /// 用户。 + User, + + /// 自定义角色。 + Role, + + /// 存储函数(system.js 里的一条)。 + Function, + + /// "加载中…" / 错误占位。 + Placeholder +} + +/// 分组文件夹的种类。 +internal enum FolderKind +{ + /// 集合。 + Collections, + + /// 视图。 + Views, + + /// GridFS 存储桶。 + Buckets, + + /// 用户与角色。 + Users, + + /// 存储函数。 + Functions +} + +/// +/// 对象树的一个节点。树被**拍平**成一张虚拟化列表来画(见 ): +/// 缩进由 决定,选中底色贯穿整行 —— TreeView 的缩进画在选中底色之外, +/// 与设计稿那种"整行高亮 + 左侧强调条"对不上。 +/// +internal sealed class TreeNode : ObservableObject +{ + private bool _isExpanded; + private string _meta = ""; + private string? _tag; + private string _tagClass = "muted"; + private string _name; + private string _dotClass = "off"; + + /// 构造。 + /// 种类。 + /// 显示名。 + /// 缩进层级。 + /// 父行。 + /// 所属连接;不给就随父行(只有根行要给)。 + public TreeNode(NodeKind kind, string name, int depth, TreeNode? parent, ConnectionEntry? owner = null) + { + Kind = kind; + _name = name; + Depth = depth; + Parent = parent; + Owner = owner ?? parent?.Owner; + } + + /// 这一行属于哪条连接(根上可以挂好几条)。 + public ConnectionEntry? Owner { get; } + + /// 它那条连接此刻的会话;没连着为 。 + public MongoSession? Session => Owner?.Session; + + /// 连接行的色点:ok 连着、connecting 正在连、err 没连上、off 没连。 + public string DotClass + { + get => _dotClass; + set => SetProperty(ref _dotClass, value); + } + + /// 种类。 + public NodeKind Kind { get; } + + /// 显示名。 + public string Name + { + get => _name; + set => SetProperty(ref _name, value); + } + + /// 层级(0 = 连接)。 + public int Depth { get; } + + /// 父节点。 + public TreeNode? Parent { get; } + + /// 所属库(连接节点为空)。 + public string Database { get; init; } = ""; + + /// 文件夹种类(仅 )。 + public FolderKind Folder { get; init; } + + /// 集合信息(集合 / 视图节点)。 + public CollectionInfo? Collection { get; init; } + + /// GridFS 桶(桶节点)。 + public GridFsBucketInfo? Bucket { get; init; } + + /// 子节点。 + public ObservableCollection Children { get; } = []; + + /// 子节点是否已加载(库节点惰性加载)。 + public bool IsLoaded { get; set; } + + /// 能不能展开(文件夹、库、连接)。 + public bool IsExpandable => Kind is NodeKind.Group or NodeKind.Connection or NodeKind.Database or NodeKind.Folder; + + /// 展开状态。 + public bool IsExpanded + { + get => _isExpanded; + set + { + if (SetProperty(ref _isExpanded, value)) + { + RaisePropertiesChanged(nameof(ChevronKey), nameof(IconToken), nameof(IsEmphasized)); + } + } + } + + /// 右侧小字(大小 / 计数 / 版本)。 + public string Meta + { + get => _meta; + set => SetProperty(ref _meta, value); + } + + /// 徽章文字(TTL / 时序 / 固定);没有为 。 + public string? Tag + { + get => _tag; + set + { + if (SetProperty(ref _tag, value)) + { + RaisePropertyChanged(nameof(HasTag)); + } + } + } + + /// 徽章配色类(warn / info / muted)。 + public string TagClass + { + get => _tagClass; + set => SetProperty(ref _tagClass, value); + } + + /// 有没有徽章。 + public bool HasTag => !string.IsNullOrEmpty(_tag); + + /// 左侧缩进(设计稿:连接 8、库 22、文件夹 36、对象 50 —— 每层 14)。 + public double Indent => 8 + (Depth * 14); + + /// 折叠箭头;叶子节点为空(留位不画)。 + public string? ChevronKey => IsExpandable ? (IsExpanded ? "Mongo.chevron-down" : "Mongo.chevron-right") : null; + + /// 行高:连接根行 30、其余 26(设计稿 01 的对象树)。 + public double RowHeight => Kind == NodeKind.Connection ? 30 : 26; + + /// 是不是连接根行(画状态圆点而不是图标)。 + public bool IsConnection => Kind == NodeKind.Connection; + + /// 名字加粗、用主文字色(连接行、展开的库)。 + public bool IsEmphasized => Kind == NodeKind.Connection || (Kind == NodeKind.Database && IsExpanded); + + /// 图标。 + public string IconKey => Kind switch + { + NodeKind.Database => "Mongo.database", + NodeKind.Folder or NodeKind.Group => "Mongo.folder", + NodeKind.Collection when Collection?.Kind == CollectionKind.TimeSeries => "Mongo.chart-no-axes-column", + NodeKind.Collection => "Mongo.table-2", + NodeKind.View => "Mongo.eye", + NodeKind.Bucket => "Mongo.hard-drive", + NodeKind.User => "Mongo.user", + NodeKind.Role => "Mongo.shield", + NodeKind.Function => "Mongo.square-code", + NodeKind.Placeholder => "Mongo.loader-circle", + _ => "Mongo.leaf" + }; + + /// 图标颜色令牌。 + public string IconToken => Kind switch + { + NodeKind.Database => IsExpanded ? "VelaWarning" : "VelaTextMuted", + NodeKind.Folder or NodeKind.Group => "VelaFileFolderIcon", + NodeKind.Collection or NodeKind.View or NodeKind.User or NodeKind.Role or NodeKind.Function => "VelaInfo", + NodeKind.Bucket => "VelaWarning", + _ => "VelaTextMuted" + }; + + /// 命名空间(集合 / 视图 / 桶)。 + public string? Namespace => Collection?.Namespace ?? (Bucket is { } b ? $"{b.Database}.{b.Name}" : null); +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Shell/ViewLocator.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Shell/ViewLocator.cs new file mode 100644 index 0000000..07704a4 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Shell/ViewLocator.cs @@ -0,0 +1,145 @@ +using System.Collections.Specialized; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Templates; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 自带视图的视图模型。各功能区自己的小对话框(导出为代码、新建存储桶、编辑 metadata…)实现它, +/// 就能经 挂到覆盖层上,而不必回来改下面那张表。 +/// +internal interface IViewFactory +{ + /// 建视图(只调一次)。 + Control CreateView(); +} + +/// +/// 视图模型 → 视图。一张显式的表,而不是按命名约定反射: +/// 插件跑在独立的装载上下文里,反射找类型在裁剪 / 单文件发布下都可能找空; +/// 而一张表在编译期就保证了每个视图模型都有视图。 +/// +internal sealed class ViewLocator : IDataTemplate +{ + /// 共享实例。 + public static ViewLocator Instance { get; } = new(); + + /// + /// 对话框视图按视图模型缓存:确认框会临时顶掉当前对话框、确认完再放回来, + /// 重建视图的话滚动位置、焦点、编辑器光标与撤销栈全丢(文档编辑器里改到一半弹一次确认就前功尽弃)。 + /// 弱引用表:对话框关掉、视图模型被回收,视图随之回收。 + /// + private readonly System.Runtime.CompilerServices.ConditionalWeakTable _dialogViews = []; + + /// + public Control? Build(object? param) => + param is DialogViewModel dialog ? _dialogViews.GetValue(dialog, d => Create(d)!) : Create(param); + + private static Control? Create(object? param) => param switch + { + ObjectsTabViewModel vm => new ObjectsTabView(vm), + CollectionTabViewModel vm => new CollectionTabView(vm), + QueryTabViewModel vm => new QueryTabView(vm), + PipelineTabViewModel vm => new PipelineTabView(vm), + GridFsTabViewModel vm => new GridFsTabView(vm), + DesignTabViewModel vm => new DesignTabView(vm), + MonitorTabViewModel vm => new MonitorTabView(vm), + ProfilerTabViewModel vm => new ProfilerTabView(vm), + UsersTabViewModel vm => new UsersTabView(vm), + ConfirmDialogViewModel vm => new ConfirmDialogView(vm), + ConnectionStateTabViewModel vm => new ConnectionStateTabView(vm), + ConnectionDialogViewModel vm => new ConnectionDialogView(vm), + NewCollectionDialogViewModel vm => new NewCollectionDialogView(vm), + DocumentEditorDialogViewModel vm => new DocumentEditorDialogView(vm), + ConflictDialogViewModel vm => new ConflictDialogView(vm), + ExportWizardViewModel vm => new ExportWizardView(vm), + ImportWizardViewModel vm => new ImportWizardView(vm), + TransferWizardViewModel vm => new TransferWizardView(vm), + IViewFactory factory => factory.CreateView(), + _ => new TextBlock { Text = param?.GetType().Name ?? "" } + }; + + /// + public bool Match(object? data) => data is WorkspaceTab or DialogViewModel; +} + +/// +/// 标签页内容宿主:每个标签的视图**只建一次**,切换标签只是换可见性。 +/// +/// 用一个 ContentControl 绑当前标签的话,每次切换都会把视图拆掉重建 —— +/// 查询编辑器丢光标与撤销栈、网格丢滚动位置、监控页的图表从头再画。 +/// 这一点在设计稿的使用方式下是致命的:用户在「orders」「查询 1」「fs」之间来回切是常态。 +/// +/// +internal sealed class TabContentHost : Panel +{ + private readonly Dictionary _views = []; + + /// 标签集合。 + public static readonly StyledProperty?> TabsProperty = + AvaloniaProperty.Register?>(nameof(Tabs)); + + /// 当前标签。 + public static readonly StyledProperty ActiveProperty = + AvaloniaProperty.Register(nameof(Active)); + + /// 标签集合。 + public IEnumerable? Tabs + { + get => GetValue(TabsProperty); + set => SetValue(TabsProperty, value); + } + + /// 当前标签。 + public WorkspaceTab? Active + { + get => GetValue(ActiveProperty); + set => SetValue(ActiveProperty, value); + } + + /// + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == TabsProperty) + { + if (change.OldValue is INotifyCollectionChanged old) + { + old.CollectionChanged -= OnTabsChanged; + } + if (change.NewValue is INotifyCollectionChanged next) + { + next.CollectionChanged += OnTabsChanged; + } + Sync(); + } + else if (change.Property == ActiveProperty) + { + Sync(); + } + } + + private void OnTabsChanged(object? sender, NotifyCollectionChangedEventArgs e) => Sync(); + + private void Sync() + { + HashSet alive = [.. Tabs ?? []]; + foreach (WorkspaceTab gone in _views.Keys.Where(t => !alive.Contains(t)).ToList()) + { + Children.Remove(_views[gone]); + _views.Remove(gone); + } + if (Active is { } active && !_views.ContainsKey(active)) + { + Control view = ViewLocator.Instance.Build(active) ?? new Panel(); + view.DataContext = active; + _views[active] = view; + Children.Add(view); + } + foreach ((WorkspaceTab tab, Control view) in _views) + { + view.IsVisible = ReferenceEquals(tab, Active); + } + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Shell/WorkspaceTab.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Shell/WorkspaceTab.cs new file mode 100644 index 0000000..529791c --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Shell/WorkspaceTab.cs @@ -0,0 +1,216 @@ +namespace VelaShell.Plugin.Mongo.Ui; + +/// 对象标签页的种类(决定 Navicat 工具栏哪个大按钮亮着)。 +public enum TabKind +{ + /// 连接中 / 连接失败的占位标签。 + Connection, + + /// 对象列表。 + Objects, + + /// 集合工作台。 + Collection, + + /// 查询编辑器。 + Query, + + /// 聚合管道。 + Pipeline, + + /// GridFS。 + GridFs, + + /// 集合设计。 + Design, + + /// 服务器监控。 + Monitor, + + /// 慢查询。 + Profiler, + + /// 用户与角色。 + Users +} + +/// +/// 一个对象标签页(工作台主区里那一排:对象 / orders @shop / 查询 1 / …)。 +/// +/// 外壳只认这个基类:标题、图标、"有未提交修改"圆点、关闭前确认、给状态栏的那一行。 +/// 内容由派生类自己画(视图经 按视图模型类型找到)。 +/// +/// +internal abstract class WorkspaceTab : ObservableObject, IDisposable +{ + private string _title = ""; + private string _scope = ""; + private bool _isModified; + private bool _isActive; + private string _statusText = ""; + private readonly IMongoWorkspace? _workspace; + + /// 构造一条连接里的标签页。 + /// 这条连接的服务。 + protected WorkspaceTab(IMongoWorkspace workspace) + : this((IWorkbench)workspace) + { + _workspace = workspace; + } + + /// + /// 构造外壳自己的标签页(连接中 / 连接失败的占位卡):不属于任何一条连接, 不可用。 + /// + /// 外壳服务。 + protected WorkspaceTab(IWorkbench workbench) + { + Workbench = workbench; + CloseCommand = new AsyncCommand(() => Closer?.Invoke(this) ?? Task.CompletedTask); + } + + /// 这条连接的服务。外壳自己的标签页没有连接,读它是编程错误。 + public IMongoWorkspace Workspace => + _workspace ?? throw new InvalidOperationException(GetType().Name + " does not belong to a connection."); + + /// 外壳服务。 + public IWorkbench Workbench { get; } + + /// 它属于哪条连接;外壳自己的标签页为 。 + public IMongoWorkspace? Owner => _workspace; + + /// 关掉自己(外壳在挂上标签时设好)。 + internal Func? Closer { get; set; } + + /// 文案表(视图里 {Binding Loc[...]} 用)。 + public Loc Loc => Workbench.Loc; + + /// 种类。 + public abstract TabKind Kind { get; } + + /// 同一对象只开一个标签:相同键的标签被复用而不是再开一个。 + public abstract string Key { get; } + + /// 标题(orders、查询 1、设计 · orders)。 + public string Title + { + get => _title; + set + { + if (SetProperty(ref _title, value)) + { + RaisePropertyChanged(nameof(Header)); + } + } + } + + /// 作用域后缀(@shop);没有为空。 + public string Scope + { + get => _scope; + set + { + if (SetProperty(ref _scope, value)) + { + RaisePropertyChanged(nameof(Header)); + } + } + } + + /// 标签上显示的完整文字。 + public string Header => string.IsNullOrEmpty(Scope) ? Title : $"{Title} {Scope}"; + + /// 标签图标(Mongo.table-2)。 + public abstract string IconKey { get; } + + /// 标签图标颜色令牌。 + public abstract string IconToken { get; } + + /// 有未提交的修改(标签上的橙点,关闭前要确认)。 + public bool IsModified + { + get => _isModified; + protected set => SetProperty(ref _isModified, value); + } + + /// 当前是不是活动标签。 + public bool IsActive + { + get => _isActive; + set + { + if (!SetProperty(ref _isActive, value)) + { + return; + } + if (value) + { + OnActivated(); + } + else + { + OnDeactivated(); + } + } + } + + /// + /// 给宿主状态栏的那一行(shop.orders · 50 行 · 12 ms · IXSCAN status_1_createdAt_-1)。 + /// + public string StatusText + { + get => _statusText; + protected set + { + if (SetProperty(ref _statusText, value) && IsActive) + { + Workbench.NotifyStatusChanged(); + } + } + } + + /// 能不能关(对象列表那个固定标签不能关)。 + public virtual bool CanClose => true; + + /// 关闭命令。 + public AsyncCommand CloseCommand { get; } + + /// 第一次显示时加载(外壳在标签加进来之后调一次)。 + public virtual Task LoadAsync() => Task.CompletedTask; + + /// 变成活动标签时(刷新过期数据之类)。 + protected virtual void OnActivated() + { + } + + /// 不再是活动标签时(停采样、停轮询之类)。 + protected virtual void OnDeactivated() + { + } + + /// + /// 关闭前的确认。有未提交修改时问一句;返回 即不关。 + /// + public virtual async Task ConfirmCloseAsync() + { + if (!IsModified) + { + return true; + } + return await Workbench.ConfirmAsync(new() + { + Title = Loc["Tab_CloseModifiedTitle"], + Message = Loc.Format("Tab_CloseModifiedBody", Header), + ConfirmLabel = Loc["Tab_CloseDiscard"], + IconKey = "Mongo.triangle-alert", + Danger = true + }).ConfigureAwait(true); + } + + /// F5 / Ctrl+R 之类的"刷新当前页"。 + public virtual Task RefreshAsync() => Task.CompletedTask; + + /// + public virtual void Dispose() + { + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Shell/WriteGuard.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Shell/WriteGuard.cs new file mode 100644 index 0000000..271cb18 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Shell/WriteGuard.cs @@ -0,0 +1,82 @@ +using VelaShell.Plugin.Mongo.Core; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 写护栏:只读开关、写前确认、dropDatabase 禁用、权限置灰。 +/// +/// 只读是**客户端**护栏 —— 真正的只读应当落在服务器端的用户权限上(只授 read)。 +/// 但"生产连接默认只读、写之前手动解锁"这一步拦下的,恰恰是最常见的那一类事故: +/// 在一个以为是测试库的标签页里批量改了数据。 +/// +/// +internal sealed class WriteGuard : ObservableObject +{ + private bool _isReadOnly; + + /// 按连接设置与探到的权限初始化。 + /// 连接设置。 + /// 当前用户的权限。 + public WriteGuard(MongoSettings settings, PrivilegeSummary privileges) + { + Environment = settings.Environment; + _isReadOnly = settings.ReadOnly; + ConfirmWrites = settings.ConfirmWrites; + DisableDropDatabase = settings.DisableDropDatabase; + Privileges = privileges; + } + + /// 环境标记。 + public MongoEnvironment Environment { get; } + + /// 是不是生产连接。 + public bool IsProduction => Environment == MongoEnvironment.Production; + + /// 只读模式(工具栏「读写」开关)。 + public bool IsReadOnly + { + get => _isReadOnly; + set + { + if (SetProperty(ref _isReadOnly, value)) + { + RaisePropertyChanged(nameof(IsWritable)); + Changed?.Invoke(); + } + } + } + + /// 可写(只读关着)。 + public bool IsWritable => !_isReadOnly; + + /// 写操作前二次确认(删除、批量更新、dropCollection 要手打名称)。 + public bool ConfirmWrites { get; } + + /// 不提供 dropDatabase。 + public bool DisableDropDatabase { get; } + + /// 当前用户的权限。 + public PrivilegeSummary Privileges { get; private set; } + + /// 只读状态或权限变了(各标签页据此刷新按钮可用性)。 + public event Action? Changed; + + /// 重探权限之后更新。 + public void UpdatePrivileges(PrivilegeSummary privileges) + { + Privileges = privileges; + Changed?.Invoke(); + } + + /// 在这个库上能不能写;不能时给出原因文案键。 + /// 库名。 + /// 不能写的原因(文案键);能写为 。 + public string? Check(string database) + { + if (_isReadOnly) + { + return "Common_ReadOnlyBlocked"; + } + return Privileges.CanWrite(database) ? null : "Common_NoPrivilege"; + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/SidePanel.cs b/plugins/VelaShell.Plugin.Mongo/Ui/SidePanel.cs new file mode 100644 index 0000000..95aef5b --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/SidePanel.cs @@ -0,0 +1,63 @@ +using System.Runtime.CompilerServices; +using Avalonia; +using Avalonia.Controls; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 数据区右侧面板的收起(Navicat 右下角那颗按钮):挂在「内容 | 分隔条 | 面板」三列的 上, +/// 收起时最后一列宽度归零、分隔条与面板藏起来;展开时恢复收起前用户拖到的宽度。 +/// +public static class SidePanel +{ + /// 收起右侧面板。 + public static readonly AttachedProperty CollapsedProperty = + AvaloniaProperty.RegisterAttached("Collapsed", typeof(SidePanel)); + + private static readonly ConditionalWeakTable SavedWidths = []; + + static SidePanel() + { + CollapsedProperty.Changed.AddClassHandler(static (grid, _) => Apply(grid)); + } + + /// 读。 + public static bool GetCollapsed(Grid grid) => grid.GetValue(CollapsedProperty); + + /// 写。 + public static void SetCollapsed(Grid grid, bool value) => grid.SetValue(CollapsedProperty, value); + + private static void Apply(Grid grid) + { + if (grid.ColumnDefinitions.Count < 3) + { + return; + } + int panel = grid.ColumnDefinitions.Count - 1; + ColumnDefinition column = grid.ColumnDefinitions[panel]; + bool collapsed = GetCollapsed(grid); + if (collapsed) + { + if (column.Width.Value > 0 || column.MinWidth > 0) + { + SavedWidths.AddOrUpdate(grid, new Saved(column.Width, column.MinWidth)); + } + column.MinWidth = 0; + column.Width = new GridLength(0); + } + else if (SavedWidths.TryGetValue(grid, out Saved? saved)) + { + column.Width = saved.Width; + column.MinWidth = saved.MinWidth; + } + foreach (Control child in grid.Children) + { + if (Grid.GetColumn(child) >= panel - 1) + { + child.IsVisible = !collapsed; + } + } + } + + private sealed record Saved(GridLength Width, double MinWidth); +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/TableColumns.cs b/plugins/VelaShell.Plugin.Mongo/Ui/TableColumns.cs new file mode 100644 index 0000000..783f25f --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/TableColumns.cs @@ -0,0 +1,259 @@ +using System.Runtime.CompilerServices; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Styling; +using Avalonia.VisualTree; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 表格列宽:列头一个 、每一行各一个 ,ColumnDefinitions 写成一样, +/// 再挂上同一个组名 —— 列头那一个用 ui:TableColumns.Header,行用 ui:TableColumns.Row。 +/// +/// 列头的每条列间分隔线上自动放一个拖把(MongoColumnGrip):拖动改宽,双击按内容自适应; +/// 改动同步到同组的每一行(包括之后才被虚拟化列表实现出来的行)。 +/// +/// 组按所在的 隔开:两个标签页里同名的表各管各的。 +/// 用户没动过的列保持声明时的样子(* 列照旧填满剩余宽度)。 +/// +public static class TableColumns +{ + /// 列头那个 Grid 的组名。 + public static readonly AttachedProperty HeaderProperty = + AvaloniaProperty.RegisterAttached("Header", typeof(TableColumns)); + + /// 行里那个 Grid 的组名。 + public static readonly AttachedProperty RowProperty = + AvaloniaProperty.RegisterAttached("Row", typeof(TableColumns)); + + /// 列头上不放拖把的列(逗号分隔的下标,如勾选框列、图标列)。 + public static readonly AttachedProperty FixedProperty = + AvaloniaProperty.RegisterAttached("Fixed", typeof(TableColumns)); + + private static readonly ConditionalWeakTable> Scopes = new(); + + static TableColumns() + { + HeaderProperty.Changed.AddClassHandler((grid, e) => Hook(grid, e.NewValue as string, isHeader: true)); + RowProperty.Changed.AddClassHandler((grid, e) => Hook(grid, e.NewValue as string, isHeader: false)); + } + + /// 取列头组名。 + public static string? GetHeader(Grid grid) => grid.GetValue(HeaderProperty); + + /// 设列头组名。 + public static void SetHeader(Grid grid, string? value) => grid.SetValue(HeaderProperty, value); + + /// 取行组名。 + public static string? GetRow(Grid grid) => grid.GetValue(RowProperty); + + /// 设行组名。 + public static void SetRow(Grid grid, string? value) => grid.SetValue(RowProperty, value); + + /// 取不放拖把的列。 + public static string? GetFixed(Grid grid) => grid.GetValue(FixedProperty); + + /// 设不放拖把的列。 + public static void SetFixed(Grid grid, string? value) => grid.SetValue(FixedProperty, value); + + /// 某个 Grid 所在的组(测试用);还没挂上可视树为 。 + internal static Group? GroupOf(Grid grid) + { + string? name = GetHeader(grid) ?? GetRow(grid); + return name is not null && ScopeOf(grid) is { } scope && Scopes.TryGetValue(scope, out Dictionary? groups) + && groups.TryGetValue(name, out Group? group) + ? group + : null; + } + + private static void Hook(Grid grid, string? name, bool isHeader) + { + if (string.IsNullOrEmpty(name)) + { + return; + } + grid.AttachedToVisualTree += (_, _) => Join(grid, name, isHeader); + grid.DetachedFromVisualTree += (_, _) => Leave(grid); + if (grid.IsAttachedToVisualTree()) + { + Join(grid, name, isHeader); + } + } + + private static Control? ScopeOf(Grid grid) => (Control?)grid.FindAncestorOfType() ?? TopLevel.GetTopLevel(grid); + + private static void Join(Grid grid, string name, bool isHeader) + { + if (ScopeOf(grid) is not { } scope) + { + return; + } + Dictionary groups = Scopes.GetOrCreateValue(scope); + if (!groups.TryGetValue(name, out Group? group)) + { + group = new Group(); + groups[name] = group; + } + group.Add(grid, isHeader); + } + + private static void Leave(Grid grid) + { + foreach (Group group in AllGroupsOf(grid)) + { + group.Remove(grid); + } + } + + private static IEnumerable AllGroupsOf(Grid grid) + { + string? name = GetHeader(grid) ?? GetRow(grid); + if (name is null) + { + yield break; + } + foreach (KeyValuePair> scope in Scopes) + { + if (scope.Value.TryGetValue(name, out Group? group) && group.Contains(grid)) + { + yield return group; + } + } + } + + /// 一组共享列宽的 Grid。 + internal sealed class Group + { + private readonly Dictionary _widths = []; + private readonly List> _members = []; + private WeakReference? _header; + + /// 用户改过的列宽(下标 → 像素)。 + public IReadOnlyDictionary Widths => _widths; + + /// 列头。 + public Grid? Header => _header is not null && _header.TryGetTarget(out Grid? grid) ? grid : null; + + /// 还活着的成员(列头 + 行)。 + public IEnumerable Members + { + get + { + _members.RemoveAll(static w => !w.TryGetTarget(out _)); + foreach (WeakReference member in _members) + { + if (member.TryGetTarget(out Grid? grid)) + { + yield return grid; + } + } + } + } + + public bool Contains(Grid grid) => Members.Any(m => ReferenceEquals(m, grid)); + + public void Add(Grid grid, bool isHeader) + { + if (!Contains(grid)) + { + _members.Add(new WeakReference(grid)); + } + if (isHeader) + { + _header = new WeakReference(grid); + AddGrips(grid); + } + Apply(grid); + } + + public void Remove(Grid grid) => _members.RemoveAll(w => !w.TryGetTarget(out Grid? g) || ReferenceEquals(g, grid)); + + /// 把第 列设成 像素,同步到每个成员。 + public void Set(int column, double width) + { + _widths[column] = Math.Round(Math.Clamp(width, 24, 1600)); + foreach (Grid grid in Members) + { + Apply(grid); + } + } + + /// 按内容自适应第 列:量每个成员这一列里内容的自然宽度,取最大。 + public void AutoFit(int column) + { + double width = 0; + foreach (Grid grid in Members) + { + foreach (Control child in grid.Children) + { + if (child is Thumb || !child.IsVisible || Grid.GetColumn(child) != column || Grid.GetColumnSpan(child) != 1) + { + continue; + } + width = Math.Max(width, ColumnFit.Natural(child)); + } + } + if (width > 0) + { + Set(column, Math.Ceiling(width) + 8); + } + } + + private void Apply(Grid grid) + { + foreach ((int column, double width) in _widths) + { + if (column < grid.ColumnDefinitions.Count) + { + grid.ColumnDefinitions[column].Width = new GridLength(width); + } + } + } + + private void AddGrips(Grid header) + { + if (header.Children.OfType().Any(static t => t.Classes.Contains("tablegrip"))) + { + return; + } + HashSet fixedColumns = + [ + .. (GetFixed(header) ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(static s => int.TryParse(s, out int i) ? i : -1) + ]; + ControlTheme? theme = header.TryFindResource("MongoColumnGrip", out object? found) ? found as ControlTheme : null; + // 最后一列多半是 * 列,右边没有可拖的分隔线;是固定宽度时也给一个(拖宽之后表就比视口宽,横向滚动)。 + int count = header.ColumnDefinitions.Count; + for (int i = 0; i < count; i++) + { + if (fixedColumns.Contains(i) || (i == count - 1 && header.ColumnDefinitions[i].Width.IsStar)) + { + continue; + } + int column = i; + var grip = new Thumb { Classes = { "tablegrip" }, Tag = column }; + if (theme is not null) + { + grip.Theme = theme; + } + Grid.SetColumn(grip, column); + grip.DragDelta += (_, e) => + { + double current = header.ColumnDefinitions[column].ActualWidth; + Set(column, current + e.Vector.X); + }; + grip.AddHandler(InputElement.PointerPressedEvent, (_, e) => + { + if (e.ClickCount == 2) + { + e.Handled = true; + AutoFit(column); + } + }, Avalonia.Interactivity.RoutingStrategies.Tunnel, handledEventsToo: true); + header.Children.Add(grip); + } + } + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/ThemeBrushes.cs b/plugins/VelaShell.Plugin.Mongo/Ui/ThemeBrushes.cs new file mode 100644 index 0000000..2a5f0f1 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/ThemeBrushes.cs @@ -0,0 +1,198 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Threading; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 转换器取色的唯一出口:按令牌名给一支**长期有效**的画刷,宿主换肤时就地改色。 +/// +/// +/// 要解决的问题。转换器是绑定求值时才跑的,而绑定只在**它自己的源值**变化时重算。 +/// 主题不是绑定源 —— 所以换肤时转换器根本不会被再调一次。 +/// 从前这里直接把宿主资源里的画刷对象返回出去,而宿主换主题是**整格替换** +/// (Application.Resources.ThemeDictionaries 里换一个新的字典,里面是一批新造的 +/// SolidColorBrush),于是上一次返回出去的那个对象还在界面上挂着、颜色还是旧主题的。 +/// 表现是:切完主题,列表里的状态点、CPU 数字、危险按钮的描边全停在上一套配色上, +/// 直到那一行的数据碰巧变了才跟上来。 +/// +/// +/// +/// 为什么不是"换肤时让绑定重算"。那需要把主题做成绑定源(每个转换器后面挂一个 +/// 通知属性),十几处调用点全要改,而且每次换肤都要惊动整棵树重新求值。 +/// 这里反过来:**画刷对象本身不换**,只改它的 Color。 +/// SolidColorBrush.Color 是 styled property,改它会自动让所有用到这支画刷的地方重绘 —— +/// 绑定一次都不用重算,调用点一行都不用改。 +/// +/// +/// +/// 为什么要 Attach/Detach。订阅 Application.Current 的事件,是从**宿主**的对象 +/// 指向**插件**静态方法的一条引用。插件停用后 ALC 要能回收,这条引用就必须撤掉 —— +/// 不撤的话整个插件程序集被钉在内存里,而且下次加载会有两份。 +/// 生命周期挂在插件的激活/停用上,见 MongoPlugin。 +/// +/// +/// +/// 本面板是 inProcess 的(清单里钉死),所以直接认宿主应用的资源变更就够了 —— +/// 它覆盖全部三种换肤:换具名主题、"跟随系统"下系统明暗翻转、用户改强调色。 +/// 隔离模式的插件拿不到宿主的 Application,那边要认 ctx.Theme.Changed。 +/// +/// +internal static class ThemeBrushes +{ + /// 令牌名 → 那支长期有效的画刷。只在 UI 线程上读写(转换器与资源事件都在 UI 线程)。 + private static readonly Dictionary Tracked = new(StringComparer.Ordinal); + + private static EventHandler? _hook; + + /// 开始跟随宿主换肤。插件激活时调用一次;重复调用无副作用。 + public static void Attach() + { + if (_hook is not null || Application.Current is not { } app) + { + return; + } + _hook = (_, _) => Refresh(); + app.ResourcesChanged += _hook; + } + + /// 停止跟随并清空缓存。插件停用时调用 —— 不调就是把插件程序集钉在内存里。 + public static void Detach() + { + if (_hook is { } hook && Application.Current is { } app) + { + app.ResourcesChanged -= hook; + } + _hook = null; + Tracked.Clear(); + Derived.Clear(); + } + + /// + /// 取令牌对应的画刷。同一个令牌**永远返回同一个实例** —— 这是整套机制成立的前提: + /// 早先绑上去的那些地方,靠的就是手里这个实例会被就地改色。 + /// + /// 宿主令牌名,如 VelaAccent。 + /// 令牌解析不到时的兜底色(宿主资源还没就位,或令牌名写错了)。 + public static IBrush Get(string token, IBrush fallback) + { + if (Tracked.TryGetValue(token, out SolidColorBrush? tracked)) + { + return tracked; + } + // 解析不到就用兜底色起头,但**照样登记**:资源可能只是还没挂上(控件尚未进可视树), + // 下一次 Refresh 会把它补正。不登记的话它就永远是兜底灰了。 + Color initial = Resolve(token) ?? (fallback as ISolidColorBrush)?.Color ?? Colors.Gray; + var brush = new SolidColorBrush(initial); + Tracked[token] = brush; + return brush; + } + + /// 派生画刷:(令牌, 不透明度) → 那支长期有效的画刷。 + private static readonly Dictionary<(string Token, byte Alpha), SolidColorBrush> Derived = []; + + /// + /// 取"某个令牌按固定透明度淡化"的画刷(设计稿的 warningDim / errorDim / successDim / infoDim)。 + /// + /// 宿主只给了 VelaAccentDim 与终端色那一族的 Dim,没有 VelaWarning / VelaError / VelaInfo 的淡底; + /// 拿相近的 VelaShellYellowDim 顶替的话,橙色字压在黄色淡底上,一眼看得出不是一套。 + /// 这里按宿主的派生规则(种子色 + 固定 alpha)现算,换肤时与其余画刷一起就地改色。 + /// + /// + /// 宿主令牌名。 + /// 不透明度(0–1)。 + public static IBrush GetDim(string token, double opacity) + { + byte alpha = (byte)Math.Clamp(Math.Round(opacity * 255), 0, 255); + if (Derived.TryGetValue((token, alpha), out SolidColorBrush? tracked)) + { + return tracked; + } + Color seed = Resolve(token) ?? Colors.Gray; + var brush = new SolidColorBrush(Color.FromArgb(alpha, seed.R, seed.G, seed.B)); + Derived[(token, alpha)] = brush; + return brush; + } + + /// 宿主资源变了:把每一支跟踪中的画刷重新解析一遍。 + private static void Refresh() + { + // 资源变更可能来自任意线程的写入;改 styled property 必须回 UI 线程。 + if (!Dispatcher.UIThread.CheckAccess()) + { + Dispatcher.UIThread.Post(Refresh); + return; + } + foreach ((string token, SolidColorBrush brush) in Tracked) + { + if (Resolve(token) is { } color && brush.Color != color) + { + brush.Color = color; + } + } + foreach (((string token, byte alpha), SolidColorBrush brush) in Derived) + { + if (Resolve(token) is { } seed) + { + Color tinted = Color.FromArgb(alpha, seed.R, seed.G, seed.B); + if (brush.Color != tinted) + { + brush.Color = tinted; + } + } + } + } + + /// + /// 按**当前主题变体**解析一个令牌。 + /// + /// 必须带上 ActualThemeVariant:宿主的令牌分两族 —— VelaError / + /// VelaWarning / VelaShell* 写在整份主题文件里,而 VelaAccent / + /// VelaStatusConnected / VelaGauge* / VelaText* 写在 + /// ThemeDictionaries 的 Dark / Light 分支下。不带主题的那个重载按 + /// ThemeVariant.Default 查,后一族一个都查不到,于是**恰好半套颜色**静默回落成灰。 + /// + /// + private static Color? Resolve(string token) + { + if (Application.Current is not { } app) + { + return null; + } + if (app.TryFindResource(token, app.ActualThemeVariant, out object? value) && value is ISolidColorBrush brush) + { + return brush.Color; + } + // 插件自己的令牌(MongoChart1–4)不在宿主的应用资源里 —— 它们只挂在插件视图的样式上, + // 而转换器与自绘图表拿不到视图。回落到插件自带的那份主题字典。 + return PluginTheme?.TryGetResource(token, app.ActualThemeVariant, out object? own) == true && own is ISolidColorBrush mine + ? mine.Color + : null; + } + + private static ResourceDictionary? _pluginTheme; + private static bool _pluginThemeLoaded; + + /// 插件自带的主题字典(惰性装载一次;装不上就当没有)。 + private static ResourceDictionary? PluginTheme + { + get + { + if (!_pluginThemeLoaded) + { + _pluginThemeLoaded = true; + try + { + _pluginTheme = Avalonia.Markup.Xaml.AvaloniaXamlLoader.Load( + new Uri("avares://VelaShell.Plugin.Mongo/Ui/MongoTheme.axaml")) as ResourceDictionary; + } + catch (Exception) + { + _pluginTheme = null; + } + } + return _pluginTheme; + } + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Transfer/ExportRows.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Transfer/ExportRows.cs new file mode 100644 index 0000000..106414b --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Transfer/ExportRows.cs @@ -0,0 +1,288 @@ +using MongoDB.Bson; +using VelaShell.Plugin.Mongo.Bson; +using VelaShell.Plugin.Mongo.Core; +using VelaShell.Plugin.Mongo.Transfer; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 导出格式卡片(设计稿 19「格式」那一排五张)。 +internal sealed class ExportFormatCard : ObservableObject +{ + private readonly Action _select; + private bool _isSelected; + + /// 构造。 + public ExportFormatCard(ExportFormat format, string title, string subtitle, string iconKey, Action select) + { + Format = format; + Title = title; + Subtitle = subtitle; + IconKey = iconKey; + _select = select; + } + + /// 格式。 + public ExportFormat Format { get; } + + /// 标题(CSV)。 + public string Title { get; } + + /// 说明(扁平化嵌套字段)。 + public string Subtitle { get; } + + /// 图标。 + public string IconKey { get; } + + /// 选中(强调色描边 + AccentDim 底)。 + public bool IsSelected + { + get => _isSelected; + set + { + if (SetProperty(ref _isSelected, value)) + { + RaisePropertyChanged(nameof(IconToken)); + if (value) + { + _select(Format); + } + } + } + } + + /// 图标颜色:选中强调色,否则三级灰。 + public string IconToken => _isSelected ? "VelaAccent" : "VelaTextTertiary"; + + /// 不触发回调地同步选中态。 + internal void Sync(bool selected) + { + if (_isSelected != selected) + { + _isSelected = selected; + RaisePropertiesChanged(nameof(IsSelected), nameof(IconToken)); + } + } +} + +/// 多集合导出时的一个来源集合(第一步的勾选列表)。 +internal sealed class ExportSourceRow : ObservableObject +{ + private readonly Action _changed; + private bool _isChecked; + private string _countText = ""; + + /// 构造。 + public ExportSourceRow(CollectionInfo info, bool isChecked, Action changed) + { + Info = info; + _isChecked = isChecked; + _changed = changed; + } + + /// 集合信息。 + public CollectionInfo Info { get; } + + /// 名字。 + public string Name => Info.Name; + + /// 图标。 + public string IconKey => Info.Kind switch + { + CollectionKind.View => "Mongo.eye", + CollectionKind.TimeSeries => "Mongo.chart-no-axes-column", + _ => "Mongo.table-2" + }; + + /// 图标颜色。 + public string IconToken => Info.Kind == CollectionKind.View ? "VelaShellMagenta" : "VelaInfo"; + + /// 勾选。 + public bool IsChecked + { + get => _isChecked; + set + { + if (SetProperty(ref _isChecked, value)) + { + _changed(); + } + } + } + + /// 估算文档数(1,500)。 + public string CountText + { + get => _countText; + set => SetProperty(ref _countText, value); + } + + /// 估算文档数。 + public long Count { get; set; } + + /// 数据量(字节,collStats 的 size;视图为 0)。整库导出的文件大小按它估。 + public long Bytes { get; set; } +} + +/// 字段表的一行(设计稿 19:☑ / 拖动 / 字段路径 / 类型 / CSV 列名 / 转换)。 +internal sealed class ExportFieldRow : ObservableObject +{ + private readonly Action _changed; + private bool _include; + private string _header; + private XferOption _conversion; + private bool _nullAsEmpty = true; + + /// 构造。 + public ExportFieldRow(XferField field, bool include, string header, CellConversion conversion, Loc loc, Action changed) + { + Field = field; + _include = include; + _header = header; + _changed = changed; + Loc = loc; + Choices = [.. XferField.ChoicesFor(field.Dominant).Select(c => new XferOption(c, ConversionName(c, loc)))]; + _conversion = Choices.FirstOrDefault(c => (CellConversion)c.Value == conversion) ?? Choices[0]; + } + + /// 抽样字段。 + public XferField Field { get; } + + private Loc Loc { get; } + + /// 路径。 + public string Path => Field.Path; + + /// 类型文字(String | Null)。 + public string KindsText => Field.KindsText; + + /// 类型色点的令牌。 + public string KindToken => BsonKinds.ColorToken(Field.Nullable && Field.Kinds.Count > 1 && Field.Dominant == BsonKind.String ? BsonKind.Null : Field.Dominant); + + /// 导出这一列。 + public bool Include + { + get => _include; + set + { + if (SetProperty(ref _include, value)) + { + RaisePropertyChanged(nameof(RowOpacity)); + _changed(); + } + } + } + + /// 没勾的行淡一点(设计稿 0.6)。 + public double RowOpacity => _include ? 1 : 0.6; + + /// 列名。 + public string Header + { + get => _header; + set + { + if (SetProperty(ref _header, value)) + { + _changed(); + } + } + } + + /// 可选的换算。 + public IReadOnlyList Choices { get; } + + /// 有多种换算可选(显示下拉;否则显示一段说明文字)。 + public bool HasChoices => Choices.Count > 1; + + /// 当前换算。 + public XferOption ConversionOption + { + get => _conversion; + set + { + if (value is not null && SetProperty(ref _conversion, value)) + { + RaisePropertyChanged(nameof(ConversionText)); + _changed(); + } + } + } + + /// 当前换算的值。 + public CellConversion Conversion => (CellConversion)_conversion.Value; + + /// 只有一种换算时的说明(十六进制字符串 / null → 空 / —)。 + public string ConversionText + { + get + { + if (Conversion != CellConversion.None) + { + return _conversion.Label; + } + if (Field.Kinds.Contains(BsonKind.Null)) + { + return _nullAsEmpty ? Loc["Exp_NullToEmpty"] : Loc["Exp_NullToNull"]; + } + return "—"; + } + } + + /// 换算说明是不是"有内容"(否则用更淡的颜色)。 + public bool ConversionActive => Conversion != CellConversion.None || Field.Kinds.Contains(BsonKind.Null); + + /// 全局"空值"选项变了。 + internal void SetNullMode(bool nullAsEmpty) + { + if (_nullAsEmpty != nullAsEmpty) + { + _nullAsEmpty = nullAsEmpty; + RaisePropertyChanged(nameof(ConversionText)); + } + } + + /// 换算的名字。 + internal static string ConversionName(CellConversion conversion, Loc loc) => conversion switch + { + CellConversion.Hex => loc["Exp_ConvHex"], + CellConversion.Fixed2 => loc["Exp_ConvFixed2"], + CellConversion.LocalTime => loc.Format("Exp_ConvLocal", TransferText.LocalOffset()), + CellConversion.IsoTime => loc["Exp_ConvIso"], + CellConversion.Json => loc["Exp_ConvJson"], + _ => loc["Exp_ConvNone"] + }; + + /// 导出列。 + public ExportColumn ToColumn() => new(Path, Header.Trim().Length > 0 ? Header.Trim() : Path, Conversion); +} + +/// 输出预览的一行。 +/// 文本。 +/// 表头(强调色)。 +internal sealed record XferPreviewLine(string Text, bool IsHeader); + +/// 保存的导出配置(MongoStore 的 saved-export)。 +internal static class ExportProfile +{ + /// 存储里的种类名。 + public const string Kind = "export"; + + /// 导出向导当前的设置 → 配置原文。 + public static string Serialize(BsonDocument profile) => profile.ToJson(new MongoDB.Bson.IO.JsonWriterSettings + { + OutputMode = MongoDB.Bson.IO.JsonOutputMode.RelaxedExtendedJson + }); + + /// 配置原文 → 文档;坏的返回 。 + public static BsonDocument? Parse(string content) + { + try + { + return BsonDocument.Parse(content); + } + catch (Exception ex) when (ex is FormatException or BsonSerializationException or InvalidOperationException) + { + return null; + } + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Transfer/ExportWizardView.axaml b/plugins/VelaShell.Plugin.Mongo/Ui/Transfer/ExportWizardView.axaml new file mode 100644 index 0000000..99fb0af --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Transfer/ExportWizardView.axaml @@ -0,0 +1,582 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Users/ResetPasswordDialogView.axaml.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Users/ResetPasswordDialogView.axaml.cs new file mode 100644 index 0000000..6b8f085 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Users/ResetPasswordDialogView.axaml.cs @@ -0,0 +1,31 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Threading; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// 重置密码对话框视图。打开即聚焦新密码框;Enter 在可保存时等同于点保存。 +public sealed partial class ResetPasswordDialogView : UserControl +{ + /// 用给定的视图模型初始化。 + internal ResetPasswordDialogView(ResetPasswordDialogViewModel viewModel) + { + InitializeComponent(); + DataContext = viewModel; + AttachedToVisualTree += (_, _) => Dispatcher.UIThread.Post(() => PasswordBox.Focus()); + KeyDown += (_, e) => + { + if (e.Key == Key.Enter && viewModel.CanApply) + { + viewModel.ApplyCommand.Execute(null); + e.Handled = true; + } + }; + } + + /// 设计器 / headless 用的无参构造。 + public ResetPasswordDialogView() + { + InitializeComponent(); + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Users/RevealPasswordDialogView.axaml b/plugins/VelaShell.Plugin.Mongo/Ui/Users/RevealPasswordDialogView.axaml new file mode 100644 index 0000000..bca4fff --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Users/RevealPasswordDialogView.axaml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Users/UsersTabView.axaml.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Users/UsersTabView.axaml.cs new file mode 100644 index 0000000..ca69e63 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Users/UsersTabView.axaml.cs @@ -0,0 +1,90 @@ +using System.Windows.Input; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 用户与角色视图。只做视图该做的事:Ctrl+S 保存,以及「+ 添加角色」的下拉菜单在代码里现建 —— +/// 候选随当前编辑器的状态变(已有的不再列出),而且弹出层在独立的可视树里, +/// 写在 AXAML 里就得从弹层绕回编辑器的数据上下文。 +/// +public sealed partial class UsersTabView : UserControl +{ + /// 用给定的视图模型初始化。 + internal UsersTabView(UsersTabViewModel viewModel) + { + InitializeComponent(); + DataContext = viewModel; + // Ctrl+S 保存当前编辑器(与集合工作台的「Ctrl+S 应用」同一个手势)。 + KeyDown += (_, e) => + { + if (e.Key != Key.S || !e.KeyModifiers.HasFlag(KeyModifiers.Control)) + { + return; + } + ICommand? save = viewModel.ShowRoles ? viewModel.RoleEditor?.SaveCommand : viewModel.UserEditor?.SaveCommand; + if (save?.CanExecute(null) == true) + { + save.Execute(null); + e.Handled = true; + } + }; + } + + /// 设计器 / headless 用的无参构造。 + public UsersTabView() + { + InitializeComponent(); + } + + /// 「+ 添加角色」:自定义角色在前(强调色盾牌),其余内置角色在后(灰盾牌),中间一条分隔线。 + private void OnAddRoleClick(object? sender, RoutedEventArgs e) + { + if (sender is not Button button) + { + return; + } + (IReadOnlyList roles, ICommand? command, Loc? loc) = button.DataContext switch + { + UserEditorViewModel user => (user.AddableRoles, (ICommand)user.AddRoleCommand, user.Loc), + RoleEditorViewModel role => (role.AddableRoles, role.AddRoleCommand, role.Loc), + _ => ([], null, null) + }; + if (command is null || loc is null) + { + return; + } + var items = new List(); + foreach (RoleRef role in roles.Where(static r => !BuiltinRoles.IsBuiltin(r.Role))) + { + items.Add(Item(role, "Mongo.shield-check", "VelaAccent", command)); + } + List builtin = [.. roles.Where(static r => BuiltinRoles.IsBuiltin(r.Role))]; + if (items.Count > 0 && builtin.Count > 0) + { + items.Add(new Separator()); + } + foreach (RoleRef role in builtin) + { + items.Add(Item(role, "Mongo.shield", "VelaTextTertiary", command)); + } + if (items.Count == 0) + { + items.Add(new MenuItem { Header = loc["Users_NoAddable"], IsEnabled = false }); + } + var flyout = new MenuFlyout { ItemsSource = items, Placement = PlacementMode.BottomEdgeAlignedLeft }; + flyout.ShowAt(button); + e.Handled = true; + } + + private static MenuItem Item(RoleRef role, string icon, string token, ICommand command) => new() + { + Header = role.ToString(), + Icon = new Glyph { Key = icon, Size = 13, Brush = ThemeBrushes.Get(token, Brushes.Gray) }, + Command = command, + CommandParameter = role + }; +} diff --git a/plugins/VelaShell.Plugin.Mongo/Ui/Users/UsersTabViewModel.cs b/plugins/VelaShell.Plugin.Mongo/Ui/Users/UsersTabViewModel.cs new file mode 100644 index 0000000..04b23c9 --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/Ui/Users/UsersTabViewModel.cs @@ -0,0 +1,1111 @@ +using System.Collections.ObjectModel; +using MongoDB.Bson; +using MongoDB.Driver; +using VelaShell.Plugin.Mongo.Core; + +namespace VelaShell.Plugin.Mongo.Ui; + +/// +/// 用户与角色(设计稿 18):36px 工具行 + 左栏 420px 用户 / 角色列表 + 右侧编辑器。 +/// +/// 全部读写走服务器的用户管理命令(usersInfo / rolesInfo / createUser / grantRolesToUser …), +/// 不直接碰 admin.system.users:直接改那张表绕过了服务器的校验与用户缓存失效, +/// 改完要等缓存过期才生效,而且在分片集群上根本不是同一份。 +/// +/// +/// 草稿按用户 / 角色各存一份:可以在几个用户之间来回比对、各改一点,列表行一直标着「编辑中」, +/// 状态栏列出所有未保存的对象;保存只提交当前那一个。 +/// +/// +internal sealed class UsersTabViewModel : WorkspaceTab +{ + private readonly Dictionary _userDrafts = new(StringComparer.Ordinal); + private readonly Dictionary _roleDrafts = new(StringComparer.Ordinal); + private readonly Dictionary> _privilegeCache = []; + private bool _showRoles; + private bool _isLoading; + private bool _loaded; + private string _notice = ""; + private UserRowViewModel? _selectedUser; + private RoleRowViewModel? _selectedRole; + private UserEditorViewModel? _userEditor; + private RoleEditorViewModel? _roleEditor; + private UserEditorViewModel? _newUser; + private RoleEditorViewModel? _newRole; + private IReadOnlyList<(string User, string Db)> _authenticated = []; + private IReadOnlyList _databases = []; + private IReadOnlyList _customRoles = []; + + /// 构造。 + /// 外壳服务。 + /// 打开时的作用库(矩阵与标签里优先显示它)。 + /// 直接进角色模式。 + public UsersTabViewModel(IMongoWorkspace workspace, string? database, bool roles) + : base(workspace) + { + Database = database; + _showRoles = roles; + Title = workspace.Loc["Tree_Users"]; + NewUserCommand = new(StartNewUser); + NewRoleCommand = new(StartNewRole); + ResetPasswordCommand = new(ResetPassword, () => !_showRoles && _userEditor is { IsNew: false }); + DeleteCommand = new(DeleteAsync, () => _showRoles ? _selectedRole is not null : _selectedUser is not null); + } + + /// 打开时的作用库。 + public string? Database { get; } + + /// 角色模式(工具行右侧分段「用户 / 角色」)。 + public bool ShowRoles + { + get => _showRoles; + set + { + if (!SetProperty(ref _showRoles, value)) + { + return; + } + (Workspace as MongoSession)?.Shell.OnTabToolChanged(); + OnModeChanged(); + } + } + + /// 分段「用户」。 + public bool IsUsersMode + { + get => !_showRoles; + set + { + if (value) + { + ShowRoles = false; + } + } + } + + /// 分段「角色」。 + public bool IsRolesMode + { + get => _showRoles; + set + { + if (value) + { + ShowRoles = true; + } + } + } + + /// + public override TabKind Kind => TabKind.Users; + + /// + public override string Key => "users"; + + /// + public override string IconKey => "Mongo.users"; + + /// + public override string IconToken => "VelaInfo"; + + // ── 列表 ───────────────────────────────────────────────────────────── + + /// 用户行。 + public ObservableCollection Users { get; } = []; + + /// 自定义角色行。 + public ObservableCollection Roles { get; } = []; + + /// 全部自定义角色(各库)。 + public IReadOnlyList CustomRoles => _customRoles; + + /// 用户库(矩阵的行;不含 admin / config / local)。 + public IReadOnlyList MatrixDatabases => _databases; + + /// 用户库( 的别名,给角色编辑器判断"是不是现有库")。 + public IReadOnlyList Databases => _databases; + + /// 认证库 / 角色所在库的候选:admin 在前,其后是用户库。 + public IReadOnlyList AuthDatabases => ["admin", .. _databases]; + + /// 正在加载。 + public bool IsLoading + { + get => _isLoading; + private set + { + if (SetProperty(ref _isLoading, value)) + { + RaisePropertiesChanged(nameof(IsListEmpty), nameof(EmptyText)); + } + } + } + + /// 列表顶上的一条说明(没有 forAllDBs 权限时只列了当前库)。 + public string Notice + { + get => _notice; + private set + { + if (SetProperty(ref _notice, value)) + { + RaisePropertyChanged(nameof(HasNotice)); + } + } + } + + /// 有没有那条说明。 + public bool HasNotice => _notice.Length > 0; + + /// 当前模式的列表是空的(加载完之后)。 + public bool IsListEmpty => !_isLoading && _loaded && (_showRoles ? Roles.Count == 0 : Users.Count == 0); + + /// 空态文字。 + public string EmptyText => _isLoading ? Loc["Common_Loading"] : _showRoles ? Loc["Users_NoRoles"] : Loc["Users_NoUsers"]; + + /// 列头第一列(用户 / 角色)。 + public string NameHeader => _showRoles ? Loc["Users_ColRole"] : Loc["Users_ColUser"]; + + /// 列头第二列(认证库 / 所在库)。 + public string DbHeader => _showRoles ? Loc["Users_ColRoleDb"] : Loc["Users_ColAuthDb"]; + + /// 列头第三列(角色 / 继承)。 + public string RolesHeader => _showRoles ? Loc["Users_ColInherits"] : Loc["Users_ColRoles"]; + + /// 右侧没有编辑器时的那句话。 + public string EmptyEditorText => _showRoles ? Loc["Users_EmptyRoleEditor"] : Loc["Users_EmptyEditor"]; + + /// 工具行「删除」的图标(用户模式 user-x,角色模式 trash-2)。 + public string DeleteIcon => _showRoles ? "Mongo.trash-2" : "Mongo.user-x"; + + /// 选中的用户行。 + public UserRowViewModel? SelectedUser + { + get => _selectedUser; + set + { + if (!SetProperty(ref _selectedUser, value)) + { + return; + } + if (value is not null) + { + UserEditor = DraftFor(value.User); + } + RefreshCommands(); + } + } + + /// 选中的角色行。 + public RoleRowViewModel? SelectedRole + { + get => _selectedRole; + set + { + if (!SetProperty(ref _selectedRole, value)) + { + return; + } + if (value is not null) + { + RoleEditor = DraftFor(value.Role); + } + RefreshCommands(); + } + } + + /// 右侧的用户编辑器。 + public UserEditorViewModel? UserEditor + { + get => _userEditor; + private set + { + if (SetProperty(ref _userEditor, value)) + { + RaisePropertiesChanged(nameof(ShowUserEditor), nameof(ShowEmptyEditor)); + RefreshCommands(); + } + } + } + + /// 右侧的角色编辑器。 + public RoleEditorViewModel? RoleEditor + { + get => _roleEditor; + private set + { + if (SetProperty(ref _roleEditor, value)) + { + RaisePropertiesChanged(nameof(ShowRoleEditor), nameof(ShowEmptyEditor)); + RefreshCommands(); + } + } + } + + /// 右侧显示用户编辑器。 + public bool ShowUserEditor => !_showRoles && _userEditor is not null; + + /// 右侧显示角色编辑器。 + public bool ShowRoleEditor => _showRoles && _roleEditor is not null; + + /// 右侧什么也没选(空态)。 + public bool ShowEmptyEditor => !ShowUserEditor && !ShowRoleEditor; + + // ── 工具行命令 ─────────────────────────────────────────────────────── + + /// 新建用户。 + public RelayCommand NewUserCommand { get; } + + /// 新建角色。 + public RelayCommand NewRoleCommand { get; } + + /// 重置密码。 + public RelayCommand ResetPasswordCommand { get; } + + /// 删除(用户模式删用户,角色模式删角色)。 + public AsyncCommand DeleteCommand { get; } + + // ── 加载 ───────────────────────────────────────────────────────────── + + /// + public override Task LoadAsync() => ReloadAsync(keepDrafts: false); + + /// + public override async Task RefreshAsync() + { + if (IsModified && !await Workspace.ConfirmAsync(new() + { + Title = Loc["Users_RefreshDiscardTitle"], + Message = Loc["Users_RefreshDiscardBody"], + ConfirmLabel = Loc["Tab_CloseDiscard"], + IconKey = "Mongo.triangle-alert", + Danger = true + }).ConfigureAwait(true)) + { + return; + } + await ReloadAsync(keepDrafts: false).ConfigureAwait(true); + } + + /// + /// 重新读身份、库、用户与自定义角色。 + /// 为真时(保存之后)保留其余对象上未保存的草稿;否则全部丢弃。 + /// + internal async Task ReloadAsync(bool keepDrafts, string? selectUserId = null, string? selectRoleId = null) + { + IsLoading = true; + string? userId = selectUserId ?? _selectedUser?.User.Id; + string? roleId = selectRoleId ?? (_selectedRole is { } r ? $"{r.Db}.{r.Name}" : null); + try + { + _authenticated = await LoadIdentityAsync().ConfigureAwait(true); + _databases = await LoadDatabasesAsync().ConfigureAwait(true); + (IReadOnlyList users, string notice) = await LoadUsersAsync().ConfigureAwait(true); + _customRoles = await LoadCustomRolesAsync(users).ConfigureAwait(true); + _privilegeCache.Clear(); + Notice = notice; + + if (!keepDrafts) + { + DropDrafts(); + } + _selectedUser = null; + _selectedRole = null; + Users.Clear(); + foreach (MongoUser user in users + .OrderBy(static u => u.IsRoot ? 0 : 1) + .ThenBy(static u => u.Db == "admin" ? 0 : 1) + .ThenBy(static u => u.Db, StringComparer.Ordinal) + .ThenBy(static u => u.Name, StringComparer.Ordinal)) + { + var row = new UserRowViewModel(user, Loc, Database, UserAdmin.IsSelf(user.Name, user.Db, _authenticated)); + if (_userDrafts.TryGetValue(user.Id, out UserEditorViewModel? draft)) + { + // 没改动的草稿以服务器的新状态为准;改了一半的保留(那是人的工作)。 + if (!draft.IsModified) + { + draft.Rebase(user); + _ = LoadDetailsAsync(draft); + } + row.IsEditing = draft.IsModified; + } + Users.Add(row); + } + foreach (string stale in _userDrafts.Keys.Where(id => users.All(u => u.Id != id)).ToList()) + { + _userDrafts[stale].Dispose(); + _userDrafts.Remove(stale); + } + Roles.Clear(); + foreach (MongoRole role in _customRoles) + { + var row = new RoleRowViewModel(role, Loc, Database, HoldersOf(role.Ref).Count); + if (_roleDrafts.TryGetValue(RoleId(role), out RoleEditorViewModel? draft)) + { + if (!draft.IsModified) + { + draft.Rebase(role); + } + row.IsEditing = draft.IsModified; + } + Roles.Add(row); + } + foreach (string stale in _roleDrafts.Keys.Where(id => _customRoles.All(c => RoleId(c) != id)).ToList()) + { + _roleDrafts[stale].Dispose(); + _roleDrafts.Remove(stale); + } + _loaded = true; + + RaisePropertiesChanged(nameof(SelectedUser), nameof(SelectedRole), nameof(CustomRoles), nameof(MatrixDatabases), + nameof(AuthDatabases), nameof(IsListEmpty)); + bool editingNewUser = _userEditor is not null && ReferenceEquals(_userEditor, _newUser); + bool editingNewRole = _roleEditor is not null && ReferenceEquals(_roleEditor, _newRole); + SelectedUser = Users.FirstOrDefault(u => u.User.Id == userId) ?? (editingNewUser ? null : Users.FirstOrDefault()); + if (_selectedUser is null && !editingNewUser) + { + UserEditor = null; + } + SelectedRole = Roles.FirstOrDefault(x => $"{x.Db}.{x.Name}" == roleId) ?? (editingNewRole ? null : Roles.FirstOrDefault()); + if (_selectedRole is null && !editingNewRole) + { + RoleEditor = null; + } + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Notice = Loc.Format("Users_LoadFailed", MongoConnector.Describe(ex)); + Workspace.Toast(new() { Title = Loc.Format("Common_Failed", MongoConnector.Describe(ex)), Kind = ToastKind.Error }); + } + finally + { + IsLoading = false; + UpdateModified(); + } + } + + /// 当前连接是以谁的身份认证的(删自己、撤自己的权限要拦)。 + private async Task> LoadIdentityAsync() + { + try + { + BsonDocument status = await Workspace.Connection.RunCommandAsync("admin", new BsonDocument("connectionStatus", 1)).ConfigureAwait(true); + BsonArray users = status.GetValue("authInfo", new BsonDocument()).AsBsonDocument + .GetValue("authenticatedUsers", new BsonArray()).AsBsonArray; + return + [ + .. users.OfType() + .Where(static u => u.Contains("user") && u.Contains("db")) + .Select(static u => (u["user"].AsString, u["db"].AsString)) + ]; + } + catch (MongoCommandException) + { + return []; + } + } + + private async Task> LoadDatabasesAsync() + { + IEnumerable names; + try + { + IReadOnlyList databases = await Workspace.Connection.ListDatabasesAsync().ConfigureAwait(true); + names = databases.Select(static d => d.Name); + } + catch (MongoCommandException) + { + // 没有 listDatabases 权限:退到对象树里已知的库。 + names = Workspace.Databases; + } + // 标签页的作用库排第一(从 shop 打开就先看到 shop 那一行),其余按名字。 + return + [ + .. names.Where(static n => n is not ("admin" or "config" or "local")) + .Distinct(StringComparer.Ordinal) + .OrderBy(n => n == Database ? 0 : 1) + .ThenBy(static n => n, StringComparer.Ordinal) + ]; + } + + /// + /// usersInfo: { forAllDBs: true } 列出全部库的用户;没有这个权限(只是某个库的 userAdmin) + /// 时退到当前库,并在列表顶上说一句"只列了 shop 的用户" —— 否则空列表会被误读成"这台服务器没有用户"。 + /// + private async Task<(IReadOnlyList Users, string Notice)> LoadUsersAsync() + { + try + { + BsonDocument reply = await Workspace.Connection.RunCommandAsync("admin", + new BsonDocument("usersInfo", new BsonDocument("forAllDBs", true))).ConfigureAwait(true); + return (ParseUsers(reply), ""); + } + catch (MongoCommandException ex) when (ex.Code == 13) + { + string db = Database is { Length: > 0 } d ? d : "admin"; + BsonDocument reply = await Workspace.Connection.RunCommandAsync(db, new BsonDocument("usersInfo", 1)).ConfigureAwait(true); + return (ParseUsers(reply), Loc.Format("Users_ScopedNotice", db)); + } + } + + private static List ParseUsers(BsonDocument reply) => + [.. reply.GetValue("users", new BsonArray()).AsBsonArray.OfType().Select(MongoUser.Parse)]; + + /// + /// 自定义角色分散在各库(rolesInfo 只看当前库),逐库并行地问:admin + 用户库 + 用户角色里提到的库。 + /// 某个库没有 viewRole 权限就跳过它,而不是让整页加载失败。 + /// + private async Task> LoadCustomRolesAsync(IReadOnlyList users) + { + var databases = new SortedSet(StringComparer.Ordinal) { "admin" }; + databases.UnionWith(_databases); + databases.UnionWith(users.SelectMany(static u => u.Roles).Where(static r => !BuiltinRoles.IsBuiltin(r.Role)).Select(static r => r.Db)); + IEnumerable>> tasks = databases.Select(async db => + { + try + { + BsonDocument reply = await Workspace.Connection.RunCommandAsync(db, new BsonDocument + { + { "rolesInfo", 1 }, + { "showPrivileges", true }, + { "showBuiltinRoles", false } + }).ConfigureAwait(false); + return (IReadOnlyList) + [ + .. reply.GetValue("roles", new BsonArray()).AsBsonArray.OfType() + .Select(MongoRole.Parse).Where(static r => !r.IsBuiltin) + ]; + } + catch (MongoCommandException) + { + return []; + } + }); + IReadOnlyList[] all = await Task.WhenAll(tasks).ConfigureAwait(true); + return [.. all.SelectMany(static x => x).OrderBy(static r => r.Db, StringComparer.Ordinal).ThenBy(static r => r.Name, StringComparer.Ordinal)]; + } + + /// + /// 一个用户的详情:登录限制只有精确的 usersInfo(指名用户)才回 —— forAllDBs 的列表里没有。 + /// + internal async Task FetchUserAsync(string user, string db) + { + BsonDocument reply = await Workspace.Connection.RunCommandAsync(db, new BsonDocument + { + { "usersInfo", new BsonDocument { { "user", user }, { "db", db } } }, + { "showCustomData", true }, + { "showAuthenticationRestrictions", true } + }).ConfigureAwait(true); + return ParseUsers(reply).FirstOrDefault(); + } + + private async Task FetchRoleAsync(string role, string db) + { + BsonDocument reply = await Workspace.Connection.RunCommandAsync(db, new BsonDocument + { + { "rolesInfo", new BsonDocument { { "role", role }, { "db", db } } }, + { "showPrivileges", true } + }).ConfigureAwait(true); + return reply.GetValue("roles", new BsonArray()).AsBsonArray.OfType().Select(MongoRole.Parse).FirstOrDefault(); + } + + private async Task LoadDetailsAsync(UserEditorViewModel editor) + { + if (editor.User is not { } user) + { + return; + } + try + { + if (await FetchUserAsync(user.Name, user.Db).ConfigureAwait(true) is { } detailed) + { + editor.ApplyDetails(detailed); + } + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Workspace.Log.Info($"usersInfo for {user.Id} failed: {ex.Message}"); + } + } + + /// + /// 把一组角色展开成权限(rolesInfo showPrivileges 的 inheritedPrivileges,已含继承链)。 + /// 结果按角色缓存到下一次整页重载 —— 内置角色的权限在一台服务器上是不变的。 + /// + internal async Task>> ResolvePrivilegesAsync( + IEnumerable roles, + CancellationToken cancellationToken) + { + List distinct = [.. roles.Distinct()]; + List missing = [.. distinct.Where(r => !_privilegeCache.ContainsKey(r))]; + if (missing.Count > 0) + { + BsonDocument reply = await Workspace.Connection.RunCommandAsync("admin", new BsonDocument + { + { "rolesInfo", new BsonArray(missing.Select(static r => r.ToBson())) }, + { "showPrivileges", true } + }, cancellationToken).ConfigureAwait(true); + foreach (BsonDocument doc in reply.GetValue("roles", new BsonArray()).AsBsonArray.OfType()) + { + var key = new RoleRef(doc.GetValue("role", "").AsString, doc.GetValue("db", "").AsString); + _privilegeCache[key] = doc.TryGetValue("inheritedPrivileges", out BsonValue p) && p.IsBsonArray + ? [.. p.AsBsonArray.Select(Privilege.From).Where(static x => x is not null).Select(static x => x!)] + : []; + } + foreach (RoleRef unknown in missing.Where(r => !_privilegeCache.ContainsKey(r))) + { + // 服务器不认识的角色(拼错了 / 刚被删)不带来任何权限。 + _privilegeCache[unknown] = []; + } + } + return distinct.ToDictionary(static r => r, r => _privilegeCache[r]); + } + + // ── 草稿 ───────────────────────────────────────────────────────────── + + private UserEditorViewModel DraftFor(MongoUser user) + { + if (_userDrafts.TryGetValue(user.Id, out UserEditorViewModel? draft)) + { + return draft; + } + draft = new UserEditorViewModel(this, user); + draft.Changed += OnUserDraftChanged; + _userDrafts[user.Id] = draft; + _ = LoadDetailsAsync(draft); + return draft; + } + + private RoleEditorViewModel DraftFor(MongoRole role) + { + string id = RoleId(role); + if (_roleDrafts.TryGetValue(id, out RoleEditorViewModel? draft)) + { + return draft; + } + draft = new RoleEditorViewModel(this, role); + draft.Changed += OnRoleDraftChanged; + _roleDrafts[id] = draft; + return draft; + } + + private static string RoleId(MongoRole role) => $"{role.Db}.{role.Name}"; + + private void OnUserDraftChanged(UserEditorViewModel editor) + { + if (Users.FirstOrDefault(u => u.User.Id == editor.Id) is { } row) + { + row.IsEditing = editor.IsModified; + } + UpdateModified(); + } + + private void OnRoleDraftChanged(RoleEditorViewModel editor) + { + if (Roles.FirstOrDefault(r => $"{r.Db}.{r.Name}" == editor.Id) is { } row) + { + row.IsEditing = editor.IsModified; + } + UpdateModified(); + } + + private void DropDrafts() + { + foreach (UserEditorViewModel draft in _userDrafts.Values) + { + draft.Changed -= OnUserDraftChanged; + draft.Dispose(); + } + foreach (RoleEditorViewModel draft in _roleDrafts.Values) + { + draft.Changed -= OnRoleDraftChanged; + draft.Dispose(); + } + _userDrafts.Clear(); + _roleDrafts.Clear(); + UserEditor = ReferenceEquals(_userEditor, _newUser) ? _newUser : null; + RoleEditor = ReferenceEquals(_roleEditor, _newRole) ? _newRole : null; + } + + /// 所有未保存的对象名(状态栏「未保存:ops_writer」)。 + private IReadOnlyList PendingNames => + [ + .. _userDrafts.Values.Where(static d => d.IsModified).Select(static d => d.DisplayName), + .. _newUser is { IsModified: true } nu ? [nu.DisplayName] : Array.Empty(), + .. _roleDrafts.Values.Where(static d => d.IsModified).Select(static d => d.DisplayName), + .. _newRole is { IsModified: true } nr ? [nr.DisplayName] : Array.Empty() + ]; + + private void UpdateModified() + { + IsModified = PendingNames.Count > 0; + UpdateStatus(); + } + + /// + /// 状态栏:admin.system.users · 5 个用户 · 2 个自定义角色,有草稿时再接 未保存:ops_writer。 + /// + private void UpdateStatus() + { + string ns = _showRoles ? "admin.system.roles" : "admin.system.users"; + string text = Loc.Format("Users_Status", ns, Users.Count, _customRoles.Count); + IReadOnlyList pending = PendingNames; + if (pending.Count > 0) + { + text += " · " + Loc.Format("Users_StatusPending", string.Join(", ", pending)); + } + StatusText = text; + } + + private void OnModeChanged() + { + RaisePropertiesChanged(nameof(IsUsersMode), nameof(IsRolesMode), nameof(NameHeader), nameof(DbHeader), nameof(RolesHeader), nameof(DeleteIcon), nameof(EmptyEditorText), + nameof(ShowUserEditor), nameof(ShowRoleEditor), nameof(ShowEmptyEditor), nameof(IsListEmpty), nameof(EmptyText)); + if (_loaded) + { + if (_showRoles && _roleEditor is null && Roles.Count > 0) + { + SelectedRole = Roles[0]; + } + else if (!_showRoles && _userEditor is null && Users.Count > 0) + { + SelectedUser = Users[0]; + } + } + RefreshCommands(); + UpdateStatus(); + } + + private void RefreshCommands() + { + ResetPasswordCommand.RaiseCanExecuteChanged(); + DeleteCommand.RaiseCanExecuteChanged(); + } + + // ── 查询助手(编辑器用) ───────────────────────────────────────────── + + /// 这个用户是不是当前连接的身份。 + internal bool IsSelf(MongoUser user) => UserAdmin.IsSelf(user.Name, user.Db, _authenticated); + + /// 直接持有某个角色的用户(ops_writer@admin)。 + internal IReadOnlyList HoldersOf(RoleRef role) => + [.. Users.Where(u => u.User.Roles.Contains(role)).Select(static u => $"{u.Name}@{u.Db}")]; + + // ── 新建 ───────────────────────────────────────────────────────────── + + private void StartNewUser() + { + if (_newUser is null) + { + _newUser = new UserEditorViewModel(this, null); + _newUser.Changed += OnNewUserChanged; + } + ShowRoles = false; + SelectedUser = null; + UserEditor = _newUser; + } + + private void StartNewRole() + { + if (_newRole is null) + { + _newRole = new RoleEditorViewModel(this, null); + _newRole.Changed += OnNewRoleChanged; + } + ShowRoles = true; + SelectedRole = null; + RoleEditor = _newRole; + } + + private void OnNewUserChanged(UserEditorViewModel editor) => UpdateModified(); + + private void OnNewRoleChanged(RoleEditorViewModel editor) => UpdateModified(); + + // ── 写:保存 / 删除 / 密码 ───────────────────────────────────────────── + + /// + /// 依次执行一组命令;中途失败就停在那里并提示是哪一条(前面成功的已经生效 —— 用户管理命令没有事务, + /// 所以保存后无论成败都会重读一次,让界面与服务器对齐)。 + /// + private async Task ExecuteAsync(IReadOnlyList commands) + { + foreach (AdminCommand command in commands) + { + try + { + await Workspace.Connection.RunCommandAsync(command.Database, command.Command).ConfigureAwait(true); + Workspace.Log.Info($"users: {command.Shell}"); + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Workspace.Toast(new() + { + Title = Loc.Format("Common_Failed", MongoConnector.Describe(ex)), + Detail = command.Command.GetElement(0).Name, + Kind = ToastKind.Error, + Duration = TimeSpan.FromSeconds(8) + }); + return false; + } + } + return true; + } + + /// 写前的统一关口:只读 / 无权限拦下;生产连接(写前确认)再弹一次、手打名称。 + private async Task GuardAsync(string name, IReadOnlyList commands, string titleKey) + { + if (!Workspace.EnsureWritable("admin")) + { + return false; + } + if (!Workspace.Guard.ConfirmWrites && !Workspace.Guard.IsProduction) + { + return true; + } + return await Workspace.ConfirmAsync(new() + { + Title = Loc.Format(titleKey, name), + Message = Loc.Format("Users_ConfirmCommands", commands.Count) + Environment.NewLine + Environment.NewLine + + string.Join(Environment.NewLine, commands.Select(static c => c.Shell)), + ConfirmLabel = Loc["Common_Save"], + IconKey = "Mongo.shield-check", + Danger = false, + TypeToConfirm = name + }).ConfigureAwait(true); + } + + /// 保存一个用户草稿(新建即 createUser)。 + internal async Task SaveUserAsync(UserEditorViewModel editor) + { + IReadOnlyList commands = editor.BuildCommands(); + if (!editor.CanSave || commands.Count == 0) + { + return; + } + if (editor.User is { } self && IsSelf(self) && UserAdmin.Diff(editor.Roles.Original, editor.Roles.Current).Removed.Count > 0 + && !await Workspace.ConfirmAsync(new() + { + Title = Loc["Users_RevokeSelfTitle"], + Message = Loc.Format("Users_RevokeSelfBody", $"{self.Name}@{self.Db}"), + ConfirmLabel = Loc["Users_RevokeSelfConfirm"], + IconKey = "Mongo.shield-alert", + Danger = true + }).ConfigureAwait(true)) + { + return; + } + if (!await GuardAsync(editor.DisplayName, commands, "Users_SaveUserTitle").ConfigureAwait(true)) + { + return; + } + bool ok = await ExecuteAsync(commands).ConfigureAwait(true); + if (editor.IsNew) + { + if (!ok) + { + return; + } + string id = $"{editor.NewDb}.{editor.NewName}"; + string label = $"{editor.NewName}@{editor.NewDb}"; + string password = editor.Password; + bool generated = editor.PasswordGenerated; + _newUser!.Changed -= OnNewUserChanged; + _newUser.Dispose(); + _newUser = null; + UserEditor = null; + if (generated) + { + await Workspace.CopyAsync(password).ConfigureAwait(true); + } + Workspace.Toast(new() + { + Title = Loc.Format("Users_Created", label), + Detail = generated ? Loc["Users_PasswordCopied"] : null, + Kind = ToastKind.Success + }); + await ReloadAsync(keepDrafts: true, selectUserId: id).ConfigureAwait(true); + return; + } + if (ok) + { + Workspace.Toast(new() + { + Title = Loc.Format("Users_Saved", editor.DisplayName), + Detail = Loc.Format("Users_SavedDetail", commands.Count), + Kind = ToastKind.Success + }); + } + await RefreshUserAsync(editor).ConfigureAwait(true); + } + + /// 保存之后:以服务器状态为准重建草稿,再重读列表(行上的角色标签跟着变)。 + private async Task RefreshUserAsync(UserEditorViewModel editor) + { + if (editor.User is not { } user) + { + return; + } + try + { + if (await FetchUserAsync(user.Name, user.Db).ConfigureAwait(true) is { } fresh) + { + editor.Rebase(fresh); + } + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Workspace.Log.Info($"usersInfo for {user.Id} failed: {ex.Message}"); + } + await ReloadAsync(keepDrafts: true, selectUserId: user.Id).ConfigureAwait(true); + } + + /// 保存一个角色草稿(新建即 createRole)。 + internal async Task SaveRoleAsync(RoleEditorViewModel editor) + { + IReadOnlyList commands = editor.BuildCommands(); + if (!editor.CanSave || commands.Count == 0) + { + return; + } + if (!await GuardAsync(editor.DisplayName, commands, "Users_SaveRoleTitle").ConfigureAwait(true)) + { + return; + } + bool ok = await ExecuteAsync(commands).ConfigureAwait(true); + if (editor.IsNew) + { + if (!ok) + { + return; + } + string id = $"{editor.NewDb}.{editor.NewName}"; + string label = $"{editor.NewName}@{editor.NewDb}"; + _newRole!.Changed -= OnNewRoleChanged; + _newRole.Dispose(); + _newRole = null; + RoleEditor = null; + Workspace.Toast(new() { Title = Loc.Format("Users_Created", label), Kind = ToastKind.Success }); + await ReloadAsync(keepDrafts: true, selectRoleId: id).ConfigureAwait(true); + return; + } + if (ok) + { + Workspace.Toast(new() + { + Title = Loc.Format("Users_Saved", editor.DisplayName), + Detail = Loc.Format("Users_SavedDetail", commands.Count), + Kind = ToastKind.Success + }); + } + if (editor.Role is { } role) + { + try + { + if (await FetchRoleAsync(role.Name, role.Db).ConfigureAwait(true) is { } fresh) + { + editor.Rebase(fresh); + } + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Workspace.Log.Info($"rolesInfo for {role.Name}@{role.Db} failed: {ex.Message}"); + } + await ReloadAsync(keepDrafts: true, selectRoleId: editor.Id).ConfigureAwait(true); + } + } + + private Task DeleteAsync() => _showRoles ? DeleteRoleAsync() : DeleteUserAsync(); + + /// + /// 删除用户。当前连接正在用的那个身份**拦下不删**:删掉之后这条连接的下一条命令就会认证失败, + /// 而能把它建回来的人恰恰就是它自己。 + /// + private async Task DeleteUserAsync() + { + if (_selectedUser is not { } row) + { + return; + } + MongoUser user = row.User; + if (IsSelf(user)) + { + Workspace.Toast(new() + { + Title = Loc.Format("Users_CannotDeleteSelf", $"{user.Name}@{user.Db}"), + Detail = Loc["Users_CannotDeleteSelfDetail"], + Kind = ToastKind.Warning, + Duration = TimeSpan.FromSeconds(6) + }); + return; + } + if (!Workspace.EnsureWritable("admin")) + { + return; + } + if (!await Workspace.ConfirmAsync(new() + { + Title = Loc["Users_DeleteUserTitle"], + Message = Loc.Format("Users_DeleteUserBody", $"{user.Name}@{user.Db}"), + ConfirmLabel = Loc["Users_DeleteUserTitle"], + IconKey = "Mongo.user-x", + Facts = + [ + new(Loc["Users_ColAuthDb"], user.Db), + new(Loc["Users_ColRoles"], user.Roles.Count == 0 ? Loc["Common_None"] : string.Join(", ", user.Roles)) + ], + TypeToConfirm = Workspace.Guard.ConfirmWrites || user.IsRoot ? user.Name : null + }).ConfigureAwait(true)) + { + return; + } + if (!await ExecuteAsync([UserAdmin.DropUser(user.Name, user.Db)]).ConfigureAwait(true)) + { + return; + } + if (_userDrafts.Remove(user.Id, out UserEditorViewModel? draft)) + { + draft.Changed -= OnUserDraftChanged; + draft.Dispose(); + } + UserEditor = null; + Workspace.Toast(new() { Title = Loc.Format("Users_Deleted", $"{user.Name}@{user.Db}"), Kind = ToastKind.Success }); + await ReloadAsync(keepDrafts: true, selectUserId: "").ConfigureAwait(true); + } + + /// 删除自定义角色(持有者会立即失去它带来的权限 —— 在确认框里列出来)。 + private async Task DeleteRoleAsync() + { + if (_selectedRole is not { } row) + { + return; + } + MongoRole role = row.Role; + if (!Workspace.EnsureWritable("admin")) + { + return; + } + IReadOnlyList holders = HoldersOf(role.Ref); + if (!await Workspace.ConfirmAsync(new() + { + Title = Loc["Users_DeleteRoleTitle"], + Message = Loc.Format("Users_DeleteRoleBody", role.Ref, holders.Count), + ConfirmLabel = Loc["Users_DeleteRoleTitle"], + IconKey = "Mongo.shield-alert", + Facts = + [ + new(Loc["Users_Privileges"], role.Privileges.Count.ToString(System.Globalization.CultureInfo.InvariantCulture)), + new(Loc["Users_Holders"], holders.Count == 0 ? Loc["Common_None"] : string.Join(", ", holders)) + ], + TypeToConfirm = Workspace.Guard.ConfirmWrites || holders.Count > 0 ? role.Name : null + }).ConfigureAwait(true)) + { + return; + } + if (!await ExecuteAsync([UserAdmin.DropRole(role.Name, role.Db)]).ConfigureAwait(true)) + { + return; + } + if (_roleDrafts.Remove(RoleId(role), out RoleEditorViewModel? draft)) + { + draft.Changed -= OnRoleDraftChanged; + draft.Dispose(); + } + RoleEditor = null; + Workspace.Toast(new() { Title = Loc.Format("Users_Deleted", role.Ref), Kind = ToastKind.Success }); + await ReloadAsync(keepDrafts: true, selectRoleId: "").ConfigureAwait(true); + } + + private void ResetPassword() + { + if (_userEditor?.User is not { } user) + { + return; + } + Workspace.ShowDialog(new ResetPasswordDialogViewModel(Workspace, user, async (password, generated) => + { + if (!await ChangePasswordAsync(user, password).ConfigureAwait(true)) + { + return false; + } + if (generated) + { + await Workspace.CopyAsync(password).ConfigureAwait(true); + } + return true; + })); + } + + /// + /// 轮换密码:生成强随机密码 → updateUser → 复制到剪贴板,并且只显示这一次。 + /// 先确认:旧密码立即失效,还在用它的应用下次认证就会失败。 + /// + internal async Task RotatePasswordAsync(UserEditorViewModel editor) + { + if (editor.User is not { } user || !Workspace.EnsureWritable("admin")) + { + return; + } + if (!await Workspace.ConfirmAsync(new() + { + Title = Loc.Format("Users_RotateTitle", user.Name), + Message = Loc.Format("Users_RotateBody", $"{user.Name}@{user.Db}"), + ConfirmLabel = Loc["Users_Rotate"], + IconKey = "Mongo.key-round", + Danger = false, + TypeToConfirm = Workspace.Guard.ConfirmWrites ? user.Name : null + }).ConfigureAwait(true)) + { + return; + } + string password = UserAdmin.GeneratePassword(); + if (!await ChangePasswordAsync(user, password, guarded: true).ConfigureAwait(true)) + { + return; + } + await Workspace.CopyAsync(password).ConfigureAwait(true); + Workspace.ShowDialog(new RevealPasswordDialogViewModel(Workspace, $"{user.Name}@{user.Db}", password)); + } + + /// 改密码:updateUser { pwd, customData(合并,刷新修改时间) }。 + private async Task ChangePasswordAsync(MongoUser user, string password, bool guarded = false) + { + if (!guarded && !Workspace.EnsureWritable("admin")) + { + return false; + } + // customData 要以服务器上的最新一份为底(列表里那份可能早于别人刚写的备注)。 + MongoUser current = user; + try + { + current = await FetchUserAsync(user.Name, user.Db).ConfigureAwait(true) ?? user; + } + catch (Exception ex) when (ex is MongoException or TimeoutException) + { + Workspace.Log.Info($"usersInfo for {user.Id} failed: {ex.Message}"); + } + AdminCommand command = UserAdmin.UpdatePassword(user.Name, user.Db, password, UserAdmin.MergeCustomData(current.CustomData, DateTime.UtcNow)); + if (!guarded && !await GuardAsync(user.Name, [command], "Users_SaveUserTitle").ConfigureAwait(true)) + { + return false; + } + if (!await ExecuteAsync([command]).ConfigureAwait(true)) + { + return false; + } + Workspace.Toast(new() { Title = Loc.Format("Users_PasswordChanged", $"{user.Name}@{user.Db}"), Kind = ToastKind.Success }); + if (_userDrafts.TryGetValue(user.Id, out UserEditorViewModel? draft) && !draft.IsModified) + { + await RefreshUserAsync(draft).ConfigureAwait(true); + } + return true; + } + + /// + public override void Dispose() + { + DropDrafts(); + _newUser?.Dispose(); + _newRole?.Dispose(); + base.Dispose(); + } +} diff --git a/plugins/VelaShell.Plugin.Mongo/VelaShell.Plugin.Mongo.csproj b/plugins/VelaShell.Plugin.Mongo/VelaShell.Plugin.Mongo.csproj new file mode 100644 index 0000000..b21d4fd --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/VelaShell.Plugin.Mongo.csproj @@ -0,0 +1,41 @@ + + + + VelaShell MongoDB 插件:Navicat 式的对象树与大图标工具栏,补齐 Compass 的聚合管道、Schema 分析、索引建议与 GridFS;网格 / 树 / JSON 三视图共用一份数据,所有编辑先进暂存区。 + + velashell.mongo + + $(NoWarn);AVLN3001 + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/VelaShell.Plugin.Mongo/plugin.json b/plugins/VelaShell.Plugin.Mongo/plugin.json new file mode 100644 index 0000000..8ee15ee --- /dev/null +++ b/plugins/VelaShell.Plugin.Mongo/plugin.json @@ -0,0 +1,33 @@ +{ + "id": "velashell.mongo", + "version": "2.0.3", + "displayName": "MongoDB Workbench", + "description": "MongoDB 工作台:对象树、网格 / 树 / JSON 三视图与暂存区编辑、查询编辑器、聚合管道、Schema 分析、索引建议、GridFS、服务器监控、慢查询、用户与角色、导入导出与数据传输。从命令面板打开,连接由插件自己管理。", + "publisher": "velashell", + "entry": "VelaShell.Plugin.Mongo.dll", + "apiLevel": 2, + // 连接由插件自己管:SSH 跳板要经宿主打开一条已保存的 SSH 会话(ISessionsApi.OpenAsync) + // 再在上面开 TCP 流(IRemoteTunnelApi.OpenTcpAsync)。前者是 2.0.2 才有的面,apiLevel 表达不了这一档, + // 所以钉 minSdkVersion(同 DockerPanel)。 + "minSdkVersion": "2.0.2", + "license": "MIT", + "homepage": "https://github.com/joesdu/VelaShell", + // 命令贡献:发现期就进命令面板(**不装载本程序集**),用户按下它才触发下面的惰性激活 —— + // 驱动有好几兆,不用 MongoDB 的用户一个字节都不该为它装载。 + // 不贡献 workspaces:MongoDB 不出现在宿主的新建连接窗口与会话树里,连接列在工作台对象树的根上。 + "contributes": { + "commands": [ + { + "id": "velashell.mongo.open", + "title": "MongoDB: 打开 MongoDB 工作台", + "category": "MongoDB" + } + ] + }, + "activationEvents": [ + "onCommand:velashell.mongo.open" + ] + // hostMode 必须是默认的 inProcess,两个理由都是硬的: + // · 工作台要以文档标签页的身份进主窗口标签区,而原生控件无法跨进程嵌入; + // · IRemoteTunnelApi 交出去的是一条活的 Stream,隔离进程里明确不可用。 +} diff --git a/tests/VelaShell.Plugin.Mongo.Tests/BindingHygieneTests.cs b/tests/VelaShell.Plugin.Mongo.Tests/BindingHygieneTests.cs new file mode 100644 index 0000000..4c85839 --- /dev/null +++ b/tests/VelaShell.Plugin.Mongo.Tests/BindingHygieneTests.cs @@ -0,0 +1,172 @@ +using Avalonia.Controls; +using Avalonia.Logging; +using Avalonia.VisualTree; +using VelaShell.Plugin.Mongo.Ui; + +namespace VelaShell.Plugin.Mongo.Tests; + +/// +/// 绑定卫生:把主要界面走一遍(集合三视图 + 检查器 + 内联编辑、筛选补全、查询、GridFS、对象列表), +/// 期间 Avalonia 报出的绑定错误一条都不许有。 +/// +/// 这些错误不让界面崩,只在宿主的调试输出里刷屏([Binding]An error occurred binding …)—— +/// 正因为不疼不痒,不拿测试守着就会一条条攒起来,真出问题时淹没在噪声里。 +/// 常见的两种:路径中段为 null(Editor.HasError 在不编辑时),以及控件从外面继承了 +/// 别人的 DataContext、编译绑定按 x:DataType 去转型失败(补全弹层继承了宿主视图的视图模型)。 +/// +/// +[TestClass] +public sealed class BindingHygieneTests +{ + private sealed class BindingErrorSink : ILogSink + { + public List Errors { get; } = []; + + public bool IsEnabled(LogEventLevel level, string area) => area == LogArea.Binding && level >= LogEventLevel.Warning; + + public void Log(LogEventLevel level, string area, object? source, string messageTemplate) => + Log(level, area, source, messageTemplate, []); + + public void Log(LogEventLevel level, string area, object? source, string messageTemplate, params object?[] propertyValues) + { + if (IsEnabled(level, area)) + { + Errors.Add($"{messageTemplate} | {string.Join(" | ", propertyValues)} | {source?.GetType().Name}"); + } + } + } + + private static async Task WaitAsync(Func condition, int timeoutMs = 15_000) + { + DateTime deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); + while (!condition() && DateTime.UtcNow < deadline) + { + await Screens.PumpAsync(5); + } + } + + [TestMethod] + public void Walking_the_main_screens_raises_no_binding_errors() => Screens.OnUi(async () => + { + await TestServer.RequireAsync(); + var sink = new BindingErrorSink(); + ILogSink? previous = Logger.Sink; + Logger.Sink = sink; + try + { + await using Workbench bench = await Screens.OpenWorkbenchAsync(); + + // 集合:网格 + 检查器,内联编辑一格、检查器里编辑一个字段再点回网格。 + bench.Session.OpenCollection(Screens.Database, "orders"); + var tab = (CollectionTabViewModel)bench.ViewModel.ActiveTab!; + await WaitAsync(() => !tab.IsLoading && tab.Rows.Count > 0); + await Screens.PumpAsync(20); + tab.SelectedRow = tab.Rows[0]; + await Screens.PumpAsync(10); + CollectionCell cell = tab.Rows[0].CellOf(tab.Columns.Single(static c => c.Name == "status"))!; + tab.BeginCellEdit(cell); + await Screens.PumpAsync(10); + CollectionTabViewModel.CancelCellEdit(cell); + InspectorField status = tab.Inspector.Fields.First(static f => f.Path == "status"); + tab.Inspector.BeginEdit(status); + await Screens.PumpAsync(10); + DocInspectorViewModel.CancelEdit(status); + tab.Inspector.Page = 1; + await Screens.PumpAsync(10); + tab.Inspector.Page = 0; + + // 筛选框的补全弹层开一次。 + CollectionTabView view = bench.Window.GetVisualDescendants().OfType().First(static v => v.IsEffectivelyVisible); + CodeEditor filter = view.FindControl("FilterEditor")!; + filter.FocusEditor(); + filter.RequestCompletion(); + await Screens.PumpAsync(20); + + // 树视图、JSON 视图(大纲)。 + tab.ViewMode = CollectionViewMode.Tree; + await Screens.PumpAsync(20); + tab.ViewMode = CollectionViewMode.Json; + await Screens.PumpAsync(20); + tab.ViewMode = CollectionViewMode.Grid; + await Screens.PumpAsync(10); + + // 查询、GridFS、对象列表。 + bench.Session.OpenQuery(Screens.Database, "db.orders.find({}).limit(5)", run: true); + await Screens.PumpAsync(60); + bench.Session.OpenGridFs(Screens.Database, "fs"); + await WaitAsync(() => bench.ViewModel.ActiveTab is GridFsTabViewModel { IsLoading: false }); + var gridFs = (GridFsTabViewModel)bench.ViewModel.ActiveTab!; + // 选一个多版本文件:详情里的版本列表(勾选框、行上的删除、底下那两条)都要绑一遍。 + await gridFs.NavigateAsync("products/SKU-7710/"); + await WaitAsync(() => gridFs.Entries.Any(static e => e.Name == "main.jpg")); + gridFs.SelectedEntry = gridFs.Entries.First(static e => e.Name == "main.jpg"); + await WaitAsync(() => gridFs.Details.Versions.Count > 0 && !gridFs.Details.IsPreviewLoading); + gridFs.Details.Versions.LastOrDefault()?.IsChecked = true; + await Screens.PumpAsync(20); + bench.Session.OpenObjects(Screens.Database); + await Screens.PumpAsync(40); + } + finally + { + Logger.Sink = previous; + } + + Assert.IsEmpty(sink.Errors, "binding errors:\n" + string.Join("\n", sink.Errors.Distinct())); + }); + + [TestMethod] + public void The_other_tabs_and_every_dialog_raise_no_binding_errors() => Screens.OnUi(async () => + { + await TestServer.RequireAsync(); + var sink = new BindingErrorSink(); + ILogSink? previous = Logger.Sink; + Logger.Sink = sink; + try + { + await using Workbench bench = await Screens.OpenWorkbenchAsync(); + MongoSession session = bench.Session; + + session.OpenPipeline(Screens.Database, "orders"); + await Screens.PumpAsync(40); + foreach (DesignPage page in Enum.GetValues()) + { + session.OpenDesign(Screens.Database, "orders", page); + await Screens.PumpAsync(30); + } + session.OpenMonitor(); + await Screens.PumpAsync(40); + session.OpenProfiler(Screens.Database); + await Screens.PumpAsync(30); + session.OpenUsers(Screens.Database); + await Screens.PumpAsync(30); + session.OpenUsers(Screens.Database, roles: true); + await Screens.PumpAsync(30); + + session.OpenCollection(Screens.Database, "orders"); + var tab = (CollectionTabViewModel)bench.ViewModel.Tabs.Last(static t => t is CollectionTabViewModel); + await WaitAsync(() => !tab.IsLoading && tab.Rows.Count > 0); + + async Task ShowAsync(Action open) + { + open(); + await Screens.PumpAsync(30); + bench.ViewModel.Dialog?.Close(); + await Screens.PumpAsync(5); + } + + await ShowAsync(() => tab.OpenInEditor(tab.Rows[0])); + await ShowAsync(() => session.ShowDialog(new NewCollectionDialogViewModel(session, Screens.Database))); + await ShowAsync(() => session.ShowDialog(new GridFsNewBucketDialogViewModel(session, Screens.Database))); + await ShowAsync(() => session.ShowDialog(new ExportWizardViewModel(session, Screens.Database, "orders", null, null))); + await ShowAsync(() => session.ShowDialog(new ImportWizardViewModel(session, Screens.Database, "orders"))); + await ShowAsync(() => session.ShowDialog(new TransferWizardViewModel(session, Screens.Database))); + await ShowAsync(() => bench.ViewModel.NewConnectionCommand.Execute(null)); + } + finally + { + Logger.Sink = previous; + } + + Assert.IsEmpty(sink.Errors, "binding errors:\n" + string.Join("\n", sink.Errors.Distinct())); + }); +} diff --git a/tests/VelaShell.Plugin.Mongo.Tests/BsonTests.cs b/tests/VelaShell.Plugin.Mongo.Tests/BsonTests.cs new file mode 100644 index 0000000..bda67b6 --- /dev/null +++ b/tests/VelaShell.Plugin.Mongo.Tests/BsonTests.cs @@ -0,0 +1,200 @@ +using System.Globalization; +using MongoDB.Bson; +using VelaShell.Plugin.Mongo.Bson; + +namespace VelaShell.Plugin.Mongo.Tests; + +/// +/// BSON 的呈现、解析与编辑规则。网格、检查器、JSON 视图、表单与查询编辑器都靠这一层, +/// 同一个值在五处必须长得一样、改出来的也必须是同一个类型。 +/// +[TestClass] +public sealed class BsonTests +{ + [TestMethod] + public void Kinds_ClassifyUuidAndMissingSeparately() + { + Assert.AreEqual(BsonKind.Uuid, BsonKinds.Of(new BsonBinaryData(Guid.NewGuid(), GuidRepresentation.Standard))); + Assert.AreEqual(BsonKind.Binary, BsonKinds.Of(new BsonBinaryData([1, 2, 3]))); + Assert.AreEqual(BsonKind.Missing, BsonKinds.Of(null)); + Assert.AreEqual(BsonKind.Null, BsonKinds.Of(BsonNull.Value)); + Assert.AreEqual("VelaShellGreen", BsonKinds.ColorToken(BsonKind.Decimal128)); + Assert.AreEqual("VelaShellBlue", BsonKinds.ColorToken(BsonKind.ObjectId)); + } + + [TestMethod] + public void Cell_FormatsLikeTheDesignGrid() + { + Assert.AreEqual("1,299.00", BsonText.Cell(new BsonDecimal128(Decimal128.Parse("1299.00")))); + Assert.AreEqual("8,740.00", BsonText.Cell(new BsonDecimal128(Decimal128.Parse("8740.00")))); + Assert.AreEqual("6172.86", BsonText.Cell(new BsonDouble(6172.86))); + Assert.AreEqual("12.0", BsonText.Cell(new BsonDouble(12)), "整数值的 Double 要能和 Int32 区分开"); + Assert.AreEqual("1,284,902", BsonText.Cell(new BsonInt64(1284902))); + Assert.AreEqual("paid", BsonText.Cell(new BsonString("paid"))); + Assert.AreEqual("—", BsonText.Cell(null)); + Assert.AreEqual("null", BsonText.Cell(BsonNull.Value)); + Assert.AreEqual("{3} 陈立 · VIP", BsonText.Cell(new BsonDocument { { "id", ObjectId.GenerateNewId() }, { "name", "陈立" }, { "level", "VIP" } })); + Assert.AreEqual("[2] SKU-1182…", BsonText.Cell(new BsonArray { new BsonDocument("sku", "SKU-1182"), new BsonDocument("sku", "SKU-2044") })); + } + + [TestMethod] + public void Cell_DatesAreLocalTime() + { + var utc = new DateTime(2026, 9, 26, 1, 12, 0, DateTimeKind.Utc); + string expected = utc.ToLocalTime().ToString(BsonText.DateFormat, CultureInfo.InvariantCulture); + Assert.AreEqual(expected, BsonText.Cell(new BsonDateTime(utc))); + } + + [TestMethod] + public void Pretty_ShellModeMatchesMongosh() + { + var doc = new BsonDocument + { + { "_id", ObjectId.Parse("66f5c2a1b04e97d2c3a1f58e") }, + { "orderNo", "SO2609-10402" }, + { "total", new BsonDecimal128(Decimal128.Parse("516.00")) }, + { "createdAt", new BsonDateTime(new DateTime(2026, 9, 26, 20, 51, 27, DateTimeKind.Utc)) }, + { "tags", new BsonArray { "企业", "开票" } }, + { "weird key", 1 } + }; + string expected = """ + { + _id: ObjectId("66f5c2a1b04e97d2c3a1f58e"), + orderNo: "SO2609-10402", + total: NumberDecimal("516.00"), + createdAt: ISODate("2026-09-26T20:51:27Z"), + tags: [ "企业", "开票" ], + "weird key": 1 + } + """; + Assert.AreEqual(expected.ReplaceLineEndings("\n"), BsonText.Pretty(doc)); + } + + [TestMethod] + public void Pretty_RelaxedAndCanonicalComeFromTheDriver() + { + var doc = new BsonDocument("n", 5); + StringAssert.Contains(BsonText.Pretty(doc, EjsonMode.Relaxed), "\"n\" : 5"); + StringAssert.Contains(BsonText.Pretty(doc, EjsonMode.Canonical), "$numberInt"); + } + + [TestMethod] + public void ShellJson_ParsesMongoshLiterals() + { + BsonDocument doc = ShellJson.ParseDocument(""" + // 近 30 天高价值订单 + { status: 'paid', total: { $gte: 500 }, createdAt: { $gte: ISODate("2026-08-27") }, + id: ObjectId("66f5c2a1b04e97d2c3a1f58e"), amount: NumberDecimal("8740.00"), name: /^张/i, } + """); + Assert.AreEqual("paid", doc["status"].AsString); + Assert.AreEqual(500, doc["total"]["$gte"].AsInt32); + Assert.AreEqual(BsonType.DateTime, doc["createdAt"]["$gte"].BsonType); + Assert.AreEqual(BsonType.ObjectId, doc["id"].BsonType); + Assert.AreEqual(BsonType.Decimal128, doc["amount"].BsonType); + Assert.AreEqual(BsonType.RegularExpression, doc["name"].BsonType); + } + + [TestMethod] + public void ShellJson_DottedBareKeysAreAnError() + { + IReadOnlyList diagnostics = ShellJson.Diagnose("{ orderNo: 1, customer.name: 1 }"); + ShellDiagnostic diagnostic = diagnostics.Single(); + Assert.AreEqual("Shell_DottedKey", diagnostic.MessageKey); + Assert.AreEqual("\"customer.name\"", diagnostic.Fix); + Assert.AreEqual(14, diagnostic.Offset); + Assert.ThrowsExactly(() => ShellJson.ParseDocument("{ customer.name: 1 }")); + Assert.AreEqual(1, ShellJson.ParseDocument("{ \"customer.name\": 1 }")["customer.name"].AsInt32); + } + + [TestMethod] + public void ShellJson_EmptyTextIsAnEmptyFilter() => Assert.AreEqual(0, ShellJson.ParseDocument(" ").ElementCount); + + [TestMethod] + public void ShellJson_ParsesPipelines() + { + BsonArray pipeline = ShellJson.ParseArray("[ { $match: { status: \"paid\" } }, { $group: { _id: \"$customer.id\", n: { $sum: 1 } } } ]"); + Assert.AreEqual(2, pipeline.Count); + Assert.AreEqual("$customer.id", pipeline[1]["$group"]["_id"].AsString); + } + + [TestMethod] + public void Edit_ParsesEachKind() + { + Assert.IsTrue(BsonEdit.TryParse("1,580", BsonKind.Int32, out BsonValue i32, out _)); + Assert.AreEqual(1580, i32.AsInt32); + Assert.IsFalse(BsonEdit.TryParse("3000000000", BsonKind.Int32, out _, out string? overflow)); + Assert.AreEqual("Edit_NotInt32", overflow); + Assert.IsTrue(BsonEdit.TryParse("1580.00", BsonKind.Decimal128, out BsonValue dec, out _)); + Assert.AreEqual("1580.00", dec.AsDecimal128.ToString()); + Assert.IsTrue(BsonEdit.TryParse("是", BsonKind.Boolean, out BsonValue yes, out _)); + Assert.IsTrue(yes.AsBoolean); + Assert.IsTrue(BsonEdit.TryParse("ObjectId(\"66f5c2a1b04e97d2c3a1f58e\")", BsonKind.ObjectId, out BsonValue oid, out _)); + Assert.AreEqual("66f5c2a1b04e97d2c3a1f58e", oid.AsObjectId.ToString()); + Assert.IsTrue(BsonEdit.TryParse("2026-09-27T02:30:00Z", BsonKind.Date, out BsonValue date, out _)); + Assert.AreEqual(new DateTime(2026, 9, 27, 2, 30, 0, DateTimeKind.Utc), date.ToUniversalTime()); + Assert.IsTrue(BsonEdit.TryParse("[\"企业\", \"开票\"]", BsonKind.Array, out BsonValue array, out _)); + Assert.AreEqual(2, array.AsBsonArray.Count); + Assert.IsFalse(BsonEdit.TryParse("{", BsonKind.Object, out _, out string? broken)); + Assert.AreEqual("Edit_NotObject", broken); + } + + [TestMethod] + public void Edit_TextRoundTrips() + { + BsonValue[] values = + [ + new BsonString("SO2609-10403"), new BsonInt32(42), new BsonInt64(1L << 40), new BsonDouble(3.25), + new BsonDecimal128(Decimal128.Parse("8740.00")), BsonBoolean.True, ObjectId.Parse("66f5c2a1b04e97d2c3a1f58e"), + new BsonBinaryData(Guid.Parse("0f8fad5b-d9cb-469f-a165-70867728950e"), GuidRepresentation.Standard) + ]; + foreach (BsonValue value in values) + { + Assert.IsTrue(BsonEdit.TryParse(BsonEdit.EditText(value), BsonKinds.Of(value), out BsonValue back, out _), value.ToString()); + Assert.AreEqual(value, back); + } + } + + [TestMethod] + public void Edit_ConvertBetweenKinds() + { + Assert.AreEqual(8740, BsonEdit.Convert(new BsonString("8740"), BsonKind.Int32, out bool reset1).AsInt32); + Assert.IsFalse(reset1); + Assert.AreEqual("8740.5", BsonEdit.Convert(new BsonDouble(8740.5), BsonKind.Decimal128, out _).AsDecimal128.ToString()); + BsonValue garbage = BsonEdit.Convert(new BsonString("abc"), BsonKind.Int32, out bool reset2); + Assert.IsTrue(reset2); + Assert.AreEqual(0, garbage.AsInt32); + Assert.AreEqual("true", BsonEdit.Convert(BsonBoolean.True, BsonKind.String, out _).AsString); + } + + [TestMethod] + public void Path_ReadsWritesAndRemoves() + { + var doc = new BsonDocument + { + { "customer", new BsonDocument { { "name", "张伟" } } }, + { "items", new BsonArray { new BsonDocument("sku", "SKU-1182"), new BsonDocument("sku", "SKU-2044") } } + }; + Assert.AreEqual("SKU-2044", BsonPath.Get(doc, "items.1.sku")!.AsString); + Assert.IsNull(BsonPath.Get(doc, "items.5.sku")); + Assert.IsTrue(BsonPath.Set(doc, "customer.level", "SVIP")); + Assert.IsTrue(BsonPath.Set(doc, "invoice.title", "云帆"), "缺的文档层自动补上(同 $set)"); + Assert.AreEqual("云帆", doc["invoice"]["title"].AsString); + Assert.IsTrue(BsonPath.Unset(doc, "items.0")); + Assert.AreEqual(1, doc["items"].AsBsonArray.Count); + Assert.IsFalse(BsonPath.Unset(doc, "nope")); + string[] paths = [.. BsonPath.Walk(doc).Select(static p => p.Path)]; + CollectionAssert.Contains(paths, "items.sku", "数组里的对象合并成一条路径"); + CollectionAssert.Contains(paths, "customer.level"); + } + + [TestMethod] + public void Sizes_AndCountsAbbreviate() + { + Assert.AreEqual("42.1 GB", BsonText.Bytes(45_204_000_000)); + Assert.AreEqual("312 KB", BsonText.Bytes(319_488)); + Assert.AreEqual("1.28M", BsonText.Count(1_284_902)); + Assert.AreEqual("86.4K", BsonText.Count(86_410)); + Assert.AreEqual("640", BsonText.Count(640)); + Assert.AreEqual("66f5c2a1…f58e", BsonText.Shorten("66f5c2a1b04e97d2c3a1f58e")); + } +} diff --git a/tests/VelaShell.Plugin.Mongo.Tests/CollectionTests.cs b/tests/VelaShell.Plugin.Mongo.Tests/CollectionTests.cs new file mode 100644 index 0000000..08381aa --- /dev/null +++ b/tests/VelaShell.Plugin.Mongo.Tests/CollectionTests.cs @@ -0,0 +1,720 @@ +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.Bson; +using VelaShell.Plugin.Mongo.Staging; +using VelaShell.Plugin.Mongo.Ui; + +namespace VelaShell.Plugin.Mongo.Tests; + +/// +/// 集合工作台:暂存区的路径合并与乐观并发、提交 / 撤销 / 冲突(打真实服务器,用自己建的临时库)、 +/// JSON 排版与光标路径、执行计划摘要,以及设计稿 01 / 02 / 13 的截图。 +/// +[TestClass] +public sealed class CollectionTests +{ + // ── 暂存区:纯逻辑 ───────────────────────────────────────────────────────── + + private static BsonDocument Order() => new() + { + { "_id", new ObjectId("66f5c2a1f9a7056304b0e3b7") }, + { "orderNo", "SO2609-10403" }, + { "customer", new BsonDocument { { "name", "张伟" }, { "level", "SVIP" } } }, + { "items", new BsonArray { new BsonDocument { { "sku", "SKU-7710" }, { "qty", 2 } } } }, + { "status", "paid" }, + { "updatedAt", new BsonDateTime(new DateTime(2026, 9, 26, 12, 0, 0, DateTimeKind.Utc)) } + }; + + [TestMethod] + public void Setting_a_descendant_after_its_ancestor_is_merged_into_the_ancestor() + { + var edit = new StagedEdit(Order()); + edit.Set("customer", new BsonDocument("name", "李四")); + edit.Set("customer.level", "VIP"); + + Assert.AreEqual(1, edit.Changes.Count, "MongoDB rejects $set on both customer and customer.level"); + Assert.AreEqual("customer", edit.Changes[0].Path); + Assert.AreEqual(new BsonDocument { { "name", "李四" }, { "level", "VIP" } }, edit.Changes[0].Value); + } + + [TestMethod] + public void Setting_an_ancestor_supersedes_its_staged_descendants() + { + var edit = new StagedEdit(Order()); + edit.Set("customer.name", "李四"); + edit.Set("customer.level", "VIP"); + edit.Set("customer", new BsonDocument("name", "王五")); + + Assert.AreEqual(1, edit.Changes.Count); + Assert.AreEqual("customer", edit.Changes[0].Path); + Assert.AreEqual(ChangeRelation.InsideChange, edit.RelationOf("customer.name")); + + var nested = new StagedEdit(Order()); + nested.Set("customer.name", "李四"); + Assert.AreEqual(ChangeRelation.ContainsChange, nested.RelationOf("customer")); + Assert.AreEqual(ChangeRelation.Exact, nested.RelationOf("customer.name")); + } + + [TestMethod] + public void Changing_a_value_back_drops_the_change_and_type_changes_count() + { + var edit = new StagedEdit(Order()); + edit.Set("status", "shipped"); + edit.Set("status", "paid"); + Assert.IsTrue(edit.IsEmpty, "changed and changed back is not a pending change"); + + edit.Set("items.0.qty", new BsonDouble(2)); + Assert.AreEqual(1, edit.Changes.Count, "Int32 2 → Double 2.0 is a type change and must be kept"); + } + + [TestMethod] + public void Update_filter_carries_original_values_missing_fields_and_updatedAt() + { + var edit = new StagedEdit(Order()); + edit.Set("status", "shipped"); + edit.Set("note", "加急"); + edit.Set("orderNo", null); + var op = new UpdateOperation(edit); + + BsonDocument filter = op.Filter; + Assert.AreEqual(new BsonDocument("$eq", "paid"), filter["status"]); + Assert.AreEqual(new BsonDocument("$exists", false), filter["note"]); + Assert.AreEqual(new BsonDocument("$eq", "SO2609-10403"), filter["orderNo"]); + Assert.IsTrue(filter.Contains("updatedAt"), "a document stamped with updatedAt is guarded by it as well"); + + BsonDocument update = op.Update; + Assert.AreEqual("shipped", update["$set"]["status"].AsString); + Assert.AreEqual("加急", update["$set"]["note"].AsString); + Assert.IsTrue(update["$unset"].AsBsonDocument.Contains("orderNo")); + } + + [TestMethod] + public void Deleting_supersedes_edits_and_snapshots_restore_everything() + { + var area = new StagingArea(); + BsonDocument order = Order(); + area.SetField(order, "status", "shipped"); + area.AddInsert(new BsonDocument("orderNo", "SO-NEW")); + StagingArea.Snapshot snapshot = area.TakeSnapshot(); + + area.MarkDeleted(order); + Assert.AreEqual(0, area.EditCount, "a staged delete drops the staged edits of that document"); + Assert.AreEqual(1, area.DeleteCount); + + area.Clear(); + Assert.IsTrue(area.IsEmpty); + area.Restore(snapshot); + Assert.AreEqual(1, area.EditCount); + Assert.AreEqual(1, area.InsertCount); + Assert.AreEqual("shipped", area.Current(order)["status"].AsString); + } + + [TestMethod] + public void Commit_plan_respects_the_scope() + { + var area = new StagingArea(); + BsonDocument a = Order(); + BsonDocument b = Order(); + b["_id"] = ObjectId.GenerateNewId(); + area.SetField(a, "status", "shipped"); + area.SetField(b, "status", "refunded"); + StagedInsert insert = area.AddInsert(new BsonDocument("n", 1)); + + Assert.AreEqual(3, StagingCommitter.Plan(area, CommitScope.All).Count); + IReadOnlyList onlyA = StagingCommitter.Plan(area, new CommitScope(a["_id"])); + Assert.AreEqual(1, onlyA.Count); + Assert.AreEqual(a["_id"], ((UpdateOperation)onlyA[0]).Edit.Id); + IReadOnlyList onlyInsert = StagingCommitter.Plan(area, new CommitScope(OnlyInsert: insert)); + Assert.IsInstanceOfType(onlyInsert.Single()); + } + + [TestMethod] + public void Document_diff_recurses_into_documents_and_replaces_arrays() + { + BsonDocument before = Order(); + BsonDocument after = Order(); + after["customer"]["name"] = "李四"; + after["items"][0]["qty"] = 3; + after.Remove("status"); + after["invoice"] = new BsonDocument("title", "上海云帆"); + + List<(string Path, BsonValue? Value)> diff = DocumentDiff.Compute(before, after); + CollectionAssert.AreEquivalent(new[] { "customer.name", "items", "status", "invoice" }, diff.Select(static d => d.Path).ToArray()); + Assert.IsNull(diff.Single(static d => d.Path == "status").Value, "a removed field becomes $unset"); + } + + // ── 排版、光标路径、执行计划 ──────────────────────────────────────────────── + + [TestMethod] + public void Card_printer_keeps_short_containers_inline_and_maps_lines_to_paths() + { + PrintedCard printed = CardPrinter.Print(Order(), EjsonMode.Shell); + string[] lines = printed.Text.Split('\n'); + + int customer = printed.LineOf("customer"); + StringAssert.Contains(lines[customer - 1], "customer: { name: \"张伟\", level: \"SVIP\" }"); + Assert.AreEqual(customer, printed.LineOf("customer.name"), "a field printed inline maps to its container's line"); + StringAssert.Contains(lines[printed.LineOf("status") - 1], "status: \"paid\""); + + PrintedCard relaxed = CardPrinter.Print(Order(), EjsonMode.Relaxed); + StringAssert.Contains(relaxed.Text, "\"status\": \"paid\""); + + PrintedCard preview = CardPrinter.Print(Order(), EjsonMode.Shell, static n => $"…{n} 项"); + StringAssert.Contains(preview.Text, "items: [ …1 项 ]", "the read-only preview folds arrays of documents"); + Assert.AreEqual(preview.LineOf("items"), preview.LineOf("items.0.qty"), "a change inside a folded array marks the array line"); + } + + [TestMethod] + public void Caret_scanner_finds_the_field_under_the_caret() + { + const string text = "{ status: \"pa"; + CaretPathScanner.Result value = CaretPathScanner.Scan(text, text.Length); + Assert.IsTrue(value.InValue); + Assert.AreEqual("status", value.Path); + Assert.AreEqual(text.IndexOf('"'), value.TokenStart); + + const string nested = "{ total: { $gte: "; + CaretPathScanner.Result inner = CaretPathScanner.Scan(nested, nested.Length); + Assert.AreEqual("total.$gte", inner.Path); + + const string key = "{\n customer: {\n na"; + CaretPathScanner.Result keyPosition = CaretPathScanner.Scan(key, key.Length); + Assert.IsFalse(keyPosition.InValue); + Assert.AreEqual("customer", keyPosition.ParentPath); + Assert.AreEqual(2, CaretPathScanner.Scan(key, -1).KeyLines["customer"]); + } + + [TestMethod] + public void Plan_summary_names_the_winning_scan() + { + BsonDocument classic = BsonDocument.Parse(""" + { queryPlanner: { winningPlan: { stage: "LIMIT", inputStage: { stage: "FETCH", + inputStage: { stage: "IXSCAN", indexName: "status_1_createdAt_-1" } } } } } + """); + Assert.AreEqual("IXSCAN status_1_createdAt_-1", CollectionTabViewModel.SummarizePlan(classic)); + + BsonDocument sbe = BsonDocument.Parse("{ queryPlanner: { winningPlan: { queryPlan: { stage: \"COLLSCAN\" } } } }"); + Assert.AreEqual("COLLSCAN", CollectionTabViewModel.SummarizePlan(sbe)); + } + + [TestMethod] + public void Filter_by_value_quotes_dotted_paths() + { + Assert.AreEqual("{ \"items.sku\": \"SKU-7710\" }", CollectionTabViewModel.FilterFor("items.sku", "SKU-7710")); + Assert.AreEqual("{ note: { $exists: false } }", CollectionTabViewModel.FilterFor("note", null)); + } + + [TestMethod] + public void Column_widths_follow_the_kind() + { + Assert.AreEqual(180d, CollectionTabViewModel.EstimateWidth("_id", BsonKind.ObjectId, 24)); + Assert.AreEqual(156d, CollectionTabViewModel.EstimateWidth("createdAt", BsonKind.Date, 19)); + Assert.IsTrue(CollectionTabViewModel.EstimateWidth("note", BsonKind.String, 200) <= 280); + } + + [TestMethod] + public void Localization_tables_load_with_collection_and_state_keys() + { + var zh = new Loc("zh-CN"); + var en = new Loc("en"); + Assert.AreEqual("待提交:", zh["Cw_PendingPrefix"]); + Assert.AreEqual("Edit conflict", en["State_ConflictTitle"]); + Assert.IsTrue(Loc.AllKeys.Count(static k => k.StartsWith("Cw_", StringComparison.Ordinal)) > 100); + } + + // ── 提交 / 撤销 / 冲突:真实服务器,临时库 ──────────────────────────────────── + + private static async Task<(IMongoClient Client, string Database, IMongoCollection Collection, BsonDocument[] Docs)> SeedAsync() + { + var client = new MongoClient(TestServer.Uri); + string database = "velashell_collection_" + Guid.NewGuid().ToString("N")[..8]; + IMongoCollection collection = client.GetDatabase(database).GetCollection("orders"); + BsonDocument[] docs = + [ + new() { { "_id", 1 }, { "status", "paid" }, { "customer", new BsonDocument("name", "陈立") }, { "qty", 2 } }, + new() { { "_id", 2 }, { "status", "pending" }, { "customer", new BsonDocument("name", "王芳") }, { "qty", 1 } }, + new() { { "_id", 3 }, { "status", "paid" }, { "customer", new BsonDocument("name", "李娜") }, { "qty", 5 } } + ]; + await collection.InsertManyAsync(docs.Select(static d => (BsonDocument)d.DeepClone())); + return (client, database, collection, docs); + } + + [TestMethod] + public async Task Commit_sends_one_batch_and_undo_reverts_it() + { + await TestServer.RequireAsync(); + (IMongoClient client, string database, IMongoCollection collection, BsonDocument[] docs) = await SeedAsync(); + try + { + var area = new StagingArea(); + area.SetField(docs[0], "status", "shipped"); + area.SetField(docs[0], "customer.name", "陈立(改)"); + area.MarkDeleted(docs[1]); + area.AddInsert(new BsonDocument { { "status", "new" }, { "qty", 9 } }); + + CommitOutcome outcome = await StagingCommitter.CommitAsync(collection, area, CommitScope.All); + Assert.AreEqual(1, outcome.Updated); + Assert.AreEqual(1, outcome.Inserted); + Assert.AreEqual(1, outcome.Deleted); + Assert.AreEqual(0, outcome.Conflicts.Count); + Assert.IsTrue(area.IsEmpty, "everything that was written leaves the staging area"); + + BsonDocument first = await collection.Find(new BsonDocument("_id", 1)).FirstAsync(); + Assert.AreEqual("shipped", first["status"].AsString); + Assert.AreEqual("陈立(改)", first["customer"]["name"].AsString); + long afterCommit = await collection.CountDocumentsAsync(FilterDefinition.Empty); + Assert.AreEqual(3L, afterCommit); + + (int restored, int skipped) = await outcome.Undo.ExecuteAsync(collection); + Assert.AreEqual(3, restored); + Assert.AreEqual(0, skipped); + BsonDocument reverted = await collection.Find(new BsonDocument("_id", 1)).FirstAsync(); + Assert.AreEqual("paid", reverted["status"].AsString); + Assert.AreEqual("陈立", reverted["customer"]["name"].AsString); + long back = await collection.CountDocumentsAsync(new BsonDocument("_id", 2)); + Assert.AreEqual(1L, back, "the deleted document is inserted back"); + long gone = await collection.CountDocumentsAsync(new BsonDocument("status", "new")); + Assert.AreEqual(0L, gone, "the inserted document is removed again"); + } + finally + { + await client.DropDatabaseAsync(database); + } + } + + [TestMethod] + public async Task A_concurrent_change_is_reported_as_a_conflict_and_can_be_overwritten() + { + await TestServer.RequireAsync(); + (IMongoClient client, string database, IMongoCollection collection, BsonDocument[] docs) = await SeedAsync(); + try + { + var area = new StagingArea(); + area.SetField(docs[0], "status", "shipped"); + area.SetField(docs[2], "qty", 6); + // 另一个会话在我提交前改了 #1 的 status。 + await collection.UpdateOneAsync(new BsonDocument("_id", 1), new BsonDocument("$set", new BsonDocument("status", "refunded"))); + + CommitOutcome outcome = await StagingCommitter.CommitAsync(collection, area, CommitScope.All); + Assert.AreEqual(1, outcome.Updated, "the unrelated document still goes through"); + Assert.AreEqual(1, outcome.Conflicts.Count); + EditConflict conflict = outcome.Conflicts[0]; + Assert.AreEqual("refunded", conflict.Server!["status"].AsString); + Assert.AreEqual(1, area.EditCount, "the conflicting edit stays staged"); + BsonDocument untouched = await collection.Find(new BsonDocument("_id", 1)).FirstAsync(); + Assert.AreEqual("refunded", untouched["status"].AsString, "a conflict never overwrites the other session's write"); + + area.Rebase(conflict.Edit.Id, conflict.Server, new HashSet(StringComparer.Ordinal) { "status" }); + CommitOutcome retry = await StagingCommitter.CommitAsync(collection, area, new CommitScope(conflict.Edit.Id)); + Assert.AreEqual(1, retry.Updated); + BsonDocument overwritten = await collection.Find(new BsonDocument("_id", 1)).FirstAsync(); + Assert.AreEqual("shipped", overwritten["status"].AsString); + } + finally + { + await client.DropDatabaseAsync(database); + } + } + + // ── 工作台:真实服务器上的端到端 ─────────────────────────────────────────── + + private static async Task OpenTabAsync(Workbench bench, string database, string collection) + { + bench.Session.OpenCollection(database, collection); + var tab = (CollectionTabViewModel)bench.ViewModel.ActiveTab!; + for (int i = 0; i < 200 && (tab.IsLoading || tab.Rows.Count == 0); i++) + { + await Screens.PumpAsync(5); + } + await Screens.PumpAsync(20); + return tab; + } + + [TestMethod] + public void Editing_a_cell_stages_it_and_apply_writes_it() => Screens.OnUi(async () => + { + await TestServer.RequireAsync(); + (IMongoClient client, string database, IMongoCollection collection, _) = await SeedAsync(); + try + { + await using Workbench bench = await Screens.OpenWorkbenchAsync(database); + CollectionTabViewModel tab = await OpenTabAsync(bench, database, "orders"); + Assert.AreEqual(3, tab.Rows.Count); + CollectionAssert.AreEqual(new[] { "_id", "status", "customer", "qty" }, tab.Columns.Select(static c => c.Name).ToArray()); + + CollectionRow row = tab.Rows[0]; + CollectionCell status = row.CellOf(tab.Columns.Single(static c => c.Name == "status"))!; + tab.BeginCellEdit(status); + Assert.IsNotNull(status.Editor); + status.Editor!.Text = "shipped"; + Assert.IsTrue(tab.CommitCellEdit(status)); + Assert.IsTrue(tab.IsModified, "the tab shows the orange dot while something is staged"); + Assert.AreEqual(CollectionRowState.Modified, tab.Rows[0].State); + StringAssert.Contains(tab.PendingText, "1"); + StringAssert.Contains(tab.StatusText, "1 项待提交"); + + CollectionCell qty = tab.Rows[1].CellOf(tab.Columns.Single(static c => c.Name == "qty"))!; + tab.BeginCellEdit(qty); + qty.Editor!.Text = "abc"; + Assert.IsFalse(tab.CommitCellEdit(qty), "text that does not parse as Int32 keeps the editor open"); + Assert.IsNotNull(qty.Editor.Error); + CollectionTabViewModel.CancelCellEdit(qty); + + tab.ApplyCommand.Execute(null); + for (int i = 0; i < 100 && tab.HasPending; i++) + { + await Screens.PumpAsync(5); + } + await Screens.PumpAsync(20); + Assert.IsFalse(tab.HasPending); + BsonDocument written = await collection.Find(new BsonDocument("_id", 1)).FirstAsync(); + Assert.AreEqual("shipped", written["status"].AsString); + Assert.IsTrue(bench.ViewModel.Toasts.Any(static t => t.HasAction), "the success toast carries an undo action"); + } + finally + { + await client.DropDatabaseAsync(database); + } + }); + + [TestMethod] + public void Read_only_mode_blocks_staging_and_shows_the_banner() => Screens.OnUi(async () => + { + await TestServer.RequireAsync(); + await using Workbench bench = await Screens.OpenWorkbenchAsync(); + CollectionTabViewModel tab = await OpenTabAsync(bench, Screens.Database, "orders"); + bench.Session.Guard.IsReadOnly = true; + await Screens.PumpAsync(5); + + Assert.IsTrue(tab.ShowReadOnlyBanner); + Assert.IsFalse(tab.CanEdit); + CollectionCell cell = tab.Rows[0].CellOf(tab.Columns.Single(static c => c.Name == "status"))!; + tab.BeginCellEdit(cell); + Assert.IsNull(cell.Editor, "read-only mode refuses to start an edit"); + Assert.IsTrue(tab.Staging.IsEmpty); + await Screens.PumpAsync(5); + Assert.IsTrue(bench.ViewModel.Toasts.Count > 0, "the refusal explains how to unlock"); + }); + + [TestMethod] + public void A_view_is_never_editable() => Screens.OnUi(async () => + { + await TestServer.RequireAsync(); + await using Workbench bench = await Screens.OpenWorkbenchAsync(); + CollectionTabViewModel tab = await OpenTabAsync(bench, Screens.Database, "v_order_summary"); + Assert.IsFalse(tab.IsEditable); + Assert.IsFalse(tab.CanEdit); + Assert.IsTrue(tab.ShowViewBanner); + Assert.IsTrue(tab.Rows.Count > 0); + }); + + [TestMethod] + public void Filter_errors_are_flagged_before_anything_is_sent() => Screens.OnUi(async () => + { + await TestServer.RequireAsync(); + await using Workbench bench = await Screens.OpenWorkbenchAsync(); + CollectionTabViewModel tab = await OpenTabAsync(bench, Screens.Database, "orders"); + + tab.FilterText = "{ items.sku: \"SKU-7710\" }"; + Assert.IsTrue(tab.HasFilterError, "dotted bare keys must be quoted, as in mongosh"); + Assert.IsTrue(tab.FilterDiagnostics.Count > 0); + tab.FilterText = "{ status: \"refunded\", total: { $gte: 1e12 } }"; + Assert.IsFalse(tab.HasFilterError); + await tab.RunQueryAsync(resetPage: true); + await Screens.PumpAsync(10); + Assert.IsTrue(tab.IsNoResult, "an empty result with a filter shows the no-result card, not the empty-collection card"); + Assert.IsFalse(tab.IsEmptyCollection); + }); + + [TestMethod] + public void Enter_in_the_filter_box_runs_the_query_instead_of_adding_a_line() => Screens.OnUi(async () => + { + await TestServer.RequireAsync(); + await using Workbench bench = await Screens.OpenWorkbenchAsync(); + CollectionTabViewModel tab = await OpenTabAsync(bench, Screens.Database, "orders"); + CodeEditor filter = ViewOf(bench).FindControl("FilterEditor")!; + tab.FilterText = "{ status: \"shipped\" }"; + await Screens.PumpAsync(5); + filter.FocusEditor(); + filter.Editor.CaretOffset = filter.Editor.Document.TextLength; + await Screens.PumpAsync(5); + bench.Window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None); + await Screens.PumpAsync(10); + for (int i = 0; i < 100 && tab.IsLoading; i++) + { + await Screens.PumpAsync(5); + } + Assert.AreEqual("{ status: \"shipped\" }", tab.FilterText, "Enter must not insert a line break into the one-line filter"); + Assert.AreEqual(tab.FilterText, tab.LastRunFilter); + Assert.IsTrue(tab.Rows.Count > 0); + Assert.IsTrue(tab.Rows.All(static r => r.Document["status"].AsString == "shipped")); + }); + + [TestMethod] + public void Enter_in_the_filter_box_accepts_the_highlighted_completion() => Screens.OnUi(async () => + { + await TestServer.RequireAsync(); + await using Workbench bench = await Screens.OpenWorkbenchAsync(); + CollectionTabViewModel tab = await OpenTabAsync(bench, Screens.Database, "orders"); + CodeEditor filter = ViewOf(bench).FindControl("FilterEditor")!; + tab.FilterText = "{ }"; + await Screens.PumpAsync(5); + filter.FocusEditor(); + filter.Editor.CaretOffset = 2; + await Screens.PumpAsync(5); + // 敲一个字母:补全自动弹出(字段名),再 ↓ 选到第二项、Enter 接受 —— 不能被当成「查找」吃掉。 + bench.Window.KeyTextInput("s"); + await Screens.PumpAsync(20); + Popup popup = filter.GetVisualDescendants().OfType().Single(); + Assert.IsTrue(popup.IsOpen, "typing the first letter of a key opens the field completion"); + var session = (CompletionSession)((Control)popup.Child!).DataContext!; + Assert.IsGreaterThan(1, session.Items.Count, "orders has several fields starting with s"); + bench.Window.KeyPressQwerty(PhysicalKey.ArrowDown, RawInputModifiers.None); + await Screens.PumpAsync(5); + CompletionItem highlighted = session.Selected!; + string lastRun = tab.LastRunFilter; + bench.Window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None); + await Screens.PumpAsync(10); + Assert.IsFalse(popup.IsOpen, "accepting closes the completion"); + Assert.AreEqual("{ " + highlighted.InsertText!.Replace("|", "", StringComparison.Ordinal) + " }", tab.FilterText, + "Enter inserts the highlighted item, exactly as clicking it would"); + Assert.AreEqual(lastRun, tab.LastRunFilter, "the Enter that accepted a completion must not also run the query"); + }); + + [TestMethod] + public void Keyboard_editing_in_the_grid_stages_the_value() => Screens.OnUi(async () => + { + await TestServer.RequireAsync(); + await using Workbench bench = await Screens.OpenWorkbenchAsync(); + CollectionTabViewModel tab = await OpenTabAsync(bench, Screens.Database, "orders"); + CollectionRow row = tab.Rows[0]; + tab.SelectedRow = row; + tab.CurrentColumn = tab.Columns.Single(static c => c.Name == "status"); + await Screens.PumpAsync(5); + ((Control)ViewOf(bench).Grid.List.ContainerFromItem(row)!).Focus(); + await Screens.PumpAsync(5); + bench.Window.KeyPressQwerty(PhysicalKey.F2, RawInputModifiers.None); + await Screens.PumpAsync(10); + CollectionCell cell = row.CellOf(tab.CurrentColumn)!; + Assert.IsTrue(cell.IsEditing, "F2 edits the current cell"); + bench.Window.KeyTextInput("on-hold"); + await Screens.PumpAsync(5); + bench.Window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None); + await Screens.PumpAsync(10); + StagedEdit? edit = tab.Staging.EditOf(row.Id); + Assert.IsNotNull(edit, "Enter writes the value into the staging area"); + Assert.AreEqual("on-hold", edit.Find("status")!.Value!.AsString); + tab.Staging.Clear(); + }); + + [TestMethod] + public void Fifty_thousand_rows_stay_responsive() => Screens.OnUi(async () => + { + await TestServer.RequireAsync(); + var client = new MongoClient(TestServer.Uri); + string database = "velashell_collection_" + Guid.NewGuid().ToString("N")[..8]; + try + { + IMongoCollection collection = client.GetDatabase(database).GetCollection("big"); + await collection.InsertManyAsync(Enumerable.Range(0, 50_000).Select(static i => new BsonDocument + { + { "n", i }, + { "name", "row-" + i }, + { "tags", new BsonArray { "a", "b" } }, + { "at", new BsonDateTime(DateTime.UtcNow) } + })); + await using Workbench bench = await Screens.OpenWorkbenchAsync(database); + CollectionTabViewModel tab = await OpenTabAsync(bench, database, "big"); + tab.LimitText = "50000"; + var watch = System.Diagnostics.Stopwatch.StartNew(); + await tab.RunQueryAsync(resetPage: true); + await Screens.PumpAsync(10); + watch.Stop(); + Assert.AreEqual(50_000, tab.Rows.Count); + Assert.IsTrue(watch.Elapsed < TimeSpan.FromSeconds(8), $"50k rows took {watch.Elapsed}"); + + var stage = System.Diagnostics.Stopwatch.StartNew(); + tab.Stage(tab.Rows[10], "name", "changed"); + stage.Stop(); + Assert.IsTrue(stage.Elapsed < TimeSpan.FromMilliseconds(500), $"staging one cell in a 50k page took {stage.Elapsed}"); + ViewOf(bench).Grid.List.ScrollIntoView(tab.Rows[40_000]); + await Screens.PumpAsync(10); + tab.Staging.Clear(); + } + finally + { + await client.DropDatabaseAsync(database); + } + }); + + // ── 截图(设计稿 01 / 02 / 13) ────────────────────────────────────────────── + + private static CollectionTabView ViewOf(Workbench bench) => + bench.Window.GetVisualDescendants().OfType().First(static v => v.IsEffectivelyVisible); + + /// + /// 把 orders 摆成设计稿的样子:按 createdAt 倒序、每页 17 行(新增行落在可视区里), + /// 筛选框里放设计稿那条筛选(只显示、不执行)。之后的修改都只进暂存区 —— 不提交,不碰 shop 的数据。 + /// + private static async Task PrepareOrdersAsync(Workbench bench, int limit = 17) + { + CollectionTabViewModel tab = await OpenTabAsync(bench, Screens.Database, "orders"); + tab.SortText = "{ createdAt: -1 }"; + tab.LimitText = limit.ToString(System.Globalization.CultureInfo.InvariantCulture); + await tab.RunQueryAsync(resetPage: true); + await Screens.PumpAsync(20); + tab.FilterText = "{ status: \"paid\", total: { $gte: 500 }, createdAt: { $gte: ISODate(\"2026-08-27\") } }"; + tab.ProjectionText = "{ note: 0 }"; + return tab; + } + + /// 换一个与现值不同的状态(暂存一个与原值相同的值是空操作)。 + private static string OtherStatus(CollectionRow row) => row.Document.GetValue("status", "").AsString == "shipped" ? "refunded" : "shipped"; + + [TestMethod] + [TestCategory("Screenshots")] + public void Board01_collection_grid_with_inspector() => Screens.OnUi(async () => + { + await TestServer.RequireAsync(); + await using Workbench bench = await Screens.OpenWorkbenchAsync(); + CollectionTabViewModel tab = await PrepareOrdersAsync(bench); + CollectionColumn total = tab.Columns.Single(static c => c.Name == "total"); + tab.Stage(tab.Rows[2], "status", OtherStatus(tab.Rows[2])); + tab.StageDelete([tab.Rows[8]]); + tab.AddRowCommand.Execute(null); + await Screens.PumpAsync(10); + CollectionRow added = tab.Rows[^1]; + tab.Stage(added, "orderNo", "SO2609-10418"); + tab.Stage(added, "status", "pending"); + + tab.SelectedRow = tab.Rows[3]; + await Screens.PumpAsync(10); + tab.BeginCellEdit(tab.Rows[3].CellOf(total)!); + InspectorField name = tab.Inspector.Fields.First(static f => f.Path == "customer.name"); + tab.Inspector.BeginEdit(name); + await Screens.PumpAsync(30); + CollectionTabView view = ViewOf(bench); + view.Grid.List.ScrollIntoView(tab.Rows[0]); + await Screens.PumpAsync(30); + + WriteableBitmap? frame = Screens.Capture(bench.Window, "01-collection"); + Assert.IsNotNull(frame); + Assert.IsTrue(tab.Inspector.Fields.Count > 5, "the inspector lists the selected document's fields"); + Assert.AreEqual(1, tab.Staging.EditCount); + Assert.AreEqual(1, tab.Staging.InsertCount); + Assert.AreEqual(1, tab.Staging.DeleteCount); + + tab.Inspector.Page = 1; + await Screens.PumpAsync(20); + Screens.Capture(bench.Window, "01-collection-inspector-json"); + StringAssert.Contains(tab.Inspector.JsonText, "SO2609-10403"); + tab.Inspector.Page = 2; + for (int i = 0; i < 60 && tab.Stats is null; i++) + { + await Screens.PumpAsync(5); + } + await Screens.PumpAsync(10); + Screens.Capture(bench.Window, "01-collection-inspector-info"); + Assert.IsTrue(tab.Inspector.InfoLines.Count > 5, "the collection page lists count, sizes and indexes"); + tab.Staging.Clear(); + }); + + [TestMethod] + [TestCategory("Screenshots")] + public void Board02_collection_json_cards() => Screens.OnUi(async () => + { + await TestServer.RequireAsync(); + await using Workbench bench = await Screens.OpenWorkbenchAsync(); + CollectionTabViewModel tab = await PrepareOrdersAsync(bench); + tab.Ejson = EjsonMode.Shell; + tab.Stage(tab.Rows[2], "status", OtherStatus(tab.Rows[2])); + tab.ViewMode = CollectionViewMode.Json; + await Screens.PumpAsync(20); + JsonCardViewModel card = tab.Cards[3]; + card.BeginEdit(); + string status = tab.Rows[3].Document.GetValue("status", "").AsString; + card.EditText = card.EditText.Replace($"status: \"{status}\"", "status: \"\"", StringComparison.Ordinal) + .Replace("\n}", ",\n invoice: { title: \"上海云帆科技有限公司\", taxNo: \"91310115MA1K4100\" }\n}", StringComparison.Ordinal); + await Screens.PumpAsync(30); + CollectionTabView view = ViewOf(bench); + view.Json.List.ScrollIntoView(tab.Cards[3]); + await Screens.PumpAsync(10); + view.Json.List.ScrollIntoView(tab.Cards[2]); + await Screens.PumpAsync(30); + + WriteableBitmap? frame = Screens.Capture(bench.Window, "02-collection-json"); + Assert.IsNotNull(frame); + Assert.IsTrue(card.Outline.Count > 5, "the outline lists the edited card's fields"); + Assert.IsTrue(card.EditMarks.Count >= 2, "the edited and the added field are marked while editing"); + Assert.IsTrue(tab.Cards[2].Marks.Count > 0, "a staged change is marked on the read-only card"); + tab.Staging.Clear(); + }); + + [TestMethod] + [TestCategory("Screenshots")] + public void Board13_collection_tree() => Screens.OnUi(async () => + { + await TestServer.RequireAsync(); + await using Workbench bench = await Screens.OpenWorkbenchAsync(); + CollectionTabViewModel tab = await PrepareOrdersAsync(bench, limit: 50); + CollectionRow second = tab.Rows.First(static r => r.Document.GetValue("items", new BsonArray()).AsBsonArray.Count > 1); + tab.ViewMode = CollectionViewMode.Tree; + tab.ExpandTreePath(second, "customer"); + tab.ExpandTreePath(second, "items.0"); + int qty = second.Document["items"][0]["qty"].ToInt32(); + tab.Stage(second, "items.0.qty", qty + 1); + await Screens.PumpAsync(10); + tab.SelectedTreeRow = tab.TreeRows.FirstOrDefault(r => ReferenceEquals(r.Row, second) && r.Path == "items.0.sku"); + await Screens.PumpAsync(30); + + WriteableBitmap? frame = Screens.Capture(bench.Window, "13-collection-tree"); + Assert.IsNotNull(frame); + Assert.IsTrue(tab.TreeRows.Any(static r => r.IsModified), "the staged qty is marked in the tree"); + tab.Staging.Clear(); + }); + + [TestMethod] + [TestCategory("Screenshots")] + public void Board22_collection_states_and_conflict() => Screens.OnUi(async () => + { + await TestServer.RequireAsync(); + await using Workbench bench = await Screens.OpenWorkbenchAsync(); + CollectionTabViewModel tab = await OpenTabAsync(bench, Screens.Database, "orders"); + tab.FilterText = "{ status: \"refunded\", total: { $gte: 50000 } }"; + await tab.RunQueryAsync(resetPage: true); + bench.Session.Guard.IsReadOnly = true; + await Screens.PumpAsync(30); + WriteableBitmap? states = Screens.Capture(bench.Window, "22-collection-states"); + Assert.IsNotNull(states); + Assert.IsTrue(tab.IsNoResult); + Assert.IsTrue(tab.ShowReadOnlyBanner); + + var stamp = new BsonDateTime(new DateTime(2026, 9, 27, 13, 8, 51, DateTimeKind.Utc)); + var original = new BsonDocument { { "_id", new ObjectId("66f5c2a1f9a7056304b0e3b7") }, { "status", "paid" }, { "note", BsonNull.Value }, { "updatedAt", stamp } }; + var edit = new StagedEdit(original); + edit.Set("status", "shipped"); + edit.Set("shippedAt", new BsonDateTime(new DateTime(2026, 9, 27, 2, 30, 0, DateTimeKind.Utc))); + edit.Set("note", "加急"); + var server = (BsonDocument)original.DeepClone(); + server["status"] = "refunded"; + server["note"] = "客户申请退款"; + server["updatedAt"] = new BsonDateTime(stamp.ToUniversalTime().AddSeconds(39)); + var dialog = new ConflictDialogViewModel(bench.Session, new EditConflict(edit, server), static _ => Task.CompletedTask); + bench.ViewModel.ShowDialog(dialog); + await Screens.PumpAsync(30); + WriteableBitmap? conflict = Screens.Capture(bench.Window, "22-collection-conflict"); + Assert.IsNotNull(conflict); + Assert.AreEqual(3, dialog.Rows.Count); + Assert.IsTrue(dialog.Rows.Single(static r => r.Path == "status").BothChanged); + Assert.IsFalse(dialog.Rows.Single(static r => r.Path == "shippedAt").BothChanged); + dialog.Rows[0].PickServerCommand.Execute(null); + Assert.IsTrue(dialog.Rows[0].UseServer); + bench.Session.Guard.IsReadOnly = false; + }); +} diff --git a/tests/VelaShell.Plugin.Mongo.Tests/ConnectionManagementTests.cs b/tests/VelaShell.Plugin.Mongo.Tests/ConnectionManagementTests.cs new file mode 100644 index 0000000..d23e720 --- /dev/null +++ b/tests/VelaShell.Plugin.Mongo.Tests/ConnectionManagementTests.cs @@ -0,0 +1,399 @@ +using Avalonia.Controls; +using Avalonia.Threading; +using Avalonia.VisualTree; +using MongoDB.Bson; +using MongoDB.Driver; +using VelaShell.Plugin.Mongo.Core; +using VelaShell.Plugin.Mongo.Ui; +using VelaShell.PluginSdk.Sessions; +using VelaShell.PluginSdk.Testing; + +namespace VelaShell.Plugin.Mongo.Tests; + +/// +/// 插件自己管的连接(与 Docker 面板同一个路数):对象树根上的已保存连接、设计稿 10 的新建 / 编辑对话框、 +/// 设计稿 22 的连接中 / 连接失败占位标签、几条连接同时开着时各管各的标签,以及数据区右侧面板的收起。 +/// +[TestClass] +public sealed class ConnectionManagementTests +{ + private static readonly Loc Zh = new("zh-CN"); + + /// 空工作台:不连任何东西,只有外壳与视图。 + private static async Task<(MongoWorkspaceViewModel Shell, MongoWorkspaceView View, Window Window)> OpenEmptyAsync(TestPluginContext context) + { + var shell = new MongoWorkspaceViewModel(Zh, context, new MongoStore(context)); + var view = new MongoWorkspaceView(shell); + var window = new Window { Width = 1440, Height = 836, Content = view }; + window.Show(); + await shell.InitializeAsync(); + await Screens.PumpAsync(5); + return (shell, view, window); + } + + private static MongoProfile LocalProfile(string name, Dictionary? settings = null) + { + var url = new MongoUrl(TestServer.Uri); + var profile = new MongoProfile { Name = name, Host = url.Server.Host, Port = url.Server.Port }; + profile.Set(MongoSettings.KeyDirect, "true"); + foreach ((string key, string value) in settings ?? []) + { + profile.Set(key, value); + } + return profile; + } + + // ── 外壳:空状态、已保存的连接、新建 ───────────────────────────────────── + + /// 一条连接都没有:内容区给「新建连接」;工具栏最左是「连接」,按下去是设计稿 10 的对话框。 + [TestMethod] + public void Empty_workbench_offers_a_new_connection() => Screens.OnUi(async () => + { + using var context = new TestPluginContext(); + (MongoWorkspaceViewModel shell, MongoWorkspaceView view, Window window) = await OpenEmptyAsync(context); + try + { + Assert.IsTrue(shell.HasNoConnections); + Assert.IsFalse(shell.HasSession, "工具栏右侧的只读开关与服务器徽章没有对象可讲"); + Button connect = view.GetVisualDescendants().OfType