Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/demo/XBase.Demo.App/App.axaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:diag="clr-namespace:XBase.Demo.Domain.Diagnostics;assembly=XBase.Demo.Domain"
xmlns:vm="clr-namespace:XBase.Demo.App.ViewModels"
x:Class="XBase.Demo.App.App">
<Application.DataTemplates>
<DataTemplate x:DataType="vm:TableListItemViewModel">
<StackPanel Margin="4" Spacing="2">
<TextBlock Text="{Binding Name}" FontWeight="Medium" />
<TextBlock Text="{Binding Path}" FontSize="11" Foreground="#FF617C9A" TextTrimming="CharacterEllipsis" />
</StackPanel>
</DataTemplate>
<DataTemplate x:DataType="vm:IndexListItemViewModel">
<StackPanel Margin="4" Spacing="2">
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" />
<TextBlock Text="{Binding Expression}" FontSize="11" Foreground="#FF617C9A" TextTrimming="CharacterEllipsis" />
</StackPanel>
</DataTemplate>
<DataTemplate x:DataType="diag:DemoTelemetryEvent">
<StackPanel Margin="4" Spacing="2">
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" />
<TextBlock Text="{Binding Timestamp, StringFormat='{}{0:HH:mm:ss}'}" FontSize="11" />
<ItemsControl ItemsSource="{Binding Payload}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock FontSize="11" Text="{Binding}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</DataTemplate>
</Application.DataTemplates>
</Application>
26 changes: 26 additions & 0 deletions src/demo/XBase.Demo.App/ViewModels/IndexListItemViewModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using System;
using XBase.Demo.Domain.Catalog;

namespace XBase.Demo.App.ViewModels;

/// <summary>
/// Represents an index entry for the selected table.
/// </summary>
public sealed class IndexListItemViewModel
{
public IndexListItemViewModel(IndexModel model)
{
Model = model ?? throw new ArgumentNullException(nameof(model));
Name = model.Name;
Expression = model.Expression;
Order = model.Order;
}

public IndexModel Model { get; }

public string Name { get; }

public string Expression { get; }

public int Order { get; }
}
196 changes: 161 additions & 35 deletions src/demo/XBase.Demo.App/ViewModels/ShellViewModel.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Reactive;
using System.Reactive.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using ReactiveUI;
using System.Reactive.Threading.Tasks;
using XBase.Demo.Domain.Catalog;
using XBase.Demo.Domain.Diagnostics;
using XBase.Demo.Domain.Services;
Expand All @@ -19,8 +22,9 @@ public class ShellViewModel : ReactiveObject
private readonly ILogger<ShellViewModel> _logger;

private string? _catalogRoot;
private string? _tableSummary;
private bool _isBusy;
private TableListItemViewModel? _selectedTable;
private string? _catalogStatus;

