diff --git a/src/CodingWithCalvin.CouchbaseExplorer/CodingWithCalvin.CouchbaseExplorer.csproj b/src/CodingWithCalvin.CouchbaseExplorer/CodingWithCalvin.CouchbaseExplorer.csproj index 13acb50..cd89db5 100644 --- a/src/CodingWithCalvin.CouchbaseExplorer/CodingWithCalvin.CouchbaseExplorer.csproj +++ b/src/CodingWithCalvin.CouchbaseExplorer/CodingWithCalvin.CouchbaseExplorer.csproj @@ -87,6 +87,7 @@ + diff --git a/src/CodingWithCalvin.CouchbaseExplorer/CouchbaseExplorerWindowControl.xaml b/src/CodingWithCalvin.CouchbaseExplorer/CouchbaseExplorerWindowControl.xaml index 524aa3b..6cc4bf3 100644 --- a/src/CodingWithCalvin.CouchbaseExplorer/CouchbaseExplorerWindowControl.xaml +++ b/src/CodingWithCalvin.CouchbaseExplorer/CouchbaseExplorerWindowControl.xaml @@ -140,6 +140,15 @@ + + + + + + + + diff --git a/src/CodingWithCalvin.CouchbaseExplorer/Services/CouchbaseService.cs b/src/CodingWithCalvin.CouchbaseExplorer/Services/CouchbaseService.cs index 065daeb..6887481 100644 --- a/src/CodingWithCalvin.CouchbaseExplorer/Services/CouchbaseService.cs +++ b/src/CodingWithCalvin.CouchbaseExplorer/Services/CouchbaseService.cs @@ -166,5 +166,48 @@ public static async Task> GetCollectionsAsync(string connec var scope = scopes.FirstOrDefault(s => s.Name == scopeName); return scope?.Collections ?? new List(); } + + public static async Task GetDocumentIdsAsync(string connectionId, string bucketName, string scopeName, string collectionName, int limit = 50, int offset = 0) + { + var connection = GetConnection(connectionId); + if (connection == null) + { + throw new InvalidOperationException("Not connected to cluster"); + } + + var query = $"SELECT META().id FROM `{bucketName}`.`{scopeName}`.`{collectionName}` ORDER BY META().id LIMIT {limit + 1} OFFSET {offset}"; + + var result = await connection.Cluster.QueryAsync(query); + var documentIds = new List(); + + await foreach (var row in result.Rows) + { + documentIds.Add(row.Id); + } + + // Check if there are more documents (we fetched limit+1 to check) + var hasMore = documentIds.Count > limit; + if (hasMore) + { + documentIds.RemoveAt(documentIds.Count - 1); + } + + return new DocumentQueryResult + { + DocumentIds = documentIds, + HasMore = hasMore + }; + } + } + + public class DocumentIdResult + { + public string Id { get; set; } + } + + public class DocumentQueryResult + { + public List DocumentIds { get; set; } = new List(); + public bool HasMore { get; set; } } } diff --git a/src/CodingWithCalvin.CouchbaseExplorer/ViewModels/CollectionNode.cs b/src/CodingWithCalvin.CouchbaseExplorer/ViewModels/CollectionNode.cs index 37126f8..8ad82c6 100644 --- a/src/CodingWithCalvin.CouchbaseExplorer/ViewModels/CollectionNode.cs +++ b/src/CodingWithCalvin.CouchbaseExplorer/ViewModels/CollectionNode.cs @@ -1,7 +1,15 @@ +using System; +using System.Threading.Tasks; +using CodingWithCalvin.CouchbaseExplorer.Services; + namespace CodingWithCalvin.CouchbaseExplorer.ViewModels { public class CollectionNode : TreeNodeBase { + private bool _hasLoadedDocuments; + private int _currentOffset; + private const int BatchSize = 50; + public override string NodeType => "Collection"; public string ConnectionId { get; set; } @@ -16,9 +24,128 @@ public CollectionNode() Children.Add(new PlaceholderNode()); } - protected override void OnExpanded() + protected override async void OnExpanded() + { + if (_hasLoadedDocuments) + { + return; + } + + await LoadDocumentBatchesAsync(); + } + + public async Task RefreshAsync() + { + _hasLoadedDocuments = false; + _currentOffset = 0; + Children.Clear(); + Children.Add(new PlaceholderNode { Name = "Refreshing..." }); + await LoadDocumentBatchesAsync(); + } + + private async Task LoadDocumentBatchesAsync() + { + IsLoading = true; + + try + { + Children.Clear(); + Children.Add(new PlaceholderNode { Name = "Loading documents..." }); + + var result = await CouchbaseService.GetDocumentIdsAsync( + ConnectionId, BucketName, ScopeName, Name, BatchSize, _currentOffset); + + Children.Clear(); + + if (result.DocumentIds.Count > 0) + { + var batchNode = new DocumentBatchNode(result.DocumentIds, _currentOffset) + { + ConnectionId = ConnectionId, + BucketName = BucketName, + ScopeName = ScopeName, + CollectionName = Name, + Parent = this + }; + Children.Add(batchNode); + + _currentOffset += result.DocumentIds.Count; + + if (result.HasMore) + { + var loadMoreNode = new LoadMoreNode + { + Name = "Load More...", + Parent = this + }; + loadMoreNode.LoadMoreRequested += OnLoadMoreRequested; + Children.Add(loadMoreNode); + } + } + else + { + Children.Add(new PlaceholderNode { Name = "(No documents)" }); + } + + _hasLoadedDocuments = true; + } + catch (Exception ex) + { + Children.Clear(); + Children.Add(new PlaceholderNode { Name = $"(Error: {ex.Message})" }); + } + finally + { + IsLoading = false; + } + } + + private async void OnLoadMoreRequested(LoadMoreNode node) { - // TODO: Load documents in batches when expanded + node.LoadMoreRequested -= OnLoadMoreRequested; + Children.Remove(node); + + IsLoading = true; + + try + { + var result = await CouchbaseService.GetDocumentIdsAsync( + ConnectionId, BucketName, ScopeName, Name, BatchSize, _currentOffset); + + if (result.DocumentIds.Count > 0) + { + var batchNode = new DocumentBatchNode(result.DocumentIds, _currentOffset) + { + ConnectionId = ConnectionId, + BucketName = BucketName, + ScopeName = ScopeName, + CollectionName = Name, + Parent = this + }; + Children.Add(batchNode); + + _currentOffset += result.DocumentIds.Count; + + if (result.HasMore) + { + var loadMoreNode = new LoadMoreNode + { + Name = "Load More...", + Parent = this + }; + loadMoreNode.LoadMoreRequested += OnLoadMoreRequested; + Children.Add(loadMoreNode); + } + } + } + catch (Exception ex) + { + Children.Add(new PlaceholderNode { Name = $"(Error loading more: {ex.Message})" }); + } + finally + { + IsLoading = false; + } } } } diff --git a/src/CodingWithCalvin.CouchbaseExplorer/ViewModels/CouchbaseExplorerViewModel.cs b/src/CodingWithCalvin.CouchbaseExplorer/ViewModels/CouchbaseExplorerViewModel.cs index a5401fa..8f547b3 100644 --- a/src/CodingWithCalvin.CouchbaseExplorer/ViewModels/CouchbaseExplorerViewModel.cs +++ b/src/CodingWithCalvin.CouchbaseExplorer/ViewModels/CouchbaseExplorerViewModel.cs @@ -169,13 +169,16 @@ private async void OnRefresh(object parameter) case ScopeNode scope: await scope.RefreshAsync(); break; + case CollectionNode collection: + await collection.RefreshAsync(); + break; } } private bool CanRefresh(object parameter) { var node = parameter as TreeNodeBase ?? SelectedNode; - return node is ConnectionNode conn ? conn.IsConnected : node is BucketNode || node is ScopeNode; + return node is ConnectionNode conn ? conn.IsConnected : node is BucketNode || node is ScopeNode || node is CollectionNode; } private void OnCollapseAll(object parameter) diff --git a/src/CodingWithCalvin.CouchbaseExplorer/ViewModels/DocumentBatchNode.cs b/src/CodingWithCalvin.CouchbaseExplorer/ViewModels/DocumentBatchNode.cs new file mode 100644 index 0000000..4db1fe5 --- /dev/null +++ b/src/CodingWithCalvin.CouchbaseExplorer/ViewModels/DocumentBatchNode.cs @@ -0,0 +1,102 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using CodingWithCalvin.CouchbaseExplorer.Services; + +namespace CodingWithCalvin.CouchbaseExplorer.ViewModels +{ + public class DocumentBatchNode : TreeNodeBase + { + private bool _hasLoadedDocuments; + private List _documentIds; + + public override string NodeType => "DocumentBatch"; + + public string ConnectionId { get; set; } + + public string BucketName { get; set; } + + public string ScopeName { get; set; } + + public string CollectionName { get; set; } + + public int StartIndex { get; set; } + + public int EndIndex { get; set; } + + public DocumentBatchNode() + { + Children.Add(new PlaceholderNode()); + } + + public DocumentBatchNode(List documentIds, int startIndex) + { + _documentIds = documentIds; + StartIndex = startIndex; + EndIndex = startIndex + documentIds.Count - 1; + Name = $"[{StartIndex + 1}-{EndIndex + 1}]"; + Children.Add(new PlaceholderNode()); + } + + protected override async void OnExpanded() + { + if (_hasLoadedDocuments) + { + return; + } + + await LoadDocumentsAsync(); + } + + public async Task RefreshAsync() + { + _hasLoadedDocuments = false; + Children.Clear(); + Children.Add(new PlaceholderNode { Name = "Refreshing..." }); + await LoadDocumentsAsync(); + } + + private async Task LoadDocumentsAsync() + { + IsLoading = true; + + try + { + Children.Clear(); + + if (_documentIds != null && _documentIds.Count > 0) + { + foreach (var docId in _documentIds) + { + var docNode = new DocumentNode + { + Name = docId, + DocumentId = docId, + ConnectionId = ConnectionId, + BucketName = BucketName, + ScopeName = ScopeName, + CollectionName = CollectionName, + Parent = this + }; + Children.Add(docNode); + } + } + else + { + Children.Add(new PlaceholderNode { Name = "(No documents)" }); + } + + _hasLoadedDocuments = true; + } + catch (Exception ex) + { + Children.Clear(); + Children.Add(new PlaceholderNode { Name = $"(Error: {ex.Message})" }); + } + finally + { + IsLoading = false; + } + } + } +} diff --git a/src/CodingWithCalvin.CouchbaseExplorer/ViewModels/DocumentNode.cs b/src/CodingWithCalvin.CouchbaseExplorer/ViewModels/DocumentNode.cs index f1039d1..146e652 100644 --- a/src/CodingWithCalvin.CouchbaseExplorer/ViewModels/DocumentNode.cs +++ b/src/CodingWithCalvin.CouchbaseExplorer/ViewModels/DocumentNode.cs @@ -4,6 +4,8 @@ public class DocumentNode : TreeNodeBase { public override string NodeType => "Document"; + public string ConnectionId { get; set; } + public string DocumentId { get; set; } public string BucketName { get; set; } diff --git a/src/CodingWithCalvin.CouchbaseExplorer/ViewModels/LoadMoreNode.cs b/src/CodingWithCalvin.CouchbaseExplorer/ViewModels/LoadMoreNode.cs index 4a1bb64..3e4af3f 100644 --- a/src/CodingWithCalvin.CouchbaseExplorer/ViewModels/LoadMoreNode.cs +++ b/src/CodingWithCalvin.CouchbaseExplorer/ViewModels/LoadMoreNode.cs @@ -1,3 +1,5 @@ +using System; + namespace CodingWithCalvin.CouchbaseExplorer.ViewModels { public class LoadMoreNode : TreeNodeBase @@ -6,9 +8,21 @@ public class LoadMoreNode : TreeNodeBase public int NextOffset { get; set; } + public event Action LoadMoreRequested; + public LoadMoreNode() { Name = "Load More..."; } + + public void RequestLoadMore() + { + LoadMoreRequested?.Invoke(this); + } + + protected override void OnSelected() + { + RequestLoadMore(); + } } } diff --git a/src/CodingWithCalvin.CouchbaseExplorer/ViewModels/TreeNodeBase.cs b/src/CodingWithCalvin.CouchbaseExplorer/ViewModels/TreeNodeBase.cs index 7f53e71..36ed9b4 100644 --- a/src/CodingWithCalvin.CouchbaseExplorer/ViewModels/TreeNodeBase.cs +++ b/src/CodingWithCalvin.CouchbaseExplorer/ViewModels/TreeNodeBase.cs @@ -34,7 +34,13 @@ public bool IsExpanded public bool IsSelected { get => _isSelected; - set => SetProperty(ref _isSelected, value); + set + { + if (SetProperty(ref _isSelected, value) && value) + { + OnSelected(); + } + } } public bool IsLoading @@ -53,6 +59,10 @@ protected virtual void OnExpanded() { } + protected virtual void OnSelected() + { + } + protected bool SetProperty(ref T field, T value, [CallerMemberName] string propertyName = null) { if (Equals(field, value))