public ShellViewModel(
ITableCatalogService catalogService,
Expand All @@ -33,22 +37,60 @@ public ShellViewModel(
_telemetrySink = telemetrySink;
_logger = logger;

OpenCatalogCommand = ReactiveCommand.CreateFromTask<string>(ExecuteOpenCatalogAsync);
Tables = new ReadOnlyObservableCollection<TableListItemViewModel>(_tables);
TelemetryEvents = new ReadOnlyObservableCollection<DemoTelemetryEvent>(_telemetryEvents);
TablePage = new TablePageViewModel();

var isIdle = this.WhenAnyValue(x => x.IsBusy).Select(isBusy => !isBusy);

OpenCatalogCommand = ReactiveCommand.CreateFromTask<string, CatalogModel>(ExecuteOpenCatalogAsync, isIdle);
OpenCatalogCommand.Subscribe(OnCatalogLoaded);
OpenCatalogCommand.ThrownExceptions.Subscribe(OnOpenCatalogFault);

BrowseCatalogCommand = ReactiveCommand.CreateFromTask(ExecuteBrowseCatalogAsync, isIdle);
BrowseCatalogCommand.ThrownExceptions.Subscribe(ex => _logger.LogError(ex, "Catalog browse failed"));

var canRefresh = this.WhenAnyValue(x => x.CatalogRoot, x => x.IsBusy, (root, busy) => !busy && !string.IsNullOrWhiteSpace(root));
RefreshCatalogCommand = ReactiveCommand.CreateFromTask(ExecuteRefreshCatalogAsync, canRefresh);

LoadTableCommand = ReactiveCommand.CreateFromTask<TableListItemViewModel>(ExecuteLoadTableAsync);
LoadTableCommand.ThrownExceptions.Subscribe(OnLoadTableFault);

Observable.CombineLatest(
OpenCatalogCommand.IsExecuting.StartWith(false),
LoadTableCommand.IsExecuting.StartWith(false),
(isCatalogExecuting, isTableExecuting) => isCatalogExecuting || isTableExecuting)
.DistinctUntilChanged()
.Subscribe(executing => IsBusy = executing);

this.WhenAnyValue(x => x.SelectedTable)
.WhereNotNull()
.InvokeCommand(LoadTableCommand);
}

public ReactiveCommand<string, Unit> OpenCatalogCommand { get; }
private readonly ObservableCollection<TableListItemViewModel> _tables = new();
private readonly ObservableCollection<DemoTelemetryEvent> _telemetryEvents = new();

public Interaction<Unit, string?> SelectCatalogFolderInteraction { get; } = new();

public ReactiveCommand<string, CatalogModel> OpenCatalogCommand { get; }

public ReactiveCommand<Unit, Unit> BrowseCatalogCommand { get; }

public ReactiveCommand<Unit, Unit> RefreshCatalogCommand { get; }

public ReactiveCommand<TableListItemViewModel, Unit> LoadTableCommand { get; }

public string? CatalogRoot
{
get => _catalogRoot;
private set => this.RaiseAndSetIfChanged(ref _catalogRoot, value);
}

public string? TableSummary
public string? CatalogStatus
{
get => _tableSummary;
private set => this.RaiseAndSetIfChanged(ref _tableSummary, value);
get => _catalogStatus;
private set => this.RaiseAndSetIfChanged(ref _catalogStatus, value);
}

public bool IsBusy
Expand All @@ -57,41 +99,52 @@ public bool IsBusy
private set => this.RaiseAndSetIfChanged(ref _isBusy, value);
}

private async Task ExecuteOpenCatalogAsync(string rootPath)
public ReadOnlyObservableCollection<TableListItemViewModel> Tables { get; }

public TableListItemViewModel? SelectedTable
{
if (string.IsNullOrWhiteSpace(rootPath))
get => _selectedTable;
set => this.RaiseAndSetIfChanged(ref _selectedTable, value);
}

public TablePageViewModel TablePage { get; }

public ReadOnlyObservableCollection<DemoTelemetryEvent> TelemetryEvents { get; }

private async Task<Unit> ExecuteBrowseCatalogAsync()
{
var folder = await SelectCatalogFolderInteraction.Handle(Unit.Default);
if (string.IsNullOrWhiteSpace(folder))
{
return;
return Unit.Default;
}

try
await OpenCatalogCommand.Execute(folder).ToTask();
return Unit.Default;
}

private async Task<Unit> ExecuteRefreshCatalogAsync()
{
var root = CatalogRoot;
if (string.IsNullOrWhiteSpace(root))
{
IsBusy = true;
CatalogRoot = rootPath;

var catalog = await _catalogService.LoadCatalogAsync(rootPath);
var payload = new Dictionary<string, object?>
{
["root"] = catalog.RootPath,
["tableCount"] = catalog.Tables.Count
};
_telemetrySink.Publish(new DemoTelemetryEvent("CatalogLoaded", DateTimeOffset.UtcNow, payload));

if (catalog.Tables.Count > 0)
{
var firstTable = catalog.Tables[0];
var page = await _pageService.LoadPageAsync(firstTable, new TablePageRequest(0, 25));
TableSummary = $"{catalog.Tables.Count} tables discovered. Preview rows: {page.Rows.Count} from {firstTable.Name}.";
}
else
{
TableSummary = "Catalog scanned successfully with no tables detected.";
}
return Unit.Default;
}
finally

await OpenCatalogCommand.Execute(root).ToTask();
return Unit.Default;
}

private async Task<CatalogModel> ExecuteOpenCatalogAsync(string rootPath)
{
if (string.IsNullOrWhiteSpace(rootPath))
{
IsBusy = false;
return new CatalogModel(string.Empty, Array.Empty<TableModel>());
}

CatalogRoot = rootPath;
var catalog = await _catalogService.LoadCatalogAsync(rootPath);
return catalog;
}

private void OnOpenCatalogFault(Exception exception)
Expand All @@ -101,7 +154,80 @@ private void OnOpenCatalogFault(Exception exception)
{
["message"] = exception.Message
};
_telemetrySink.Publish(new DemoTelemetryEvent("CatalogLoadFailed", DateTimeOffset.UtcNow, payload));
TableSummary = "Catalog load failed. Review diagnostics for details.";
RecordTelemetry(new DemoTelemetryEvent("CatalogLoadFailed", DateTimeOffset.UtcNow, payload));
CatalogStatus = "Catalog load failed. Review diagnostics for details.";
_tables.Clear();
SelectedTable = null;
}

private void OnCatalogLoaded(CatalogModel catalog)
{
CatalogRoot = catalog.RootPath;
_tables.Clear();

foreach (var table in catalog.Tables.OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase))
{
_tables.Add(new TableListItemViewModel(table));
}

CatalogStatus = _tables.Count > 0
? $"{_tables.Count} tables discovered. Select a table to preview rows."
: "Catalog scanned successfully with no tables detected.";

if (_tables.Count == 0)
{
var emptyRows = Array.Empty<IDictionary<string, object?>>();
TablePage.Apply(new TablePage(emptyRows, 0, 0, TablePage.PageSize));
}

var payload = new Dictionary<string, object?>
{
["root"] = catalog.RootPath,
["tableCount"] = catalog.Tables.Count
};
RecordTelemetry(new DemoTelemetryEvent("CatalogLoaded", DateTimeOffset.UtcNow, payload));

SelectedTable = _tables.FirstOrDefault();
}

private async Task ExecuteLoadTableAsync(TableListItemViewModel table)
{
var request = new TablePageRequest(0, 25);
var page = await _pageService.LoadPageAsync(table.Model, request);
TablePage.Apply(page);

CatalogStatus = _tables.Count > 0
? $"{_tables.Count} tables available. Showing {table.Name}."
: CatalogStatus;

var payload = new Dictionary<string, object?>
{
["table"] = table.Name,
["indexes"] = table.Indexes.Count,
["rows"] = page.Rows.Count
};
Comment on lines +193 to +208

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Update UI state from ReactiveCommand background thread

The body of ExecuteLoadTableAsync runs on the task pool because LoadTableCommand is created with ReactiveCommand.CreateFromTask and no scheduler override. Inside the method we mutate TablePage, CatalogStatus and _telemetryEvents (TablePage.Apply, CatalogStatus = …, RecordTelemetry) which are all bound to the UI. When a user selects a table, this command executes off the UI thread and Avalonia will throw cross-thread collection/property update exceptions. Dispatch the mutations to RxApp.MainThreadScheduler or return a result and update the observable collections in a subscription on the main thread.

Useful? React with 👍 / 👎.

RecordTelemetry(new DemoTelemetryEvent("TablePreviewLoaded", DateTimeOffset.UtcNow, payload));
}

private void OnLoadTableFault(Exception exception)
{
_logger.LogError(exception, "Table preview load failed");
var payload = new Dictionary<string, object?>
{
["message"] = exception.Message
};
RecordTelemetry(new DemoTelemetryEvent("TablePreviewFailed", DateTimeOffset.UtcNow, payload));
CatalogStatus = "Table preview failed. Check diagnostics for more information.";
}

private void RecordTelemetry(DemoTelemetryEvent telemetryEvent)
{
_telemetrySink.Publish(telemetryEvent);

_telemetryEvents.Insert(0, telemetryEvent);
while (_telemetryEvents.Count > 64)
{
_telemetryEvents.RemoveAt(_telemetryEvents.Count - 1);
}
}
}
32 changes: 32 additions & 0 deletions src/demo/XBase.Demo.App/ViewModels/TableListItemViewModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using System;
using System.Collections.Generic;
using System.Linq;
using XBase.Demo.Domain.Catalog;

namespace XBase.Demo.App.ViewModels;

/// <summary>
/// Represents a single table entry displayed in the catalog browser.
/// </summary>
public sealed class TableListItemViewModel
{
public TableListItemViewModel(TableModel model)
{
Model = model ?? throw new ArgumentNullException(nameof(model));
Name = model.Name;
Path = model.Path;
Indexes = model.Indexes
.OrderBy(index => index.Order)
.ThenBy(index => index.Name, StringComparer.OrdinalIgnoreCase)
.Select(index => new IndexListItemViewModel(index))
.ToArray();
}

public TableModel Model { get; }

public string Name { get; }

public string Path { get; }

public IReadOnlyList<IndexListItemViewModel> Indexes { get; }
}
Loading