From 752c0bc59ba3552218fa31fb360c19f296014982 Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 20 May 2026 07:57:57 +0200 Subject: [PATCH 1/5] Add DetailOverlayWindow and MainWindow implementations for Chronicle Workbench - Introduced DetailOverlayWindow class to create a modal overlay for displaying item details with tabbed content and action buttons. - Implemented MainWindow class as the main interface for the Chronicle Workbench, featuring navigation, content area, event log, and status bar. - Integrated various views (Overview, Observers, Jobs, etc.) into the MainWindow with appropriate callbacks for user actions. - Enhanced user experience with keyboard shortcuts and command palette for quick navigation and actions. --- Source/Cli/Cli.csproj | 1 + .../Commands/Chronicle/Workbench/NavFrame.cs | 12 - .../Chronicle/Workbench/WorkbenchAction.cs | 68 - .../Chronicle/Workbench/WorkbenchApp.cs | 31 + .../Chronicle/Workbench/WorkbenchColors.cs | 62 + .../Chronicle/Workbench/WorkbenchCommand.cs | 1139 +-------------- .../Workbench/WorkbenchDataService.cs | 332 +++++ .../WorkbenchRenderer.DetailViews.cs | 265 ---- .../Workbench/WorkbenchRenderer.Views.cs | 655 --------- .../Chronicle/Workbench/WorkbenchRenderer.cs | 316 ----- .../Chronicle/Workbench/views/EventLogView.cs | 223 +++ .../Workbench/views/EventStoresView.cs | 127 ++ .../Workbench/views/EventTypesView.cs | 224 +++ .../Workbench/views/FailedPartitionsView.cs | 235 +++ .../Workbench/views/IWorkbenchView.cs | 55 + .../Chronicle/Workbench/views/JobsView.cs | 196 +++ .../Workbench/views/NamespacesView.cs | 128 ++ .../Workbench/views/ObserversView.cs | 300 ++++ .../Chronicle/Workbench/views/OverviewView.cs | 196 +++ .../Workbench/views/ProjectionsView.cs | 203 +++ .../Workbench/views/ReadModelsView.cs | 304 ++++ .../Workbench/views/RecommendationsView.cs | 159 +++ .../Workbench/windows/DetailOverlayWindow.cs | 84 ++ .../Chronicle/Workbench/windows/MainWindow.cs | 1259 +++++++++++++++++ 24 files changed, 4126 insertions(+), 2448 deletions(-) delete mode 100644 Source/Cli/Commands/Chronicle/Workbench/NavFrame.cs delete mode 100644 Source/Cli/Commands/Chronicle/Workbench/WorkbenchAction.cs create mode 100644 Source/Cli/Commands/Chronicle/Workbench/WorkbenchApp.cs create mode 100644 Source/Cli/Commands/Chronicle/Workbench/WorkbenchColors.cs create mode 100644 Source/Cli/Commands/Chronicle/Workbench/WorkbenchDataService.cs delete mode 100644 Source/Cli/Commands/Chronicle/Workbench/WorkbenchRenderer.DetailViews.cs delete mode 100644 Source/Cli/Commands/Chronicle/Workbench/WorkbenchRenderer.Views.cs delete mode 100644 Source/Cli/Commands/Chronicle/Workbench/WorkbenchRenderer.cs create mode 100644 Source/Cli/Commands/Chronicle/Workbench/views/EventLogView.cs create mode 100644 Source/Cli/Commands/Chronicle/Workbench/views/EventStoresView.cs create mode 100644 Source/Cli/Commands/Chronicle/Workbench/views/EventTypesView.cs create mode 100644 Source/Cli/Commands/Chronicle/Workbench/views/FailedPartitionsView.cs create mode 100644 Source/Cli/Commands/Chronicle/Workbench/views/IWorkbenchView.cs create mode 100644 Source/Cli/Commands/Chronicle/Workbench/views/JobsView.cs create mode 100644 Source/Cli/Commands/Chronicle/Workbench/views/NamespacesView.cs create mode 100644 Source/Cli/Commands/Chronicle/Workbench/views/ObserversView.cs create mode 100644 Source/Cli/Commands/Chronicle/Workbench/views/OverviewView.cs create mode 100644 Source/Cli/Commands/Chronicle/Workbench/views/ProjectionsView.cs create mode 100644 Source/Cli/Commands/Chronicle/Workbench/views/ReadModelsView.cs create mode 100644 Source/Cli/Commands/Chronicle/Workbench/views/RecommendationsView.cs create mode 100644 Source/Cli/Commands/Chronicle/Workbench/windows/DetailOverlayWindow.cs create mode 100644 Source/Cli/Commands/Chronicle/Workbench/windows/MainWindow.cs diff --git a/Source/Cli/Cli.csproj b/Source/Cli/Cli.csproj index 008d56a..219ff3f 100644 --- a/Source/Cli/Cli.csproj +++ b/Source/Cli/Cli.csproj @@ -24,6 +24,7 @@ + diff --git a/Source/Cli/Commands/Chronicle/Workbench/NavFrame.cs b/Source/Cli/Commands/Chronicle/Workbench/NavFrame.cs deleted file mode 100644 index b05f993..0000000 --- a/Source/Cli/Commands/Chronicle/Workbench/NavFrame.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) Cratis. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - -namespace Cratis.Cli.Commands.Chronicle.Workbench; - -/// -/// A frame on the workbench navigation stack, capturing view state before drilling into a detail view. -/// -/// The view enum value (stored as int to avoid volatile boxing). -/// The list cursor position in that view. -/// The focused item identifier in that view. -public readonly record struct NavFrame(int View, int SelectedIndex, string FocusedId); diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchAction.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchAction.cs deleted file mode 100644 index 3411c7b..0000000 --- a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchAction.cs +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright (c) Cratis. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - -namespace Cratis.Cli.Commands.Chronicle.Workbench; - -/// -/// The current action lifecycle state of the workbench. -/// -public enum WorkbenchActionState -{ - /// Normal operation — no action pending. - None, - - /// An action is queued and awaiting user confirmation. - AwaitingConfirmation, - - /// An action is being executed against the server. - Executing, - - /// An action has completed; the result is available. - Completed -} - -/// -/// Describes a pending in-place action that requires user confirmation before execution. -/// -/// Human-readable label shown in the confirmation prompt. -/// Message displayed after successful execution. -/// The gRPC call to invoke when the user confirms. -public record PendingAction( - string Description, - string SuccessMessage, - Func Execute); - -/// -/// Carries all render-relevant state for a single frame of the workbench, keeping the Build() signature stable. -/// -/// The currently active view (primary or detail). -/// The selected list-item index within the current view. -/// The current refresh interval in seconds. -/// Whether a data fetch is currently in progress. -/// The current action lifecycle state. -/// Description of the action awaiting confirmation, or . -/// Result message after action completion, or . -/// Whether the most recent action completed with an error. -/// The identifier of the item shown in a detail view. -/// The content scroll offset (line number) in detail views. -/// Navigation breadcrumb path, e.g. ["Observers", "SomeProjection"]. -/// The active inline filter string, or empty when no filter is applied. -/// Whether the user is actively typing a filter (entered via '/'). -/// Whether the event log is sorted ascending (oldest first) instead of the default descending (newest first). -/// The current zero-based page index within the event log (50 events per page). -public record WorkbenchRenderState( - WorkbenchView View, - int SelectedIndex, - int Interval, - bool IsRefreshing, - WorkbenchActionState ActionState, - string? PendingActionDescription, - string? ActionResult, - bool IsActionError = false, - string FocusedId = "", - int ScrollOffset = 0, - IReadOnlyList? Breadcrumb = null, - string FilterText = "", - bool FilterInputMode = false, - bool EventLogAscending = false, - int EventLogPage = 0); diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchApp.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchApp.cs new file mode 100644 index 0000000..a89155a --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchApp.cs @@ -0,0 +1,31 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using SharpConsoleUI; +using SharpConsoleUI.Drivers; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Bootstraps and runs the Chronicle Workbench TUI application using SharpConsoleUI. +/// +/// The data service for fetching workbench snapshots. +/// The workbench command settings. +/// The Chronicle gRPC service clients. +/// Pre-fetched data snapshot to populate all views before the first frame is rendered. +public class WorkbenchApp(WorkbenchDataService dataService, WorkbenchSettings settings, IServices services, WorkbenchData initialData) +{ + /// + /// Runs the workbench TUI and blocks until the user exits. + /// + /// The exit code. + public int Run() + { + var windowSystem = new ConsoleWindowSystem(new NetConsoleDriver(RenderMode.Buffer)); + + var mainWindow = new MainWindow(windowSystem, dataService, settings, services, initialData); + windowSystem.AddWindow(mainWindow.Build(), activateWindow: true); + + return windowSystem.Run(); + } +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchColors.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchColors.cs new file mode 100644 index 0000000..28541da --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchColors.cs @@ -0,0 +1,62 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using SColor = SharpConsoleUI.Color; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Provides SharpConsoleUI-compatible color constants using a vivid Tokyo Night Storm inspired palette. +/// +public static class WorkbenchColors +{ + /// + /// The primary accent color — electric blue (Tokyo Night: Blue). + /// + public static readonly SColor Accent = new(122, 162, 247, 255); + + /// + /// A muted blue-grey for secondary text. + /// + public static readonly SColor Muted = new(86, 95, 137, 255); + + /// + /// The success color — vivid neon green (Tokyo Night: Green). + /// + public static readonly SColor Success = new(115, 218, 118, 255); + + /// + /// The warning color — vivid amber (Tokyo Night: Warning). + /// + public static readonly SColor Warning = new(224, 175, 104, 255); + + /// + /// The danger/error color — vivid coral-red (Tokyo Night: Red/Pink). + /// + public static readonly SColor Danger = new(247, 118, 142, 255); + + /// + /// A very dark navy-black background color (GitHub dark background). + /// + public static readonly SColor Background = new(13, 17, 23, 255); + + /// + /// A dark blue-grey surface color for panels. + /// + public static readonly SColor Surface = new(22, 27, 39, 255); + + /// + /// The primary foreground text color — cold white-blue (Tokyo Night: Foreground). + /// + public static readonly SColor Foreground = new(192, 202, 245, 255); + + /// + /// Mauve/purple accent for variety in stream palettes and indicators. + /// + public static readonly SColor Mauve = new(187, 154, 247, 255); + + /// + /// Teal/cyan accent for variety in stream palettes and indicators. + /// + public static readonly SColor Teal = new(42, 195, 222, 255); +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchCommand.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchCommand.cs index c5e4e4d..3bdf24d 100644 --- a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchCommand.cs +++ b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchCommand.cs @@ -1,62 +1,19 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -using System.Text.Json; -using Cratis.Chronicle.Contracts.Jobs; -using Cratis.Cli.Commands.Chronicle.ReadModels; - namespace Cratis.Cli.Commands.Chronicle.Workbench; /// /// Interactive TUI workbench — a live-updating dashboard with full drill-down navigation and in-place /// actions for observers, failed partitions, jobs, recommendations, event log, event types, and projections. /// -[LlmDescription("Opens an interactive full-screen TUI workbench for the Chronicle server. Navigate with ← → or 1–8, drill into items with Enter, go back with Escape. Not suitable for scripting.")] +[LlmDescription("Opens an interactive full-screen TUI workbench for the Chronicle server. Navigate with number keys 1–9 to switch tabs. Not suitable for scripting.")] [CliCommand("workbench", "Open the interactive Chronicle workbench (live TUI dashboard)", Branch = typeof(ChronicleBranch))] [CliExample("chronicle", "workbench")] [CliExample("chronicle", "workbench", "--interval", "10")] [CliExample("chronicle", "workbench", "-e", "my-event-store")] public class WorkbenchCommand : ChronicleCommand { - /// Number of primary values (values < 100) — must be kept in sync with the enum. - const int ViewCount = 11; - - /// Number of events displayed per page in the Event Log view. - const int EventLogPageSize = 50; - - /// Total events fetched from the server for the Event Log — determines how many pages are available. - const int EventLogFetchWindow = 500; - - static readonly JsonSerializerOptions _instanceJsonOptions = new() { WriteIndented = true }; - - /// Nav stack — only touched by the input task (HandleKey). Render loop uses snapshot fields. - readonly Stack _navStack = new(); - - volatile int _currentView; - volatile int _selectedIndex; - volatile int _actionState; - volatile int _scrollOffset; - volatile int _isActionError; - volatile int _filterInputMode; - volatile int _eventLogAscending; - volatile int _eventLogPage; - - /// - /// Fired by the input task whenever the user presses a key; wakes the render loop immediately - /// rather than waiting for the full refresh interval to elapse. - /// - TaskCompletionSource _keyPressSignal = new(TaskCreationOptions.RunContinuationsAsynchronously); - - PendingAction? _pendingAction; - string _actionResult = string.Empty; - string _focusedId = string.Empty; - string _filter = string.Empty; - string? _activeEventStore; - string? _activeNamespace; - IReadOnlyList _breadcrumb = []; - IServices? _services; - WorkbenchData? _lastData; - /// protected override bool UseStatusSpinner => false; @@ -73,1098 +30,16 @@ protected override async Task ExecuteCommandAsync(IServices services, Workb return ExitCodes.ValidationError; } - _services = services; - - using var cts = new CancellationTokenSource(); - Console.CancelKeyPress += (_, e) => - { - e.Cancel = true; - cts.Cancel(); - }; - - var inputTask = Task.Run( - async () => - { - while (!cts.Token.IsCancellationRequested) - { - if (Console.KeyAvailable) - { - var pressedKey = Console.ReadKey(intercept: true); - HandleKey(pressedKey, settings, cts); - } - - try - { - await Task.Delay(50, cts.Token).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - break; - } - } - }, - cts.Token); + var dataService = new WorkbenchDataService(services, settings); - var data = WorkbenchData.Loading(settings); + // Pre-fetch before launching the window so every view has real data from the first frame — + // mirroring the old render-loop approach where data was fetched before the first render. + var initialData = await dataService.FetchAsync(null, null, null, CancellationToken.None).ConfigureAwait(false); - await AnsiConsole.Live(WorkbenchRenderer.Build(data, LoadingState(settings))) - .StartAsync(async ctx => - { - while (!cts.Token.IsCancellationRequested) - { - var signal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - Interlocked.Exchange(ref _keyPressSignal, signal); - - ctx.UpdateTarget(WorkbenchRenderer.Build(data, RenderState(settings, isRefreshing: true))); - ctx.Refresh(); - - data = await FetchData(services, settings, _activeEventStore, _activeNamespace, (WorkbenchView)_currentView, _focusedId, cts.Token); - _lastData = data; - - ctx.UpdateTarget(WorkbenchRenderer.Build(data, RenderState(settings, isRefreshing: false))); - ctx.Refresh(); - - try - { - await Task.WhenAny( - Task.Delay(TimeSpan.FromSeconds(settings.Interval), cts.Token), - signal.Task); - } - catch (OperationCanceledException) - { - break; - } - - if (cts.Token.IsCancellationRequested) break; - - ctx.UpdateTarget(WorkbenchRenderer.Build(data, RenderState(settings, isRefreshing: true))); - ctx.Refresh(); - } - }); - - try - { - await inputTask; - } - catch (OperationCanceledException) - { - } + var app = new WorkbenchApp(dataService, settings, services, initialData); + app.Run(); AnsiConsole.MarkupLine($" [{OutputFormatter.Muted.ToMarkup()}]Workbench closed.[/]"); return ExitCodes.Success; } - - static async Task FetchData( - IServices services, - WorkbenchSettings settings, - string? activeEventStore, - string? activeNamespace, - WorkbenchView currentView, - string focusedId, - CancellationToken ct) - { - var eventStore = activeEventStore ?? settings.ResolveEventStore(); - var ns = activeNamespace ?? settings.ResolveNamespace(); - var connectionString = settings.ResolveConnectionString(); - - string? serverVersion = null; - var isConnected = true; - - try - { - var versionInfo = await services.Server.GetVersionInfo(); - serverVersion = versionInfo.Version; - } - catch - { - isConnected = false; - } - - var eventStoreNames = new List(); - try { eventStoreNames = [.. await services.EventStores.GetEventStores()]; } - catch { } - - IReadOnlyList observers = []; - try - { - observers = [.. await services.Observers.GetObservers(new AllObserversRequest - { - EventStore = eventStore, - Namespace = ns - })]; - } - catch { } - - IReadOnlyList failedPartitions = []; - try - { - failedPartitions = [.. await services.FailedPartitions.GetFailedPartitions(new GetFailedPartitionsRequest - { - EventStore = eventStore, - Namespace = ns - })]; - } - catch { } - - IReadOnlyList jobs = []; - try - { - jobs = [.. (await services.Jobs.GetJobs(new GetJobsRequest - { - EventStore = eventStore, - Namespace = ns - })) ?? []]; - } - catch { } - - IReadOnlyList recommendations = []; - try - { - recommendations = [.. await services.Recommendations.GetRecommendations(new GetRecommendationsRequest - { - EventStore = eventStore, - Namespace = ns - })]; - } - catch { } - - ulong? tailSequenceNumber = null; - try - { - var tail = await services.EventSequences.GetTailSequenceNumber(new GetTailSequenceNumberRequest - { - EventStore = eventStore, - Namespace = ns, - EventSequenceId = CliDefaults.DefaultEventSequenceId - }); - tailSequenceNumber = tail.SequenceNumber == ulong.MaxValue ? null : tail.SequenceNumber; - } - catch { } - - IReadOnlyList eventTypeRegistrations = []; - try - { - eventTypeRegistrations = [.. await services.EventTypes.GetAllRegistrations( - new GetAllEventTypesRequest { EventStore = eventStore })]; - } - catch { } - - IReadOnlyList projectionDefinitions = []; - try - { - projectionDefinitions = [.. await services.Projections.GetAllDefinitions( - new GetAllDefinitionsRequest { EventStore = eventStore })]; - } - catch { } - - var projectionDeclarations = new Dictionary(); - try - { - var declarations = await services.Projections.GetAllDeclarations( - new GetAllDeclarationsRequest { EventStore = eventStore }); - foreach (var d in declarations) - { - projectionDeclarations[d.Identifier] = d.Declaration ?? string.Empty; - } - } - catch { } - - IReadOnlyList recentEvents = []; - try - { - if (tailSequenceNumber > 0) - { - var fromSeq = tailSequenceNumber.Value >= EventLogFetchWindow - ? tailSequenceNumber.Value - EventLogFetchWindow + 1 - : 0; - var eventsResp = await services.EventSequences.GetEventsFromEventSequenceNumber( - new GetFromEventSequenceNumberRequest - { - EventStore = eventStore, - Namespace = ns, - EventSequenceId = CliDefaults.DefaultEventSequenceId, - FromEventSequenceNumber = fromSeq - }); - recentEvents = [.. eventsResp.Events.OrderByDescending(e => e.Context.SequenceNumber)]; - } - } - catch { } - - IReadOnlyList readModelDefinitions = []; - try - { - var defs = await services.ReadModels.GetDefinitions(new GetDefinitionsRequest { EventStore = eventStore }); - readModelDefinitions = [.. defs.ReadModels.Select(rm => new WorkbenchReadModel( - rm.ContainerName, - rm.DisplayName, - rm.Owner.ToString(), - !string.Equals(rm.Owner.ToString(), "Client", StringComparison.Ordinal), - rm.Source.ToString(), - rm.Type?.Identifier ?? string.Empty))]; - } - catch { } - - IReadOnlyList namespaceNames = []; - try { namespaceNames = [.. await services.Namespaces.GetNamespaces(new GetNamespacesRequest { EventStore = eventStore })]; } - catch { } - - IReadOnlyList readModelInstances = []; - var readModelInstancesTotalCount = 0; - string? readModelInstancesError = null; - if (currentView == WorkbenchView.ReadModelDetail && !string.IsNullOrEmpty(focusedId)) - { - try - { - var instResp = await services.ReadModels.GetInstances(new GetInstancesRequest - { - EventStore = eventStore, - Namespace = ns, - ReadModel = focusedId, - Page = 0, - PageSize = 20 - }); - readModelInstancesTotalCount = (int)Math.Min(instResp.TotalCount, int.MaxValue); - readModelInstances = [.. (instResp.Instances ?? []) - .Select(ReadModelJsonCleaner.CleanInstance) - .Where(o => o is not null) - .Select(o => JsonSerializer.Serialize(o, _instanceJsonOptions))]; - } - catch (Exception ex) - { - readModelInstancesError = ex.Message; - } - } - - return new WorkbenchData( - ConnectionString: connectionString, - EventStore: eventStore, - Namespace: ns, - IsConnected: isConnected, - ServerVersion: serverVersion, - EventStoreNames: eventStoreNames, - Observers: observers, - FailedPartitions: failedPartitions, - Jobs: jobs, - Recommendations: recommendations, - TailSequenceNumber: tailSequenceNumber, - CapturedAt: DateTimeOffset.Now, - FetchError: null, - EventTypeRegistrations: eventTypeRegistrations, - ProjectionDefinitions: projectionDefinitions, - ProjectionDeclarations: projectionDeclarations, - RecentEvents: recentEvents, - ReadModelDefinitions: readModelDefinitions, - NamespaceNames: namespaceNames, - ReadModelInstances: readModelInstances, - ReadModelInstancesTotalCount: readModelInstancesTotalCount, - ReadModelInstancesError: readModelInstancesError); - } - - static int ObserverSortOrder(ObserverInformation o) => o.RunningState switch - { - ObserverRunningState.Disconnected => 0, - ObserverRunningState.Replaying => 1, - ObserverRunningState.Active => 2, - ObserverRunningState.Suspended => 3, - _ => 4 - }; - - static string GetViewLabel(WorkbenchView view) => view switch - { - WorkbenchView.Overview => "Overview", - WorkbenchView.Observers => "Observers", - WorkbenchView.FailedPartitions => "Failures", - WorkbenchView.Jobs => "Jobs", - WorkbenchView.Recommendations => "Recommendations", - WorkbenchView.EventLog => "Event Log", - WorkbenchView.EventTypes => "Event Types", - WorkbenchView.Projections => "Projections", - WorkbenchView.ReadModels => "Read Models", - WorkbenchView.EventStores => "Event Stores", - WorkbenchView.Namespaces => "Namespaces", - WorkbenchView.ObserverDetail => "Observer", - WorkbenchView.FailedPartitionDetail => "Failure", - WorkbenchView.EventDetail => "Event", - WorkbenchView.EventTypeDetail => "Event Type", - WorkbenchView.ProjectionDetail => "Projection", - WorkbenchView.ReadModelDetail => "Read Model", - _ => string.Empty - }; - - static WorkbenchRenderState LoadingState(WorkbenchSettings settings) => - new(WorkbenchView.Overview, 0, settings.Interval, true, WorkbenchActionState.None, null, null); - - static string TruncateForPrompt(string s) => s.Length <= 60 ? s : s[..57] + "…"; - - static bool HasSubNavigation(WorkbenchView view) => view == WorkbenchView.ObserverDetail; - - static bool IsFilterableView(WorkbenchView view) => - view is WorkbenchView.Observers or WorkbenchView.EventTypes - or WorkbenchView.EventLog or WorkbenchView.Projections - or WorkbenchView.ReadModels; - - int GetMaxSelectedIndex() - { - var view = (WorkbenchView)_currentView; - var data = _lastData; - if (data is null) return 0; - if (view == WorkbenchView.EventLog) - { - var pageStart = _eventLogPage * EventLogPageSize; - return Math.Max(0, Math.Min(EventLogPageSize, data.RecentEvents.Count - pageStart) - 1); - } - return Math.Max(0, view switch - { - WorkbenchView.Observers => data.Observers.Count - 1, - WorkbenchView.FailedPartitions => data.FailedPartitions.Count - 1, - WorkbenchView.Jobs => data.Jobs.Count - 1, - WorkbenchView.Recommendations => data.Recommendations.Count - 1, - WorkbenchView.EventTypes => data.EventTypeRegistrations.Count - 1, - WorkbenchView.Projections => data.ProjectionDefinitions.Count - 1, - WorkbenchView.ReadModels => data.ReadModelDefinitions.Count - 1, - WorkbenchView.EventStores => data.EventStoreNames.Count - 1, - WorkbenchView.Namespaces => data.NamespaceNames.Count - 1, - WorkbenchView.ObserverDetail => Math.Max(0, (data.Observers - .FirstOrDefault(o => o.Id == _focusedId)?.EventTypes?.Count() ?? 1) - 1), - _ => 0 - }); - } - - WorkbenchRenderState RenderState(WorkbenchSettings settings, bool isRefreshing) => - new( - View: (WorkbenchView)_currentView, - SelectedIndex: _selectedIndex, - Interval: settings.Interval, - IsRefreshing: isRefreshing, - ActionState: (WorkbenchActionState)_actionState, - PendingActionDescription: _pendingAction?.Description, - ActionResult: _actionResult, - IsActionError: _isActionError != 0, - FocusedId: _focusedId, - ScrollOffset: _scrollOffset, - Breadcrumb: _breadcrumb, - FilterText: _filter, - FilterInputMode: _filterInputMode != 0, - EventLogAscending: _eventLogAscending != 0, - EventLogPage: _eventLogPage); - - ObserverInformation? GetSelectedObserver() => - _lastData?.Observers - .OrderBy(ObserverSortOrder) - .ThenBy(o => o.Id) - .Skip(_selectedIndex) - .FirstOrDefault(); - - ObserverInformation? FindObserverById(string id) => - _lastData?.Observers.FirstOrDefault(o => o.Id == id); - - FailedPartition? GetSelectedFailedPartition() => - _lastData?.FailedPartitions - .OrderByDescending(fp => fp.Attempts.Count()) - .Skip(_selectedIndex) - .FirstOrDefault(); - - FailedPartition? FindFailedPartitionByFocusedId(string focusedId) - { - var sep = focusedId.IndexOf('/'); - if (sep < 0) return null; - var obsId = focusedId[..sep]; - var partition = focusedId[(sep + 1)..]; - return _lastData?.FailedPartitions - .FirstOrDefault(fp => fp.ObserverId == obsId && fp.Partition == partition); - } - - Job? GetSelectedJob() => - _lastData?.Jobs - .OrderBy(j => j.Status.ToString()) - .Skip(_selectedIndex) - .FirstOrDefault(); - - Recommendation? GetSelectedRecommendation() => - _lastData?.Recommendations - .Skip(_selectedIndex) - .FirstOrDefault(); - - AppendedEvent? GetSelectedEvent() => - _lastData?.RecentEvents - .Skip(_selectedIndex) - .FirstOrDefault(); - - EventTypeRegistration? GetSelectedEventTypeRegistration() => - _lastData?.EventTypeRegistrations - .OrderBy(r => r.Type.Id, StringComparer.OrdinalIgnoreCase) - .ThenBy(r => r.Type.Generation) - .Skip(_selectedIndex) - .FirstOrDefault(); - - ProjectionDefinition? GetSelectedProjectionDefinition() => - _lastData?.ProjectionDefinitions - .OrderBy(d => d.Identifier, StringComparer.OrdinalIgnoreCase) - .Skip(_selectedIndex) - .FirstOrDefault(); - - WorkbenchReadModel? GetSelectedReadModel() => - _lastData?.ReadModelDefinitions - .OrderBy(d => d.ContainerName, StringComparer.OrdinalIgnoreCase) - .Skip(_selectedIndex) - .FirstOrDefault(); - - string? GetSelectedEventStoreName() => - _lastData?.EventStoreNames - .Order(StringComparer.OrdinalIgnoreCase) - .Skip(_selectedIndex) - .FirstOrDefault(); - - string? GetSelectedNamespaceName() => - _lastData?.NamespaceNames - .Order(StringComparer.OrdinalIgnoreCase) - .Skip(_selectedIndex) - .FirstOrDefault(); - - void PushNav() - { - _navStack.Push(new NavFrame(_currentView, _selectedIndex, _focusedId)); - } - - List BuildBreadcrumb() - { - if (_navStack.Count == 0) return []; - var path = new List(); - foreach (var frame in _navStack.Reverse()) - { - path.Add(GetViewLabel((WorkbenchView)frame.View)); - } - - if (!string.IsNullOrEmpty(_focusedId)) path.Add(TruncateForPrompt(_focusedId)); - return path; - } - - void NavigateToDetail(WorkbenchView detailView, string focusedId) - { - PushNav(); - _focusedId = focusedId; - _currentView = (int)detailView; - _selectedIndex = 0; - _scrollOffset = 0; - _breadcrumb = BuildBreadcrumb(); - } - - void NavigateBack() - { - var frame = _navStack.Pop(); - _currentView = frame.View; - _selectedIndex = frame.SelectedIndex; - _focusedId = frame.FocusedId; - _scrollOffset = 0; - _breadcrumb = BuildBreadcrumb(); - } - - void SetPendingAction(PendingAction action) - { - _pendingAction = action; - _actionState = (int)WorkbenchActionState.AwaitingConfirmation; - _keyPressSignal.TrySetResult(); - } - - async Task ExecuteActionAsync(CancellationToken ct) - { - var action = _pendingAction; - if (action is null) return; - - _isActionError = 0; - _actionState = (int)WorkbenchActionState.Executing; - _keyPressSignal.TrySetResult(); - - try - { - await action.Execute(ct); - _actionResult = action.SuccessMessage; - } - catch (OperationCanceledException) - { - _actionState = (int)WorkbenchActionState.None; - return; - } - catch (Exception ex) - { - var msg = ex.Message; - _actionResult = msg.Length > 100 ? msg[..100] + "…" : msg; - _isActionError = 1; - } - - _pendingAction = null; - _actionState = (int)WorkbenchActionState.Completed; - _keyPressSignal.TrySetResult(); - } - - void HandleKey(ConsoleKeyInfo keyInfo, WorkbenchSettings settings, CancellationTokenSource cts) - { - var key = keyInfo.Key; - var actionState = (WorkbenchActionState)_actionState; - var view = (WorkbenchView)_currentView; - - // When a completed action is showing its result, any key dismisses it. - if (actionState == WorkbenchActionState.Completed) - { - _actionState = (int)WorkbenchActionState.None; - _actionResult = string.Empty; - _keyPressSignal.TrySetResult(); - return; - } - - // While executing, ignore all input except quit. - if (actionState == WorkbenchActionState.Executing) - { - if (key == ConsoleKey.Q) cts.Cancel(); - return; - } - - // Confirmation prompt — only Y/N/Escape are valid. - if (actionState == WorkbenchActionState.AwaitingConfirmation) - { - switch (key) - { - case ConsoleKey.Y: - _ = Task.Run(() => ExecuteActionAsync(cts.Token), cts.Token); - break; - case ConsoleKey.N: - case ConsoleKey.Escape: - _pendingAction = null; - _actionState = (int)WorkbenchActionState.None; - _keyPressSignal.TrySetResult(); - break; - } - - return; - } - - var isDetail = (int)view >= 100; - - // Filter input mode — route all input to the filter until Enter or Escape exits it. - if (_filterInputMode != 0) - { - switch (key) - { - case ConsoleKey.Escape: - _filterInputMode = 0; - _filter = string.Empty; - _selectedIndex = 0; - break; - case ConsoleKey.Enter: - _filterInputMode = 0; - break; - case ConsoleKey.Backspace: - if (!string.IsNullOrEmpty(_filter)) - _filter = _filter[..^1]; - break; - case ConsoleKey.UpArrow: - _selectedIndex = Math.Max(0, _selectedIndex - 1); - break; - case ConsoleKey.DownArrow: - _selectedIndex++; - break; - default: - if (keyInfo.KeyChar >= ' ') - { - _filter += keyInfo.KeyChar; - _selectedIndex = 0; - } - break; - } - _keyPressSignal.TrySetResult(); - return; - } - - switch (key) - { - // Primary view number keys — always clear the nav stack and jump directly. - case ConsoleKey.D1: - _navStack.Clear(); - _currentView = (int)WorkbenchView.Overview; - _selectedIndex = 0; - _scrollOffset = 0; - _focusedId = string.Empty; - _filter = string.Empty; - _breadcrumb = []; - break; - case ConsoleKey.D2: - _navStack.Clear(); - _currentView = (int)WorkbenchView.Observers; - _selectedIndex = 0; - _scrollOffset = 0; - _focusedId = string.Empty; - _filter = string.Empty; - _breadcrumb = []; - break; - case ConsoleKey.D3: - _navStack.Clear(); - _currentView = (int)WorkbenchView.FailedPartitions; - _selectedIndex = 0; - _scrollOffset = 0; - _focusedId = string.Empty; - _filter = string.Empty; - _breadcrumb = []; - break; - case ConsoleKey.D4: - _navStack.Clear(); - _currentView = (int)WorkbenchView.Jobs; - _selectedIndex = 0; - _scrollOffset = 0; - _focusedId = string.Empty; - _filter = string.Empty; - _breadcrumb = []; - break; - case ConsoleKey.D5: - _navStack.Clear(); - _currentView = (int)WorkbenchView.Recommendations; - _selectedIndex = 0; - _scrollOffset = 0; - _focusedId = string.Empty; - _filter = string.Empty; - _breadcrumb = []; - break; - case ConsoleKey.D6: - _navStack.Clear(); - _currentView = (int)WorkbenchView.EventLog; - _selectedIndex = 0; - _scrollOffset = 0; - _focusedId = string.Empty; - _filter = string.Empty; - _eventLogPage = 0; - _breadcrumb = []; - break; - case ConsoleKey.D7: - _navStack.Clear(); - _currentView = (int)WorkbenchView.EventTypes; - _selectedIndex = 0; - _scrollOffset = 0; - _focusedId = string.Empty; - _filter = string.Empty; - _breadcrumb = []; - break; - case ConsoleKey.D8: - _navStack.Clear(); - _currentView = (int)WorkbenchView.Projections; - _selectedIndex = 0; - _scrollOffset = 0; - _focusedId = string.Empty; - _filter = string.Empty; - _filterInputMode = 0; - _breadcrumb = []; - break; - case ConsoleKey.D9: - _navStack.Clear(); - _currentView = (int)WorkbenchView.ReadModels; - _selectedIndex = 0; - _scrollOffset = 0; - _focusedId = string.Empty; - _filter = string.Empty; - _filterInputMode = 0; - _breadcrumb = []; - break; - case ConsoleKey.D0: - _navStack.Clear(); - _currentView = (int)WorkbenchView.EventStores; - _selectedIndex = 0; - _scrollOffset = 0; - _focusedId = string.Empty; - _filter = string.Empty; - _filterInputMode = 0; - _breadcrumb = []; - break; - - // Left/right — cycle through primary views only (detail views excluded). - case ConsoleKey.LeftArrow when !isDetail: - _currentView = (_currentView - 1 + ViewCount) % ViewCount; - _selectedIndex = 0; - _scrollOffset = 0; - _filter = string.Empty; - _filterInputMode = 0; - _eventLogPage = 0; - break; - case ConsoleKey.RightArrow when !isDetail: - _currentView = (_currentView + 1) % ViewCount; - _selectedIndex = 0; - _scrollOffset = 0; - _filter = string.Empty; - _filterInputMode = 0; - _eventLogPage = 0; - break; - - // Up/down — navigate list in primary views and sub-navigation detail views; scroll in other detail views. - case ConsoleKey.UpArrow: - case ConsoleKey.K: - if (isDetail && !HasSubNavigation(view)) - _scrollOffset = Math.Max(0, _scrollOffset - 1); - else - _selectedIndex = Math.Max(0, _selectedIndex - 1); - break; - case ConsoleKey.DownArrow: - if (isDetail && !HasSubNavigation(view)) - _scrollOffset = Math.Min(2000, _scrollOffset + 1); - else - _selectedIndex = Math.Min(GetMaxSelectedIndex(), _selectedIndex + 1); - break; - - // Enter — drill into detail view for selected item. - case ConsoleKey.Enter: - HandleEnterKey(settings); - return; // HandleEnterKey fires signal - - // Escape — clear filter if active; otherwise pop nav stack or reset selection. - case ConsoleKey.Escape: - if (!string.IsNullOrEmpty(_filter)) - { - _filter = string.Empty; - _selectedIndex = 0; - } - else if (_navStack.Count > 0) - { - NavigateBack(); - } - else - { - _selectedIndex = 0; - _scrollOffset = 0; - } - - break; - - // Backspace — remove last filter character when a filter is active. - case ConsoleKey.Backspace: - if (!string.IsNullOrEmpty(_filter)) - { - _filter = _filter[..^1]; - _selectedIndex = 0; - } - - break; - - // Interval adjustment. - case ConsoleKey.OemPlus: - case ConsoleKey.Add: - settings.Interval = Math.Min(60, settings.Interval + 1); - break; - case ConsoleKey.OemMinus: - case ConsoleKey.Subtract: - settings.Interval = Math.Max(1, settings.Interval - 1); - break; - - case ConsoleKey.Q: - cts.Cancel(); - return; - - // --- In-view action keys --- - case ConsoleKey.R when view == WorkbenchView.Observers: - case ConsoleKey.R when view == WorkbenchView.ObserverDetail: - { - var obs = view == WorkbenchView.ObserverDetail - ? FindObserverById(_focusedId) - : GetSelectedObserver(); - if (obs is not null) - { - SetPendingAction(new PendingAction( - $"Replay observer '{TruncateForPrompt(obs.Id)}'", - $"Replay started for observer '{TruncateForPrompt(obs.Id)}'", - ct => _services!.Observers.Replay(new Replay - { - EventStore = settings.ResolveEventStore(), - Namespace = settings.ResolveNamespace(), - ObserverId = obs.Id, - EventSequenceId = CliDefaults.DefaultEventSequenceId - }))); - } - - return; - } - - case ConsoleKey.T when view == WorkbenchView.FailedPartitions: - case ConsoleKey.T when view == WorkbenchView.FailedPartitionDetail: - { - var fp = view == WorkbenchView.FailedPartitionDetail - ? FindFailedPartitionByFocusedId(_focusedId) - : GetSelectedFailedPartition(); - if (fp is not null) - { - SetPendingAction(new PendingAction( - $"Retry partition '{fp.Partition}' of '{TruncateForPrompt(fp.ObserverId)}'", - $"Retry started for partition '{fp.Partition}'", - ct => _services!.Observers.RetryPartition(new RetryPartition - { - EventStore = settings.ResolveEventStore(), - Namespace = settings.ResolveNamespace(), - ObserverId = fp.ObserverId, - Partition = fp.Partition, - EventSequenceId = CliDefaults.DefaultEventSequenceId - }))); - } - - return; - } - - case ConsoleKey.P when view == WorkbenchView.FailedPartitions: - case ConsoleKey.P when view == WorkbenchView.FailedPartitionDetail: - { - var fp = view == WorkbenchView.FailedPartitionDetail - ? FindFailedPartitionByFocusedId(_focusedId) - : GetSelectedFailedPartition(); - if (fp is not null) - { - SetPendingAction(new PendingAction( - $"Replay partition '{fp.Partition}' of '{TruncateForPrompt(fp.ObserverId)}'", - $"Replay started for partition '{fp.Partition}'", - ct => _services!.Observers.ReplayPartition(new ReplayPartition - { - EventStore = settings.ResolveEventStore(), - Namespace = settings.ResolveNamespace(), - ObserverId = fp.ObserverId, - Partition = fp.Partition, - EventSequenceId = CliDefaults.DefaultEventSequenceId - }))); - } - - return; - } - - // In ObserverDetail, P navigates to the projection definition for projection-type observers. - case ConsoleKey.P when view == WorkbenchView.ObserverDetail: - { - var obs = FindObserverById(_focusedId); - if (obs is not null && _lastData is not null - && obs.Type.ToString().Contains("Projection", StringComparison.OrdinalIgnoreCase)) - { - var projDef = _lastData.ProjectionDefinitions - .FirstOrDefault(d => string.Equals(d.Identifier, obs.Id, StringComparison.OrdinalIgnoreCase)); - if (projDef is not null) - NavigateToDetail(WorkbenchView.ProjectionDetail, projDef.Identifier); - } - - break; - } - - case ConsoleKey.S when view == WorkbenchView.Jobs: - { - var job = GetSelectedJob(); - if (job is not null) - { - SetPendingAction(new PendingAction( - $"Stop job '{TruncateForPrompt(job.Type ?? job.Id.ToString())}'", - "Job stopped", - ct => _services!.Jobs.Stop(new StopJob - { - EventStore = settings.ResolveEventStore(), - Namespace = settings.ResolveNamespace(), - JobId = job.Id - }))); - } - - return; - } - - case ConsoleKey.U when view == WorkbenchView.Jobs: - { - var job = GetSelectedJob(); - if (job is not null) - { - SetPendingAction(new PendingAction( - $"Resume job '{TruncateForPrompt(job.Type ?? job.Id.ToString())}'", - "Job resumed", - ct => _services!.Jobs.Resume(new ResumeJob - { - EventStore = settings.ResolveEventStore(), - Namespace = settings.ResolveNamespace(), - JobId = job.Id - }))); - } - - return; - } - - case ConsoleKey.A when view == WorkbenchView.Recommendations: - { - var rec = GetSelectedRecommendation(); - if (rec is not null) - { - SetPendingAction(new PendingAction( - $"Apply recommendation '{TruncateForPrompt(rec.Name ?? rec.Id.ToString())}'", - "Recommendation applied", - ct => _services!.Recommendations.Perform(new Perform - { - EventStore = settings.ResolveEventStore(), - Namespace = settings.ResolveNamespace(), - RecommendationId = rec.Id - }))); - } - - return; - } - - case ConsoleKey.I when view == WorkbenchView.Recommendations: - { - var rec = GetSelectedRecommendation(); - if (rec is not null) - { - SetPendingAction(new PendingAction( - $"Ignore recommendation '{TruncateForPrompt(rec.Name ?? rec.Id.ToString())}'", - "Recommendation ignored", - ct => _services!.Recommendations.Ignore(new Perform - { - EventStore = settings.ResolveEventStore(), - Namespace = settings.ResolveNamespace(), - RecommendationId = rec.Id - }))); - } - - return; - } - - // In EventDetail, T navigates to the event type detail view. - case ConsoleKey.T when view == WorkbenchView.EventDetail: - var evtForType = _lastData?.RecentEvents - .FirstOrDefault(e => e.Context.SequenceNumber.ToString() == _focusedId); - var etToNav = evtForType?.Context.EventType; - if (etToNav is not null) - NavigateToDetail(WorkbenchView.EventTypeDetail, $"{etToNav.Id}+{etToNav.Generation}"); - break; - - // Event Log sort order toggle — also resets to page 0. - case ConsoleKey.S when view == WorkbenchView.EventLog: - _eventLogAscending = 1 - _eventLogAscending; - _eventLogPage = 0; - _selectedIndex = 0; - break; - - // Event Log paging — PageDown goes to older events, PageUp goes to newer. - case ConsoleKey.PageDown when view == WorkbenchView.EventLog: - var totalEvents = _lastData?.RecentEvents.Count ?? 0; - var maxPage = Math.Max(0, (totalEvents - 1) / EventLogPageSize); - _eventLogPage = Math.Min(maxPage, _eventLogPage + 1); - _selectedIndex = 0; - break; - - case ConsoleKey.PageUp when view == WorkbenchView.EventLog: - _eventLogPage = Math.Max(0, _eventLogPage - 1); - _selectedIndex = 0; - break; - - // Quick navigation to context-switching views from any primary view. - case ConsoleKey.E when !isDetail: - _navStack.Clear(); - _currentView = (int)WorkbenchView.EventStores; - _selectedIndex = 0; - _scrollOffset = 0; - _filter = string.Empty; - _filterInputMode = 0; - _focusedId = string.Empty; - _breadcrumb = []; - break; - case ConsoleKey.N when !isDetail: - _navStack.Clear(); - _currentView = (int)WorkbenchView.Namespaces; - _selectedIndex = 0; - _scrollOffset = 0; - _filter = string.Empty; - _filterInputMode = 0; - _focusedId = string.Empty; - _breadcrumb = []; - break; - - // '/' activates inline filter mode in filterable views. - default: - if (!isDetail && IsFilterableView(view) && keyInfo.KeyChar == '/') - { - _filterInputMode = 1; - _filter = string.Empty; - _selectedIndex = 0; - } - - break; - } - - _keyPressSignal.TrySetResult(); - } - - void HandleEnterKey(WorkbenchSettings settings) - { - _filter = string.Empty; - - switch ((WorkbenchView)_currentView) - { - case WorkbenchView.Observers: - if (GetSelectedObserver() is { } obs) - NavigateToDetail(WorkbenchView.ObserverDetail, obs.Id); - break; - - case WorkbenchView.ObserverDetail: - var focusedObs = FindObserverById(_focusedId); - var eventTypes = (focusedObs?.EventTypes ?? []) - .OrderBy(et => et.Id, StringComparer.OrdinalIgnoreCase) - .ToList(); - if (_selectedIndex < eventTypes.Count) - { - var et = eventTypes[_selectedIndex]; - NavigateToDetail(WorkbenchView.EventTypeDetail, $"{et.Id}+{et.Generation}"); - } - - break; - - case WorkbenchView.FailedPartitions: - if (GetSelectedFailedPartition() is { } fp) - NavigateToDetail(WorkbenchView.FailedPartitionDetail, $"{fp.ObserverId}/{fp.Partition}"); - break; - - case WorkbenchView.EventLog: - if (GetSelectedEvent() is { } evt) - NavigateToDetail(WorkbenchView.EventDetail, evt.Context.SequenceNumber.ToString()); - break; - - case WorkbenchView.EventTypes: - if (GetSelectedEventTypeRegistration() is { } et2) - NavigateToDetail(WorkbenchView.EventTypeDetail, $"{et2.Type.Id}+{et2.Type.Generation}"); - break; - - case WorkbenchView.Projections: - if (GetSelectedProjectionDefinition() is { } proj) - NavigateToDetail(WorkbenchView.ProjectionDetail, proj.Identifier); - break; - - case WorkbenchView.ReadModels: - if (GetSelectedReadModel() is { } rm) - NavigateToDetail(WorkbenchView.ReadModelDetail, rm.ContainerName); - break; - - case WorkbenchView.EventStores: - var storeName = GetSelectedEventStoreName(); - if (storeName is not null) - { - _activeEventStore = storeName; - _activeNamespace = null; - _navStack.Clear(); - _currentView = (int)WorkbenchView.Overview; - _selectedIndex = 0; - _filter = string.Empty; - _filterInputMode = 0; - _focusedId = string.Empty; - _breadcrumb = []; - } - break; - - case WorkbenchView.Namespaces: - var nsName = GetSelectedNamespaceName(); - if (nsName is not null) - { - _activeNamespace = nsName; - _navStack.Clear(); - _currentView = (int)WorkbenchView.Overview; - _selectedIndex = 0; - _filter = string.Empty; - _filterInputMode = 0; - _focusedId = string.Empty; - _breadcrumb = []; - } - break; - } - - _keyPressSignal.TrySetResult(); - } } diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchDataService.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchDataService.cs new file mode 100644 index 0000000..90f2d36 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchDataService.cs @@ -0,0 +1,332 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text.Json; +using Cratis.Chronicle.Contracts.Jobs; +using Cratis.Cli.Commands.Chronicle.ReadModels; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Fetches all Chronicle server data needed by the workbench in a single async snapshot. +/// All independent gRPC calls are executed in parallel to minimise fetch latency. +/// +/// The Chronicle gRPC service clients. +/// The resolved workbench settings (connection string, event store, namespace). +public class WorkbenchDataService(IServices services, WorkbenchSettings settings) +{ + /// Total events fetched from the server per refresh — determines how many pages are available in the Event Log view. + public const int EventLogFetchWindow = 500; + + static readonly JsonSerializerOptions _instanceJsonOptions = new() { WriteIndented = true }; + + /// + /// Fetches a point-in-time snapshot of all data from the Chronicle server. + /// All independent gRPC calls run in parallel; only the recent-events fetch waits on the tail-sequence result. + /// + /// Override for the active event store (null uses settings default). + /// Override for the active namespace (null uses settings default). + /// Container name when fetching read model instances (non-null triggers an instances fetch). + /// Cancellation token. + /// A snapshot. + public async Task FetchAsync( + string? activeEventStore, + string? activeNamespace, + string? readModelContainerName, + CancellationToken ct) + { + var eventStore = activeEventStore ?? settings.ResolveEventStore(); + var ns = activeNamespace ?? settings.ResolveNamespace(); + var connectionString = settings.ResolveConnectionString(); + + // Run all independent calls in parallel. + var versionTask = FetchVersionAsync(); + var eventStoresTask = FetchEventStoresAsync(); + var observersTask = FetchObserversAsync(eventStore, ns); + var failedPartitionsTask = FetchFailedPartitionsAsync(eventStore, ns); + var jobsTask = FetchJobsAsync(eventStore, ns); + var recommendationsTask = FetchRecommendationsAsync(eventStore, ns); + var tailTask = FetchTailSequenceNumberAsync(eventStore, ns); + var eventTypesTask = FetchEventTypesAsync(eventStore); + var projectionsTask = FetchProjectionDefinitionsAsync(eventStore); + var declarationsTask = FetchProjectionDeclarationsAsync(eventStore); + var readModelsTask = FetchReadModelDefinitionsAsync(eventStore); + var namespacesTask = FetchNamespacesAsync(eventStore); + + await Task.WhenAll(versionTask, eventStoresTask, observersTask, failedPartitionsTask, jobsTask, recommendationsTask, tailTask, eventTypesTask, projectionsTask, declarationsTask, readModelsTask, namespacesTask).ConfigureAwait(false); + + // All tasks are complete — awaiting them returns immediately. + var (serverVersion, isConnected) = await versionTask.ConfigureAwait(false); + var tailSequenceNumber = await tailTask.ConfigureAwait(false); + var eventStoreNames = await eventStoresTask.ConfigureAwait(false); + var observers = await observersTask.ConfigureAwait(false); + var failedPartitions = await failedPartitionsTask.ConfigureAwait(false); + var jobs = await jobsTask.ConfigureAwait(false); + var recommendations = await recommendationsTask.ConfigureAwait(false); + var eventTypeRegistrations = await eventTypesTask.ConfigureAwait(false); + var projectionDefinitions = await projectionsTask.ConfigureAwait(false); + var projectionDeclarations = await declarationsTask.ConfigureAwait(false); + var readModelDefinitions = await readModelsTask.ConfigureAwait(false); + var namespaceNames = await namespacesTask.ConfigureAwait(false); + + // Recent events depends on the tail — fetch after the parallel batch. + var recentEvents = await FetchRecentEventsAsync(eventStore, ns, tailSequenceNumber).ConfigureAwait(false); + + // Read model instances are optional and fetched only when a container is specified. + var (readModelInstances, readModelInstancesTotalCount, readModelInstancesError) = + await FetchReadModelInstancesAsync(eventStore, ns, readModelContainerName).ConfigureAwait(false); + + return new WorkbenchData( + ConnectionString: connectionString, + EventStore: eventStore, + Namespace: ns, + IsConnected: isConnected, + ServerVersion: serverVersion, + EventStoreNames: eventStoreNames, + Observers: observers, + FailedPartitions: failedPartitions, + Jobs: jobs, + Recommendations: recommendations, + TailSequenceNumber: tailSequenceNumber, + CapturedAt: DateTimeOffset.Now, + FetchError: null, + EventTypeRegistrations: eventTypeRegistrations, + ProjectionDefinitions: projectionDefinitions, + ProjectionDeclarations: projectionDeclarations, + RecentEvents: recentEvents, + ReadModelDefinitions: readModelDefinitions, + NamespaceNames: namespaceNames, + ReadModelInstances: readModelInstances, + ReadModelInstancesTotalCount: readModelInstancesTotalCount, + ReadModelInstancesError: readModelInstancesError); + } + + /// + /// Fetches only the new events appended since for the live event stream. + /// + /// The last known sequence number; only events with higher numbers are returned. + /// Override for the active event store (null uses settings default). + /// Override for the active namespace (null uses settings default). + /// Cancellation token. + /// New events ordered oldest-first, or an empty list if none. + public async Task> FetchNewEventsAsync( + ulong afterSequenceNumber, + string? activeEventStore, + string? activeNamespace, + CancellationToken ct) + { + var eventStore = activeEventStore ?? settings.ResolveEventStore(); + var ns = activeNamespace ?? settings.ResolveNamespace(); + + try + { + var eventsResp = await services.EventSequences.GetEventsFromEventSequenceNumber( + new GetFromEventSequenceNumberRequest + { + EventStore = eventStore, + Namespace = ns, + EventSequenceId = CliDefaults.DefaultEventSequenceId, + FromEventSequenceNumber = afterSequenceNumber + 1 + }).ConfigureAwait(false); + return [.. eventsResp.Events.OrderBy(e => e.Context.SequenceNumber)]; + } + catch + { + return []; + } + } + + async Task<(string? Version, bool IsConnected)> FetchVersionAsync() + { + try + { + var info = await services.Server.GetVersionInfo().ConfigureAwait(false); + return (info.Version, true); + } + catch + { + return (null, false); + } + } + + async Task> FetchEventStoresAsync() + { + try { return [.. await services.EventStores.GetEventStores().ConfigureAwait(false)]; } + catch { return []; } + } + + async Task> FetchObserversAsync(string eventStore, string ns) + { + try + { + return [.. await services.Observers.GetObservers(new AllObserversRequest + { + EventStore = eventStore, + Namespace = ns + }).ConfigureAwait(false)]; + } + catch { return []; } + } + + async Task> FetchFailedPartitionsAsync(string eventStore, string ns) + { + try + { + return [.. await services.FailedPartitions.GetFailedPartitions(new GetFailedPartitionsRequest + { + EventStore = eventStore, + Namespace = ns + }).ConfigureAwait(false)]; + } + catch { return []; } + } + + async Task> FetchJobsAsync(string eventStore, string ns) + { + try + { + return [.. (await services.Jobs.GetJobs(new GetJobsRequest + { + EventStore = eventStore, + Namespace = ns + }).ConfigureAwait(false)) ?? []]; + } + catch { return []; } + } + + async Task> FetchRecommendationsAsync(string eventStore, string ns) + { + try + { + return [.. await services.Recommendations.GetRecommendations(new GetRecommendationsRequest + { + EventStore = eventStore, + Namespace = ns + }).ConfigureAwait(false)]; + } + catch { return []; } + } + + async Task FetchTailSequenceNumberAsync(string eventStore, string ns) + { + try + { + var tail = await services.EventSequences.GetTailSequenceNumber(new GetTailSequenceNumberRequest + { + EventStore = eventStore, + Namespace = ns, + EventSequenceId = CliDefaults.DefaultEventSequenceId + }).ConfigureAwait(false); + return tail.SequenceNumber == ulong.MaxValue ? null : tail.SequenceNumber; + } + catch { return null; } + } + + async Task> FetchEventTypesAsync(string eventStore) + { + try + { + return [.. await services.EventTypes.GetAllRegistrations( + new GetAllEventTypesRequest { EventStore = eventStore }).ConfigureAwait(false)]; + } + catch { return []; } + } + + async Task> FetchProjectionDefinitionsAsync(string eventStore) + { + try + { + return [.. await services.Projections.GetAllDefinitions( + new GetAllDefinitionsRequest { EventStore = eventStore }).ConfigureAwait(false)]; + } + catch { return []; } + } + + async Task> FetchProjectionDeclarationsAsync(string eventStore) + { + try + { + var declarations = await services.Projections.GetAllDeclarations( + new GetAllDeclarationsRequest { EventStore = eventStore }).ConfigureAwait(false); + return declarations.ToDictionary(d => d.Identifier, d => d.Declaration ?? string.Empty); + } + catch { return new Dictionary(); } + } + + async Task> FetchRecentEventsAsync(string eventStore, string ns, ulong? tailSequenceNumber) + { + try + { + if (tailSequenceNumber is null or 0) return []; + var fromSeq = tailSequenceNumber.Value >= EventLogFetchWindow + ? tailSequenceNumber.Value - EventLogFetchWindow + 1 + : 0; + var eventsResp = await services.EventSequences.GetEventsFromEventSequenceNumber( + new GetFromEventSequenceNumberRequest + { + EventStore = eventStore, + Namespace = ns, + EventSequenceId = CliDefaults.DefaultEventSequenceId, + FromEventSequenceNumber = fromSeq + }).ConfigureAwait(false); + return [.. eventsResp.Events.OrderByDescending(e => e.Context.SequenceNumber)]; + } + catch { return []; } + } + + async Task> FetchReadModelDefinitionsAsync(string eventStore) + { + try + { + var defs = await services.ReadModels.GetDefinitions( + new GetDefinitionsRequest { EventStore = eventStore }).ConfigureAwait(false); + return [.. defs.ReadModels.Select(rm => new WorkbenchReadModel( + rm.ContainerName, + rm.DisplayName, + rm.Owner.ToString(), + !string.Equals(rm.Owner.ToString(), "Client", StringComparison.Ordinal), + rm.Source.ToString(), + rm.Type?.Identifier ?? string.Empty))]; + } + catch { return []; } + } + + async Task> FetchNamespacesAsync(string eventStore) + { + try + { + return [.. await services.Namespaces.GetNamespaces( + new GetNamespacesRequest { EventStore = eventStore }).ConfigureAwait(false)]; + } + catch { return []; } + } + + async Task<(IReadOnlyList Instances, int TotalCount, string? Error)> FetchReadModelInstancesAsync( + string eventStore, + string ns, + string? containerName) + { + if (string.IsNullOrEmpty(containerName)) return ([], 0, null); + try + { + var instResp = await services.ReadModels.GetInstances(new GetInstancesRequest + { + EventStore = eventStore, + Namespace = ns, + ReadModel = containerName, + Page = 0, + PageSize = 20 + }).ConfigureAwait(false); + var totalCount = (int)Math.Min(instResp.TotalCount, int.MaxValue); + var instances = (IReadOnlyList)[.. (instResp.Instances ?? []) + .Select(ReadModelJsonCleaner.CleanInstance) + .Where(o => o is not null) + .Select(o => JsonSerializer.Serialize(o, _instanceJsonOptions))]; + return (instances, totalCount, null); + } + catch (Exception ex) + { + return ([], 0, ex.Message); + } + } +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchRenderer.DetailViews.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchRenderer.DetailViews.cs deleted file mode 100644 index c13316f..0000000 --- a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchRenderer.DetailViews.cs +++ /dev/null @@ -1,265 +0,0 @@ -// Copyright (c) Cratis. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - -using System.Text.Json; -using Spectre.Console.Rendering; - -namespace Cratis.Cli.Commands.Chronicle.Workbench; - -public static partial class WorkbenchRenderer -{ - static readonly JsonSerializerOptions _prettyJson = new() { WriteIndented = true }; - - static Rows BuildObserverDetailPage(WorkbenchData data, string focusedId, int selectedIndex) - { - var obs = data.Observers.FirstOrDefault(o => o.Id == focusedId); - if (obs is null) - { - return new Rows(new Panel(new Markup($"[{_dan}]Observer '{focusedId.EscapeMarkup()}' not found.[/]")) - .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Danger).Expand()); - } - - var lag = ComputeLag(obs, data.TailSequenceNumber); - var lagText = lag switch { null => $"[{_mut}]—[/]", 0 => $"[{_suc}]caught up[/]", _ => $"[{_war}]{lag.Value:N0} events behind tail ({data.TailSequenceNumber?.ToString("N0") ?? "—"})[/]" }; - - var infoPanel = new Panel(new Rows( - new Markup($" [{_mut}]type[/] [bold]{obs.Type.ToString().EscapeMarkup()}[/] [{_mut}]owner[/] {obs.Owner.ToString().EscapeMarkup()} [{_mut}]sequence[/] [{_mut}]{obs.EventSequenceId.EscapeMarkup()}[/] [{_mut}]replayable[/] {(obs.IsReplayable ? $"[{_suc}]yes[/]" : $"[{_mut}]no[/]")}"), - new Markup($" [{_mut}]state[/] {StateIcon(obs.RunningState)} {StateName(obs.RunningState)} [{_mut}]subscribed[/] {(obs.IsSubscribed ? $"[{_suc}]yes[/]" : $"[{_dan}]no[/]")}"), - new Markup($" [{_mut}]next seq[/] {FormatSeq(obs.NextEventSequenceNumber)} [{_mut}]last handled[/] {FormatSeq(obs.LastHandledEventSequenceNumber)} [{_mut}]lag[/] {lagText}"))) - .Header($"[{_acc}] {obs.Id.EscapeMarkup()} [/]") - .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Accent).Expand(); - - var eventTypes = (obs.EventTypes ?? []).OrderBy(et => et.Id, StringComparer.OrdinalIgnoreCase).ToList(); - IRenderable eventTypesSection; - if (eventTypes.Count == 0) - { - eventTypesSection = new Panel(new Markup($" [{_mut}]No subscribed event types.[/]")) - .Header($"[{_acc}] Subscribed Event Types [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted).Expand(); - } - else - { - var etEffective = Math.Min(selectedIndex, eventTypes.Count - 1); - var etTable = new Table().Border(TableBorder.Rounded).BorderColor(OutputFormatter.Muted).Expand() - .AddColumn(new TableColumn(string.Empty).Width(2)) - .AddColumn(new TableColumn("[bold]EventType[/]").Padding(1, 0)) - .AddColumn(new TableColumn("[bold]Generation[/]").Padding(1, 0).Width(12).NoWrap()); - - var (winStart, winEnd) = ListWindow(eventTypes.Count, etEffective); - if (winStart > 0) - etTable.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↑ {winStart} more above[/]"), new Markup(string.Empty)); - for (var i = winStart; i <= winEnd; i++) - { - var et = eventTypes[i]; - var isSel = i == etEffective; - etTable.AddRow( - new Markup(isSel ? $"[bold {_acc}]▶[/]" : string.Empty), - new Markup(isSel ? $"[bold {_acc}]{et.Id.EscapeMarkup()}[/]" : et.Id.EscapeMarkup()), - new Markup($"[{_mut}]{et.Generation}[/]")); - } - - if (winEnd < eventTypes.Count - 1) - etTable.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↓ {eventTypes.Count - 1 - winEnd} more below[/]"), new Markup(string.Empty)); - - eventTypesSection = new Panel(etTable) - .Header($"[{_acc}] Subscribed Event Types ({eventTypes.Count}) ↑↓ select [[ Enter ]] view schema [/]") - .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Accent).Expand(); - } - - if (obs.Type.ToString().Contains("Projection", StringComparison.OrdinalIgnoreCase)) - { - var projHint = new Panel(new Markup($" [{_acc}][[ P ]][/] [{_mut}]View this observer's projection definition[/]")) - .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted).Expand(); - return new Rows(infoPanel, eventTypesSection, projHint); - } - - return new Rows(infoPanel, eventTypesSection); - } - - static Rows BuildFailedPartitionDetailPage(WorkbenchData data, string focusedId, int scrollOffset) - { - var sep = focusedId.IndexOf('/'); - FailedPartition? fp = null; - if (sep >= 0) - { - var obsId = focusedId[..sep]; - var partition = focusedId[(sep + 1)..]; - fp = data.FailedPartitions.FirstOrDefault(f => f.ObserverId == obsId && f.Partition == partition); - } - - if (fp is null) - { - return new Rows(new Panel(new Markup($"[{_dan}]Failed partition not found.[/]")) - .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Danger).Expand()); - } - - var attempts = fp.Attempts.OrderByDescending(a => (DateTimeOffset?)a.Occurred ?? DateTimeOffset.MinValue).ToList(); - var lastAttempt = attempts.FirstOrDefault(); - var msg = (lastAttempt?.Messages.FirstOrDefault() ?? "—").EscapeMarkup(); - var stackTrace = lastAttempt?.StackTrace ?? string.Empty; - - var header = new Panel(new Markup( - $" [{_mut}]observer[/] [{_dan}]{fp.ObserverId.EscapeMarkup()}[/] [{_mut}]partition[/] {fp.Partition.EscapeMarkup()} [{_mut}]attempts[/] [{_dan}]{attempts.Count}[/]\n" + - $" [{_mut}]last failed[/] [{_dan}]{(lastAttempt is not null ? lastAttempt.Occurred.ToString() : "—").EscapeMarkup()}[/]")) - .Header($"[{_dan}] Failure Detail [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Danger).Expand(); - - var msgPanel = new Panel(new Markup($" [{_dan}]{msg}[/]")) - .Header($"[{_dan}] Last Error [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Danger).Expand(); - - var stackLines = (string.IsNullOrEmpty(stackTrace) ? ["(no stack trace)"] : stackTrace.Split('\n').Select(l => $" [{_mut}]{l.EscapeMarkup()}[/]").ToList()) - as IReadOnlyList; - var stackPanel = ScrollableText("Stack Trace", stackLines, scrollOffset, OutputFormatter.Muted); - - return new Rows(header, msgPanel, stackPanel); - } - - static Rows BuildEventDetailPage(WorkbenchData data, string focusedId, int scrollOffset) - { - AppendedEvent? evt = null; - if (ulong.TryParse(focusedId, out var seq)) - evt = data.RecentEvents.FirstOrDefault(e => e.Context.SequenceNumber == seq); - - if (evt is null) - { - return new Rows(new Panel(new Markup($"[{_dan}]Event not found (seq# {focusedId.EscapeMarkup()}).[/]")) - .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Danger).Expand()); - } - - var ctx = evt.Context; - var header = new Panel(new Rows( - new Markup($" [{_mut}]seq#[/] [bold]{ctx.SequenceNumber:N0}[/] [{_mut}]eventType[/] [bold {_acc}]{(ctx.EventType?.Id ?? "—").EscapeMarkup()}[/] [{_mut}]+{ctx.EventType?.Generation}[/]"), - new Markup($" [{_mut}]eventSourceId[/] {(ctx.EventSourceId ?? "—").EscapeMarkup()} [{_mut}]occurred[/] [{_mut}]{ctx.Occurred.ToString().EscapeMarkup()}[/]"), - new Markup($" [{_mut}]correlationId[/] [{_mut}]{ctx.CorrelationId.ToString().EscapeMarkup()}[/]"))) - .Header($"[{_acc}] Event seq# {ctx.SequenceNumber:N0} [/]") - .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Accent).Expand(); - - List contentLines; - if (string.IsNullOrWhiteSpace(evt.Content)) - { - contentLines = [$" [{_mut}](no content)[/]"]; - } - else - { - try - { - var pretty = JsonSerializer.Serialize( - JsonSerializer.Deserialize(evt.Content), - _prettyJson); - contentLines = [.. pretty.Split('\n').Select(l => $" [{_mut}]{l.EscapeMarkup()}[/]")]; - } - catch - { - contentLines = [$" [{_mut}]{evt.Content.EscapeMarkup()}[/]"]; - } - } - - var contentPanel = ScrollableText("Content", contentLines, scrollOffset, OutputFormatter.Muted); - return new Rows(header, contentPanel); - } - - static Rows BuildEventTypeDetailPage(WorkbenchData data, string focusedId, int scrollOffset) - { - var parts = focusedId.Split('+'); - var typeId = parts[0]; - var gen = parts.Length > 1 ? parts[1] : string.Empty; - var reg = data.EventTypeRegistrations - .FirstOrDefault(r => string.Equals(r.Type.Id, typeId, StringComparison.OrdinalIgnoreCase) - && r.Type.Generation.ToString() == gen); - - if (reg is null) - { - return new Rows(new Panel(new Markup($"[{_dan}]Event type '{focusedId.EscapeMarkup()}' not found.[/]")) - .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Danger).Expand()); - } - - var header = new Panel(new Markup( - $" [{_mut}]id[/] [bold]{reg.Type.Id.EscapeMarkup()}[/] [{_mut}]generation[/] {reg.Type.Generation} [{_mut}]owner[/] {reg.Owner.ToString().EscapeMarkup()} [{_mut}]source[/] {reg.Source.ToString().EscapeMarkup()} [{_mut}]tombstone[/] {reg.Type.Tombstone}")) - .Header($"[{_acc}] {reg.Type.Id.EscapeMarkup()} +{reg.Type.Generation} [/]") - .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Accent).Expand(); - - List schemaLines; - if (string.IsNullOrWhiteSpace(reg.Schema)) - { - schemaLines = [$" [{_mut}](no schema)[/]"]; - } - else - { - try - { - var pretty = JsonSerializer.Serialize( - JsonSerializer.Deserialize(reg.Schema), - _prettyJson); - schemaLines = [.. pretty.Split('\n').Select(l => $" [{_mut}]{l.EscapeMarkup()}[/]")]; - } - catch - { - schemaLines = [.. reg.Schema.Split('\n').Select(l => $" [{_mut}]{l.EscapeMarkup()}[/]")]; - } - } - - var schemaPanel = ScrollableText("JSON Schema", schemaLines, scrollOffset, OutputFormatter.Muted); - return new Rows(header, schemaPanel); - } - - static Rows BuildReadModelDetailPage(WorkbenchData data, string focusedId, int scrollOffset) - { - var def = data.ReadModelDefinitions - .FirstOrDefault(d => string.Equals(d.ContainerName, focusedId, StringComparison.OrdinalIgnoreCase)); - - var header = new Panel(new Markup( - $" [{_mut}]container[/] [bold]{focusedId.EscapeMarkup()}[/] [{_mut}]owner[/] {(def?.Owner ?? "—").EscapeMarkup()} [{_mut}]queryable[/] {(def?.IsQueryable == true ? $"[{_suc}]yes[/]" : $"[{_mut}]no[/]")} [{_mut}]total[/] [bold]{data.ReadModelInstancesTotalCount:N0}[/]")) - .Header($"[{_acc}] {focusedId.EscapeMarkup()} — Instances [/]") - .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Accent).Expand(); - - IReadOnlyList lines; - if (data.ReadModelInstancesError is not null) - { - lines = [$" [{_dan}]Error fetching instances: {data.ReadModelInstancesError.EscapeMarkup()}[/]"]; - } - else if (data.ReadModelInstances.Count == 0) - { - lines = [$" [{_mut}](no instances — model may be client-owned or empty)[/]"]; - } - else - { - var allLines = new List(); - for (var i = 0; i < data.ReadModelInstances.Count; i++) - { - if (i > 0) allLines.Add($" [{_mut}]─────────────────────────────────────────[/]"); - allLines.Add($" [{_acc}]Instance {i + 1}[/]"); - allLines.AddRange(data.ReadModelInstances[i].Split('\n').Select(l => $" [{_mut}]{l.EscapeMarkup()}[/]")); - } - if (data.ReadModelInstancesTotalCount > data.ReadModelInstances.Count) - allLines.Add($"\n [{_mut}]showing {data.ReadModelInstances.Count} of {data.ReadModelInstancesTotalCount:N0} total instances[/]"); - lines = allLines; - } - - var instancesPanel = ScrollableText("Instances (↑↓ scroll)", lines, scrollOffset, OutputFormatter.Muted); - return new Rows(header, instancesPanel); - } - - static Rows BuildProjectionDetailPage(WorkbenchData data, string focusedId, int scrollOffset) - { - var def = data.ProjectionDefinitions - .FirstOrDefault(d => string.Equals(d.Identifier, focusedId, StringComparison.OrdinalIgnoreCase)); - - if (def is null) - { - return new Rows(new Panel(new Markup($"[{_dan}]Projection '{focusedId.EscapeMarkup()}' not found.[/]")) - .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Danger).Expand()); - } - - data.ProjectionDeclarations.TryGetValue(def.Identifier, out var declaration); - - var header = new Panel(new Markup( - $" [{_mut}]readModel[/] {def.ReadModel.EscapeMarkup()} [{_mut}]active[/] {(def.IsActive ? $"[{_suc}]yes[/]" : $"[{_mut}]no[/]")} [{_mut}]rewindable[/] {(def.IsRewindable ? $"[{_suc}]yes[/]" : $"[{_mut}]no[/]")} [{_mut}]autoMap[/] {def.AutoMap.ToString().EscapeMarkup()}")) - .Header($"[{_acc}] {def.Identifier.EscapeMarkup()} [/]") - .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Accent).Expand(); - - IReadOnlyList declLines = string.IsNullOrWhiteSpace(declaration) - ? [$" [{_mut}](no declaration available)[/]"] - : [.. declaration.Split('\n').Select(l => $" [{_mut}]{l.EscapeMarkup()}[/]")]; - - var declPanel = ScrollableText("Declaration", declLines, scrollOffset, OutputFormatter.Muted); - return new Rows(header, declPanel); - } -} diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchRenderer.Views.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchRenderer.Views.cs deleted file mode 100644 index 8db9711..0000000 --- a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchRenderer.Views.cs +++ /dev/null @@ -1,655 +0,0 @@ -// Copyright (c) Cratis. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - -using Cratis.Chronicle.Contracts.Jobs; -using Spectre.Console.Rendering; - -namespace Cratis.Cli.Commands.Chronicle.Workbench; - -public static partial class WorkbenchRenderer -{ - const int EventLogPageSize = 50; - - static Rows BuildOverview(WorkbenchData data) - { - var topRow = new Table().HideHeaders().NoBorder().Expand() - .AddColumn(new TableColumn(string.Empty)) - .AddColumn(new TableColumn(string.Empty)); - topRow.AddRow(BuildServerPanel(data), BuildObserverStatsPanel(data)); - - var contextPanel = new Panel(new Rows( - new Markup($" [{_mut}]event store[/] [bold {_acc}]{data.EventStore.EscapeMarkup()}[/] [{_mut}][[ E ]][/] change"), - new Markup($" [{_mut}]namespace[/] [bold {_acc}]{data.Namespace.EscapeMarkup()}[/] [{_mut}][[ N ]][/] change"))) - .Header($"[{_acc}] Active Context [/]") - .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted).Expand(); - - var sections = new List { topRow, contextPanel }; - var attentionPanel = BuildAttentionPanel(data); - if (attentionPanel is not null) sections.Add(attentionPanel); - return new Rows(sections); - } - - static Panel BuildServerPanel(WorkbenchData data) - { - var connLine = data.IsConnected - ? $" [{_suc}]✓[/] connected [{_mut}]{(data.ServerVersion ?? "—").EscapeMarkup()}[/]" - : $" [{_dan}]✗[/] [{_dan}]disconnected[/]"; - var storesLine = $" [{_mut}]·[/] [{_mut}]{data.EventStoreNames.Count} event store{(data.EventStoreNames.Count == 1 ? string.Empty : "s")}[/]"; - var tailLine = data.TailSequenceNumber.HasValue - ? $" [{_mut}]·[/] [bold]{data.TailSequenceNumber.Value:N0}[/] [{_mut}]events in log[/]" - : $" [{_mut}]· event log unavailable[/]"; - - return new Panel(new Rows(new Markup(connLine), new Markup(storesLine), new Markup(tailLine))) - .Header($"[{_acc}] Server [/]") - .Border(BoxBorder.Rounded) - .BorderColor(OutputFormatter.Muted); - } - - static Panel BuildObserverStatsPanel(WorkbenchData data) - { - var total = data.Observers.Count; - var line1 = $" [{_suc}]●[/] [bold {_suc}]{data.ActiveObservers,3}[/] [{_suc}]Active[/] [{_war}]▲[/] [bold {_war}]{data.ReplayingObservers,3}[/] [{_war}]Replaying[/]"; - var line2 = $" [{_mut}]○[/] [bold {_mut}]{data.SuspendedObservers,3}[/] [{_mut}]Suspended[/] [{_mut}]⊘[/] [bold {_mut}]{data.DisconnectedObservers,3}[/] [{_mut}]Disconnected[/]"; - var failureLine = data.FailedPartitions.Count > 0 - ? $" [{_dan}]✗[/] [{_dan}]{data.FailedPartitions.Count} failed partition{(data.FailedPartitions.Count == 1 ? string.Empty : "s")}[/]" - : $" [{_suc}]✓[/] [{_suc}]no failed partitions[/]"; - - return new Panel(new Rows(new Markup(line1), new Markup(line2), new Markup(failureLine))) - .Header($"[{_acc}] Observers ({total}) [/]") - .Border(BoxBorder.Rounded) - .BorderColor(OutputFormatter.Muted); - } - - static Panel? BuildAttentionPanel(WorkbenchData data) - { - var items = new List(); - foreach (var fp in data.FailedPartitions.Take(5)) - { - var lastAttempt = fp.Attempts.MaxBy(a => (DateTimeOffset?)a.Occurred ?? DateTimeOffset.MinValue); - var msg = (lastAttempt?.Messages.FirstOrDefault() ?? "unknown error").EscapeMarkup(); - if (msg.Length > 80) msg = msg[..80] + "…"; - var attempts = fp.Attempts.Count(); - items.Add($" [{_dan}]✗[/] {fp.ObserverId.EscapeMarkup()} [{_mut}]on[/] {fp.Partition.EscapeMarkup()} [{_dan}]{msg}[/] [{_mut}]({attempts} attempt{(attempts == 1 ? string.Empty : "s")})[/]"); - } - - foreach (var rec in data.Recommendations.Take(3)) - { - var desc = (rec.Description ?? string.Empty).EscapeMarkup(); - if (desc.Length > 80) desc = desc[..80] + "…"; - items.Add($" [{_war}]▲[/] [{_war}]{(rec.Name ?? string.Empty).EscapeMarkup()}[/] [{_mut}]{desc}[/]"); - } - - if (items.Count == 0) return null; - - var borderColor = data.FailedPartitions.Count > 0 ? OutputFormatter.Danger : OutputFormatter.Warning; - var headerColor = data.FailedPartitions.Count > 0 ? _dan : _war; - return new Panel(new Rows(items.ConvertAll(i => (IRenderable)new Markup(i)))) - .Header($"[{headerColor}] Attention Needed ({items.Count}) [/]") - .Border(BoxBorder.Rounded) - .BorderColor(borderColor); - } - - static Rows BuildObserversView(WorkbenchData data, int selectedIndex, string filterText) - { - var allObservers = data.Observers.OrderBy(o => StateOrder(o.RunningState)).ThenBy(o => o.Id).ToList(); - var observers = string.IsNullOrEmpty(filterText) - ? allObservers - : [.. allObservers.Where(o => o.Id.Contains(filterText, StringComparison.OrdinalIgnoreCase))]; - var effectiveIndex = observers.Count > 0 ? Math.Min(selectedIndex, observers.Count - 1) : -1; - - var table = new Table() - .Border(TableBorder.Rounded).BorderColor(OutputFormatter.Muted).Expand() - .AddColumn(new TableColumn(string.Empty).Width(2)) - .AddColumn(new TableColumn("[bold]Observer[/]").Padding(1, 0)) - .AddColumn(new TableColumn("[bold]Type[/]").Padding(1, 0).Width(20).NoWrap()) - .AddColumn(new TableColumn("[bold]State[/]").Padding(1, 0).Width(18).NoWrap()); - - AddListRows(table, observers, effectiveIndex, data.TailSequenceNumber, 4); - - var header = string.IsNullOrEmpty(filterText) - ? $"[{_acc}] Observers ({data.Observers.Count}) [/]" - : $"[{_acc}] Observers ({observers.Count}/{data.Observers.Count}) [/]"; - var sections = new List - { - new Panel(table).Header(header).BorderColor(OutputFormatter.Accent).NoBorder() - }; - - if (effectiveIndex >= 0 && observers.Count > 0) - sections.Add(BuildObserverMiniDetail(observers[effectiveIndex], data.TailSequenceNumber)); - - return new Rows(sections); - } - - static void AddListRows(Table table, List observers, int effectiveIndex, ulong? tail, int colCount) - { - if (observers.Count == 0) - { - var empty = Enumerable.Range(0, colCount).Select(i => i == 1 ? (IRenderable)new Markup($"[{_mut}](none)[/]") : new Markup(string.Empty)).ToArray(); - table.AddRow(empty); - return; - } - - var (winStart, winEnd) = ListWindow(observers.Count, effectiveIndex < 0 ? 0 : effectiveIndex); - if (winStart > 0) - table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↑ {winStart} more above[/]"), new Markup(string.Empty), new Markup(string.Empty)); - - for (var i = winStart; i <= winEnd; i++) - { - var obs = observers[i]; - var isSelected = i == effectiveIndex; - var pointer = isSelected ? $"[bold {_acc}]▶[/]" : string.Empty; - table.AddRow( - new Markup(pointer), - new Markup(isSelected ? $"[bold {_acc}]{obs.Id.EscapeMarkup()}[/]" : obs.Id.EscapeMarkup()), - new Markup($"[{_mut}]{obs.Type.ToString().EscapeMarkup()}[/]"), - new Markup($"{StateIcon(obs.RunningState)} {StateName(obs.RunningState)}")); - } - - if (winEnd < observers.Count - 1) - table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↓ {observers.Count - 1 - winEnd} more below[/]"), new Markup(string.Empty), new Markup(string.Empty)); - } - - static Panel BuildObserverMiniDetail(ObserverInformation obs, ulong? tail) - { - var line1 = $" [{_mut}]type[/] [bold]{obs.Type.ToString().EscapeMarkup()}[/] [{_mut}]state[/] {StateIcon(obs.RunningState)} {StateName(obs.RunningState)}"; - var line2 = $" [{_mut}]next[/] {FormatSeq(obs.NextEventSequenceNumber)} [{_mut}]handled[/] {FormatSeq(obs.LastHandledEventSequenceNumber)}"; - var line3 = $"\n [{_acc}][[ Enter ]][/] Full detail [{_acc}][[ R ]][/] Replay"; - return new Panel(new Rows(new Markup(line1), new Markup(line2), new Markup(line3))) - .Header($"[{_acc}] {obs.Id.EscapeMarkup()} [/]") - .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Accent).Expand(); - } - - static Rows BuildFailedView(WorkbenchData data, int selectedIndex) - { - if (data.FailedPartitions.Count == 0) - { - return new Rows(new Panel(new Markup($"\n [{_suc}]✓ No failed partitions.[/]\n")) - .Header($"[{_acc}] Failures [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Success)); - } - - var fps = data.FailedPartitions.OrderByDescending(fp => fp.Attempts.Count()).ToList(); - var effectiveIndex = Math.Min(selectedIndex, fps.Count - 1); - var table = new Table().Border(TableBorder.Rounded).BorderColor(OutputFormatter.Danger).Expand() - .AddColumn(new TableColumn(string.Empty).Width(2)) - .AddColumn(new TableColumn("[bold]Observer[/]").Padding(1, 0)) - .AddColumn(new TableColumn("[bold]Partition[/]").Padding(1, 0)) - .AddColumn(new TableColumn("[bold]Attempts[/]").Padding(1, 0)) - .AddColumn(new TableColumn("[bold]Last Failed[/]").Padding(1, 0)); - - var (winStart, winEnd) = ListWindow(fps.Count, effectiveIndex); - if (winStart > 0) - table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↑ {winStart} more above[/]"), new Markup(string.Empty), new Markup(string.Empty), new Markup(string.Empty)); - for (var i = winStart; i <= winEnd; i++) - { - var fp = fps[i]; - var isSelected = i == effectiveIndex; - var lastAttempt = fp.Attempts.MaxBy(a => (DateTimeOffset?)a.Occurred ?? DateTimeOffset.MinValue); - var obsId = fp.ObserverId.EscapeMarkup(); - table.AddRow( - new Markup(isSelected ? $"[bold {_acc}]▶[/]" : string.Empty), - new Markup(isSelected ? $"[bold {_acc}]{obsId}[/]" : $"[{_dan}]{obsId}[/]"), - new Markup(fp.Partition.EscapeMarkup()), - new Markup(fp.Attempts.Count().ToString()), - new Markup($"[{_dan}]{(lastAttempt is not null ? lastAttempt.Occurred.ToString() : "—").EscapeMarkup()}[/]")); - } - - if (winEnd < fps.Count - 1) - table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↓ {fps.Count - 1 - winEnd} more below[/]"), new Markup(string.Empty), new Markup(string.Empty), new Markup(string.Empty)); - - var selected = fps[effectiveIndex]; - var selLastAttempt = selected.Attempts.MaxBy(a => (DateTimeOffset?)a.Occurred ?? DateTimeOffset.MinValue); - var msg = (selLastAttempt?.Messages.FirstOrDefault() ?? "—").EscapeMarkup(); - if (msg.Length > 80) msg = msg[..80] + "…"; - var miniDetail = new Panel(new Rows( - new Markup($" [{_mut}]partition[/] {selected.Partition.EscapeMarkup()} [{_mut}]attempts[/] [{_dan}]{selected.Attempts.Count()}[/] [{_mut}]last failed[/] [{_dan}]{(selLastAttempt is not null ? selLastAttempt.Occurred.ToString() : "—").EscapeMarkup()}[/]"), - new Markup($" [{_dan}]{msg}[/]"), - new Markup($"\n [{_acc}][[ Enter ]][/] Full detail [{_acc}][[ T ]][/] Retry [{_acc}][[ P ]][/] Replay partition"))) - .Header($"[{_dan}] Failure Detail [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Danger).Expand(); - - return new Rows( - new Panel(table).Header($"[{_dan}] Failures ({fps.Count}) [/]").BorderColor(OutputFormatter.Danger).NoBorder(), - miniDetail); - } - - static Rows BuildJobsView(WorkbenchData data, int selectedIndex) - { - if (data.Jobs.Count == 0) - { - return new Rows(new Panel(new Markup($"\n [{_mut}]No background jobs.[/]\n")) - .Header($"[{_acc}] Jobs [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted)); - } - - var jobs = data.Jobs.OrderBy(j => j.Status.ToString()).ToList(); - var effectiveIndex = Math.Min(selectedIndex, jobs.Count - 1); - var table = new Table().Border(TableBorder.Rounded).BorderColor(OutputFormatter.Muted).Expand() - .AddColumn(new TableColumn(string.Empty).Width(2)) - .AddColumn(new TableColumn("[bold]Type[/]").Padding(1, 0)) - .AddColumn(new TableColumn("[bold]Status[/]").Padding(1, 0)) - .AddColumn(new TableColumn("[bold]Progress[/]").Padding(1, 0)) - .AddColumn(new TableColumn("[bold]Created[/]").Padding(1, 0)); - - var (winStart, winEnd) = ListWindow(jobs.Count, effectiveIndex); - if (winStart > 0) - table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↑ {winStart} more above[/]"), new Markup(string.Empty), new Markup(string.Empty), new Markup(string.Empty)); - for (var i = winStart; i <= winEnd; i++) - { - var job = jobs[i]; - var isSelected = i == effectiveIndex; - var statusStr = job.Status.ToString(); - string statusColor; - if (statusStr.Contains("Running", StringComparison.OrdinalIgnoreCase)) statusColor = _suc; - else if (statusStr.Contains("Failed", StringComparison.OrdinalIgnoreCase)) statusColor = _dan; - else statusColor = _mut; - string progressCell; - if (job.Progress?.TotalSteps > 0) - { - var bar = ProgressBar(job.Progress.SuccessfulSteps, job.Progress.TotalSteps); - var pct = (int)Math.Min(100, (long)job.Progress.SuccessfulSteps * 100 / job.Progress.TotalSteps); - progressCell = $"{bar} {job.Progress.SuccessfulSteps:N0}/{job.Progress.TotalSteps:N0} [{_mut}]{pct}%[/]"; - } - else - { - progressCell = $"[{_mut}]{(job.Progress?.Message ?? "—").EscapeMarkup()}[/]"; - } - - table.AddRow( - new Markup(isSelected ? $"[bold {_acc}]▶[/]" : string.Empty), - new Markup(isSelected ? $"[bold {_acc}]{(job.Type ?? "—").EscapeMarkup()}[/]" : (job.Type ?? "—").EscapeMarkup()), - new Markup($"[{statusColor}]{statusStr.EscapeMarkup()}[/]"), - new Markup(progressCell), - new Markup(job.Created.ToString().EscapeMarkup())); - } - - if (winEnd < jobs.Count - 1) - table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↓ {jobs.Count - 1 - winEnd} more below[/]"), new Markup(string.Empty), new Markup(string.Empty), new Markup(string.Empty)); - - return new Rows(new Panel(table).Header($"[{_acc}] Jobs ({jobs.Count}) [/]").BorderColor(OutputFormatter.Accent).NoBorder()); - } - - static Rows BuildRecommendationsView(WorkbenchData data, int selectedIndex) - { - if (data.Recommendations.Count == 0) - { - return new Rows(new Panel(new Markup($"\n [{_suc}]✓ No pending recommendations.[/]\n")) - .Header($"[{_acc}] Recommendations [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Success)); - } - - var recs = data.Recommendations.ToList(); - var effectiveIndex = Math.Min(selectedIndex, recs.Count - 1); - var table = new Table().Border(TableBorder.Rounded).BorderColor(OutputFormatter.Warning).Expand() - .AddColumn(new TableColumn(string.Empty).Width(2)) - .AddColumn(new TableColumn("[bold]Recommendation[/]").Padding(1, 0)) - .AddColumn(new TableColumn("[bold]Type[/]").Padding(1, 0)) - .AddColumn(new TableColumn("[bold]Occurred[/]").Padding(1, 0)); - - var (winStart, winEnd) = ListWindow(recs.Count, effectiveIndex); - if (winStart > 0) - table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↑ {winStart} more above[/]"), new Markup(string.Empty), new Markup(string.Empty)); - for (var i = winStart; i <= winEnd; i++) - { - var rec = recs[i]; - var isSelected = i == effectiveIndex; - table.AddRow( - new Markup(isSelected ? $"[bold {_acc}]▶[/]" : string.Empty), - new Markup(isSelected ? $"[bold {_acc}]{(rec.Name ?? "—").EscapeMarkup()}[/]" : $"[{_war}]{(rec.Name ?? "—").EscapeMarkup()}[/]"), - new Markup($"[{_mut}]{(rec.Type ?? "—").EscapeMarkup()}[/]"), - new Markup($"[{_mut}]{rec.Occurred.ToString().EscapeMarkup()}[/]")); - } - - if (winEnd < recs.Count - 1) - table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↓ {recs.Count - 1 - winEnd} more below[/]"), new Markup(string.Empty), new Markup(string.Empty)); - - var selRec = recs[effectiveIndex]; - var desc = (selRec.Description ?? string.Empty).EscapeMarkup(); - if (desc.Length > 100) desc = desc[..100] + "…"; - var detail = new Panel(new Rows( - new Markup($" [{_mut}]type[/] [{_war}]{(selRec.Type ?? "—").EscapeMarkup()}[/] [{_mut}]occurred[/] [{_mut}]{selRec.Occurred.ToString().EscapeMarkup()}[/]"), - new Markup($" [{_mut}]{desc}[/]"), - new Markup($"\n [{_acc}][[ A ]][/] Apply [{_acc}][[ I ]][/] Ignore"))) - .Header($"[{_war}] {(selRec.Name ?? "—").EscapeMarkup()} [/]") - .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Warning).Expand(); - - return new Rows(new Panel(table).Header($"[{_war}] Recommendations ({recs.Count}) [/]").BorderColor(OutputFormatter.Warning).NoBorder(), detail); - } - - static Rows BuildEventLogView(WorkbenchData data, int selectedIndex, string filterText, bool ascending, int page) - { - if (data.RecentEvents.Count == 0) - { - return new Rows(new Panel(new Markup($"\n [{_mut}]No events in the event log yet.[/]\n")) - .Header($"[{_acc}] Event Log [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted)); - } - - // Sort the full event window, then filter, then page. - var sortedEvents = ascending - ? [.. data.RecentEvents.OrderBy(e => e.Context.SequenceNumber)] - : data.RecentEvents.ToList(); - - var filteredEvents = string.IsNullOrEmpty(filterText) - ? sortedEvents - : [.. sortedEvents.Where(e => (e.Context.EventType?.Id ?? string.Empty).Contains(filterText, StringComparison.OrdinalIgnoreCase))]; - - if (filteredEvents.Count == 0) - { - return new Rows(new Panel(new Markup($"\n [{_mut}]No events matching '{filterText.EscapeMarkup()}'.[/]\n")) - .Header($"[{_acc}] Event Log (0/{sortedEvents.Count}) [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted)); - } - - var totalPages = Math.Max(1, (filteredEvents.Count + EventLogPageSize - 1) / EventLogPageSize); - var effectivePage = Math.Min(page, totalPages - 1); - var pageStart = effectivePage * EventLogPageSize; - var pageEvents = filteredEvents.Skip(pageStart).Take(EventLogPageSize).ToList(); - var effectiveIndex = Math.Min(selectedIndex, Math.Max(0, pageEvents.Count - 1)); - - var sortIndicator = ascending ? $"[{_mut}]↑ oldest first[/]" : $"[{_mut}]↓ newest first[/]"; - var pageIndicator = totalPages > 1 - ? $" [{_mut}]page {effectivePage + 1}/{totalPages}[/]" - : string.Empty; - - var table = new Table().Border(TableBorder.Rounded).BorderColor(OutputFormatter.Muted).Expand() - .AddColumn(new TableColumn(string.Empty).Width(2)) - .AddColumn(new TableColumn("[bold]Seq#[/]").Padding(1, 0).RightAligned()) - .AddColumn(new TableColumn("[bold]EventType[/]").Padding(1, 0)) - .AddColumn(new TableColumn("[bold]EventSourceId[/]").Padding(1, 0)) - .AddColumn(new TableColumn("[bold]Occurred[/]").Padding(1, 0)); - - var (winStart, winEnd) = ListWindow(pageEvents.Count, effectiveIndex); - if (winStart > 0) - table.AddRow(new Markup(string.Empty), new Markup(string.Empty), new Markup($"[{_mut}]↑ {winStart} more above[/]"), new Markup(string.Empty), new Markup(string.Empty)); - for (var i = winStart; i <= winEnd; i++) - { - var evt = pageEvents[i]; - var ctx = evt.Context; - var isSelected = i == effectiveIndex; - var typeId = ctx.EventType?.Id ?? "—"; - table.AddRow( - new Markup(isSelected ? $"[bold {_acc}]▶[/]" : string.Empty), - new Markup($"[{_mut}]{ctx.SequenceNumber:N0}[/]"), - new Markup(isSelected ? $"[bold {_acc}]{typeId.EscapeMarkup()}[/]" : typeId.EscapeMarkup()), - new Markup((ctx.EventSourceId ?? string.Empty).EscapeMarkup()), - new Markup($"[{_mut}]{ctx.Occurred.ToString().EscapeMarkup()}[/]")); - } - - if (winEnd < pageEvents.Count - 1) - table.AddRow(new Markup(string.Empty), new Markup(string.Empty), new Markup($"[{_mut}]↓ {pageEvents.Count - 1 - winEnd} more below[/]"), new Markup(string.Empty), new Markup(string.Empty)); - - var selEvt = pageEvents[effectiveIndex]; - var selCtx = selEvt.Context; - var miniDetail = new Panel(new Markup( - $" [{_mut}]seq#[/] [bold]{selCtx.SequenceNumber:N0}[/] [{_mut}]eventSource[/] {(selCtx.EventSourceId ?? "—").EscapeMarkup()} [{_mut}]occurred[/] [{_mut}]{selCtx.Occurred.ToString().EscapeMarkup()}[/]\n" + - $"\n [{_acc}][[ Enter ]][/] Full event [{_acc}][[ T ]][/] View event type")) - .Header($"[{_acc}] {(selCtx.EventType?.Id ?? "—").EscapeMarkup()} [/]") - .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Accent).Expand(); - - var filterSuffix = string.IsNullOrEmpty(filterText) - ? string.Empty - : $" ({filteredEvents.Count}/{sortedEvents.Count} matching)"; - var logHeader = $"[{_acc}] Event Log{filterSuffix} {sortIndicator}{pageIndicator} [[PgDn/PgUp]] page [/]"; - return new Rows( - new Panel(table).Header(logHeader).BorderColor(OutputFormatter.Accent).NoBorder(), - miniDetail); - } - - static Rows BuildEventTypesView(WorkbenchData data, int selectedIndex, string filterText) - { - if (data.EventTypeRegistrations.Count == 0) - { - return new Rows(new Panel(new Markup($"\n [{_mut}]No event types registered.[/]\n")) - .Header($"[{_acc}] Event Types [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted)); - } - - var allTypes = data.EventTypeRegistrations.OrderBy(r => r.Type.Id, StringComparer.OrdinalIgnoreCase).ThenBy(r => r.Type.Generation).ToList(); - var types = string.IsNullOrEmpty(filterText) - ? allTypes - : [.. allTypes.Where(r => r.Type.Id.Contains(filterText, StringComparison.OrdinalIgnoreCase))]; - - if (types.Count == 0) - { - return new Rows(new Panel(new Markup($"\n [{_mut}]No event types matching '{filterText.EscapeMarkup()}'.[/]\n")) - .Header($"[{_acc}] Event Types (0/{allTypes.Count}) [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted)); - } - - var effectiveIndex = Math.Min(selectedIndex, types.Count - 1); - var table = new Table().Border(TableBorder.Rounded).BorderColor(OutputFormatter.Muted).Expand() - .AddColumn(new TableColumn(string.Empty).Width(2)) - .AddColumn(new TableColumn("[bold]EventType[/]").Padding(1, 0)) - .AddColumn(new TableColumn("[bold]Gen[/]").Padding(1, 0)) - .AddColumn(new TableColumn("[bold]Owner[/]").Padding(1, 0)) - .AddColumn(new TableColumn("[bold]Source[/]").Padding(1, 0)); - - var (winStart, winEnd) = ListWindow(types.Count, effectiveIndex); - if (winStart > 0) - table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↑ {winStart} more above[/]"), new Markup(string.Empty), new Markup(string.Empty), new Markup(string.Empty)); - for (var i = winStart; i <= winEnd; i++) - { - var reg = types[i]; - var isSelected = i == effectiveIndex; - table.AddRow( - new Markup(isSelected ? $"[bold {_acc}]▶[/]" : string.Empty), - new Markup(isSelected ? $"[bold {_acc}]{reg.Type.Id.EscapeMarkup()}[/]" : reg.Type.Id.EscapeMarkup()), - new Markup($"[{_mut}]{reg.Type.Generation}[/]"), - new Markup($"[{_mut}]{reg.Owner.ToString().EscapeMarkup()}[/]"), - new Markup($"[{_mut}]{reg.Source.ToString().EscapeMarkup()}[/]")); - } - - if (winEnd < types.Count - 1) - table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↓ {types.Count - 1 - winEnd} more below[/]"), new Markup(string.Empty), new Markup(string.Empty), new Markup(string.Empty)); - - var selType = types[effectiveIndex]; - var miniDetail = new Panel(new Markup( - $" [{_mut}]owner[/] {selType.Owner.ToString().EscapeMarkup()} [{_mut}]source[/] {selType.Source.ToString().EscapeMarkup()} [{_mut}]tombstone[/] {selType.Type.Tombstone}\n" + - $"\n [{_acc}][[ Enter ]][/] View schema")) - .Header($"[{_acc}] {selType.Type.Id.EscapeMarkup()} +{selType.Type.Generation} [/]") - .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Accent).Expand(); - - var typesHeader = string.IsNullOrEmpty(filterText) - ? $"[{_acc}] Event Types ({allTypes.Count}) [/]" - : $"[{_acc}] Event Types ({types.Count}/{allTypes.Count}) [/]"; - return new Rows( - new Panel(table).Header(typesHeader).BorderColor(OutputFormatter.Accent).NoBorder(), - miniDetail); - } - - static Rows BuildProjectionsView(WorkbenchData data, int selectedIndex, string filterText) - { - if (data.ProjectionDefinitions.Count == 0) - { - return new Rows(new Panel(new Markup($"\n [{_mut}]No projections registered.[/]\n")) - .Header($"[{_acc}] Projections [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted)); - } - - var allProjections = data.ProjectionDefinitions.OrderBy(d => d.Identifier, StringComparer.OrdinalIgnoreCase).ToList(); - var projections = string.IsNullOrEmpty(filterText) - ? allProjections - : [.. allProjections.Where(d => d.Identifier.Contains(filterText, StringComparison.OrdinalIgnoreCase))]; - - if (projections.Count == 0) - { - return new Rows(new Panel(new Markup($"\n [{_mut}]No projections matching '{filterText.EscapeMarkup()}'.[/]\n")) - .Header($"[{_acc}] Projections (0/{allProjections.Count}) [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted)); - } - - var effectiveIndex = Math.Min(selectedIndex, projections.Count - 1); - var table = new Table().Border(TableBorder.Rounded).BorderColor(OutputFormatter.Muted).Expand() - .AddColumn(new TableColumn(string.Empty).Width(2)) - .AddColumn(new TableColumn("[bold]Identifier[/]").Padding(1, 0)) - .AddColumn(new TableColumn("[bold]ReadModel[/]").Padding(1, 0)) - .AddColumn(new TableColumn("[bold]Active[/]").Padding(1, 0)) - .AddColumn(new TableColumn("[bold]Rewindable[/]").Padding(1, 0)); - - var (winStart, winEnd) = ListWindow(projections.Count, effectiveIndex); - if (winStart > 0) - table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↑ {winStart} more above[/]"), new Markup(string.Empty), new Markup(string.Empty), new Markup(string.Empty)); - for (var i = winStart; i <= winEnd; i++) - { - var def = projections[i]; - var isSelected = i == effectiveIndex; - var activeIcon = def.IsActive ? $"[{_suc}]✓[/]" : $"[{_mut}]✗[/]"; - var rewindIcon = def.IsRewindable ? $"[{_suc}]✓[/]" : $"[{_mut}]✗[/]"; - table.AddRow( - new Markup(isSelected ? $"[bold {_acc}]▶[/]" : string.Empty), - new Markup(isSelected ? $"[bold {_acc}]{def.Identifier.EscapeMarkup()}[/]" : def.Identifier.EscapeMarkup()), - new Markup($"[{_mut}]{def.ReadModel.EscapeMarkup()}[/]"), - new Markup(activeIcon), - new Markup(rewindIcon)); - } - - if (winEnd < projections.Count - 1) - table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↓ {projections.Count - 1 - winEnd} more below[/]"), new Markup(string.Empty), new Markup(string.Empty), new Markup(string.Empty)); - - var selDef = projections[effectiveIndex]; - var miniDetail = new Panel(new Markup( - $" [{_mut}]readModel[/] {selDef.ReadModel.EscapeMarkup()} [{_mut}]active[/] {(selDef.IsActive ? $"[{_suc}]yes[/]" : $"[{_mut}]no[/]")} [{_mut}]rewindable[/] {(selDef.IsRewindable ? $"[{_suc}]yes[/]" : $"[{_mut}]no[/]")}\n" + - $"\n [{_acc}][[ Enter ]][/] View declaration")) - .Header($"[{_acc}] {selDef.Identifier.EscapeMarkup()} [/]") - .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Accent).Expand(); - - var projHeader = string.IsNullOrEmpty(filterText) - ? $"[{_acc}] Projections ({allProjections.Count}) [/]" - : $"[{_acc}] Projections ({projections.Count}/{allProjections.Count}) [/]"; - return new Rows( - new Panel(table).Header(projHeader).BorderColor(OutputFormatter.Accent).NoBorder(), - miniDetail); - } - - static Rows BuildReadModelsView(WorkbenchData data, int selectedIndex, string filterText) - { - if (data.ReadModelDefinitions.Count == 0) - { - return new Rows(new Panel(new Markup($"\n [{_mut}]No read models registered.[/]\n")) - .Header($"[{_acc}] Read Models [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted)); - } - - var allModels = data.ReadModelDefinitions.OrderBy(d => d.ContainerName, StringComparer.OrdinalIgnoreCase).ToList(); - var models = string.IsNullOrEmpty(filterText) - ? allModels - : [.. allModels.Where(d => d.ContainerName.Contains(filterText, StringComparison.OrdinalIgnoreCase) - || d.DisplayName.Contains(filterText, StringComparison.OrdinalIgnoreCase))]; - - if (models.Count == 0) - { - return new Rows(new Panel(new Markup($"\n [{_mut}]No read models matching '{filterText.EscapeMarkup()}'.[/]\n")) - .Header($"[{_acc}] Read Models (0/{allModels.Count}) [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted)); - } - - var effectiveIndex = Math.Min(selectedIndex, models.Count - 1); - var table = new Table().Border(TableBorder.Rounded).BorderColor(OutputFormatter.Muted).Expand() - .AddColumn(new TableColumn(string.Empty).Width(2)) - .AddColumn(new TableColumn("[bold]Container[/]").Padding(1, 0)) - .AddColumn(new TableColumn("[bold]Display Name[/]").Padding(1, 0)) - .AddColumn(new TableColumn("[bold]Owner[/]").Padding(1, 0)) - .AddColumn(new TableColumn("[bold]Queryable[/]").Padding(1, 0)); - - var (winStart, winEnd) = ListWindow(models.Count, effectiveIndex); - if (winStart > 0) - table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↑ {winStart} more above[/]"), new Markup(string.Empty), new Markup(string.Empty), new Markup(string.Empty)); - for (var i = winStart; i <= winEnd; i++) - { - var rm = models[i]; - var isSelected = i == effectiveIndex; - var queryIcon = rm.IsQueryable ? $"[{_suc}]✓[/]" : $"[{_mut}]✗[/]"; - table.AddRow( - new Markup(isSelected ? $"[bold {_acc}]▶[/]" : string.Empty), - new Markup(isSelected ? $"[bold {_acc}]{rm.ContainerName.EscapeMarkup()}[/]" : rm.ContainerName.EscapeMarkup()), - new Markup($"[{_mut}]{rm.DisplayName.EscapeMarkup()}[/]"), - new Markup($"[{_mut}]{rm.Owner.EscapeMarkup()}[/]"), - new Markup(queryIcon)); - } - - if (winEnd < models.Count - 1) - table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↓ {models.Count - 1 - winEnd} more below[/]"), new Markup(string.Empty), new Markup(string.Empty), new Markup(string.Empty)); - - var selModel = models[effectiveIndex]; - var miniDetail = new Panel(new Markup( - $" [{_mut}]owner[/] {selModel.Owner.EscapeMarkup()} [{_mut}]source[/] {selModel.Source.EscapeMarkup()} [{_mut}]queryable[/] {(selModel.IsQueryable ? $"[{_suc}]yes[/]" : $"[{_mut}]no[/]")}\n" + - $" [{_mut}]identifier[/] {selModel.Identifier.EscapeMarkup()}\n" + - (selModel.IsQueryable ? $"\n [{_acc}][[ Enter ]][/] View instances" : $"\n [{_mut}](client-owned — instances are not stored on the server)[/]"))) - .Header($"[{_acc}] {selModel.ContainerName.EscapeMarkup()} [/]") - .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Accent).Expand(); - - var rmHeader = string.IsNullOrEmpty(filterText) - ? $"[{_acc}] Read Models ({allModels.Count}) [/]" - : $"[{_acc}] Read Models ({models.Count}/{allModels.Count}) [/]"; - return new Rows( - new Panel(table).Header(rmHeader).BorderColor(OutputFormatter.Accent).NoBorder(), - miniDetail); - } - - static Rows BuildEventStoresView(WorkbenchData data, int selectedIndex) - { - var stores = data.EventStoreNames.Order(StringComparer.OrdinalIgnoreCase).ToList(); - if (stores.Count == 0) - { - return new Rows(new Panel(new Markup($"\n [{_mut}]No event stores found.[/]\n")) - .Header($"[{_acc}] Event Stores [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted)); - } - - var effectiveIndex = Math.Min(selectedIndex, stores.Count - 1); - var table = new Table().Border(TableBorder.Rounded).BorderColor(OutputFormatter.Muted).Expand() - .AddColumn(new TableColumn(string.Empty).Width(2)) - .AddColumn(new TableColumn("[bold]Event Store[/]").Padding(1, 0)); - - var (winStart, winEnd) = ListWindow(stores.Count, effectiveIndex); - if (winStart > 0) - table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↑ {winStart} more above[/]")); - for (var i = winStart; i <= winEnd; i++) - { - var store = stores[i]; - var isSelected = i == effectiveIndex; - var isActive = string.Equals(store, data.EventStore, StringComparison.Ordinal); - var label = isActive ? $"[bold]{store.EscapeMarkup()}[/] [{_suc}]← active[/]" : store.EscapeMarkup(); - table.AddRow( - new Markup(isSelected ? $"[bold {_acc}]▶[/]" : string.Empty), - new Markup(isSelected ? $"[bold {_acc}]{store.EscapeMarkup()}[/]" : label)); - } - - if (winEnd < stores.Count - 1) - table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↓ {stores.Count - 1 - winEnd} more below[/]")); - - var hint = new Panel(new Markup($" [{_mut}]Press[/] [{_acc}][[ Enter ]][/] [{_mut}]to switch to the selected event store.[/]")) - .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted); - - return new Rows( - new Panel(table).Header($"[{_acc}] Event Stores ({stores.Count}) active: {data.EventStore.EscapeMarkup()} [/]").BorderColor(OutputFormatter.Accent).NoBorder(), - hint); - } - - static Rows BuildNamespacesView(WorkbenchData data, int selectedIndex) - { - if (data.NamespaceNames.Count == 0) - { - return new Rows(new Panel(new Markup($"\n [{_mut}]No namespaces found (or data not yet loaded).[/]\n")) - .Header($"[{_acc}] Namespaces [/]").Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted)); - } - - var nsList = data.NamespaceNames.Order(StringComparer.OrdinalIgnoreCase).ToList(); - var effectiveIndex = Math.Min(selectedIndex, nsList.Count - 1); - var table = new Table().Border(TableBorder.Rounded).BorderColor(OutputFormatter.Muted).Expand() - .AddColumn(new TableColumn(string.Empty).Width(2)) - .AddColumn(new TableColumn("[bold]Namespace[/]").Padding(1, 0)); - - var (winStart, winEnd) = ListWindow(nsList.Count, effectiveIndex); - if (winStart > 0) - table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↑ {winStart} more above[/]")); - for (var i = winStart; i <= winEnd; i++) - { - var ns = nsList[i]; - var isSelected = i == effectiveIndex; - var isActive = string.Equals(ns, data.Namespace, StringComparison.Ordinal); - var label = isActive ? $"[bold]{ns.EscapeMarkup()}[/] [{_suc}]← active[/]" : ns.EscapeMarkup(); - table.AddRow( - new Markup(isSelected ? $"[bold {_acc}]▶[/]" : string.Empty), - new Markup(isSelected ? $"[bold {_acc}]{ns.EscapeMarkup()}[/]" : label)); - } - - if (winEnd < nsList.Count - 1) - table.AddRow(new Markup(string.Empty), new Markup($"[{_mut}]↓ {nsList.Count - 1 - winEnd} more below[/]")); - - var hint = new Panel(new Markup($" [{_mut}]Press[/] [{_acc}][[ Enter ]][/] [{_mut}]to switch to the selected namespace.[/]")) - .Border(BoxBorder.Rounded).BorderColor(OutputFormatter.Muted); - - return new Rows( - new Panel(table).Header($"[{_acc}] Namespaces ({nsList.Count}) active: {data.Namespace.EscapeMarkup()} [/]").BorderColor(OutputFormatter.Accent).NoBorder(), - hint); - } -} diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchRenderer.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchRenderer.cs deleted file mode 100644 index 3fa62a6..0000000 --- a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchRenderer.cs +++ /dev/null @@ -1,316 +0,0 @@ -// Copyright (c) Cratis. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - -using Cratis.Chronicle.Contracts.Jobs; -using Spectre.Console.Rendering; - -namespace Cratis.Cli.Commands.Chronicle.Workbench; - -/// -/// Builds Spectre.Console renderables for the Chronicle workbench dashboard. -/// -public static partial class WorkbenchRenderer -{ - /// Maximum number of list rows shown before the sliding window kicks in. - const int MaxListRows = 10; - - /// Height of the scrollable text viewport in detail views (lines). - const int ScrollViewport = 12; - - static readonly string _acc = OutputFormatter.Accent.ToMarkup(); - static readonly string _mut = OutputFormatter.Muted.ToMarkup(); - static readonly string _suc = OutputFormatter.Success.ToMarkup(); - static readonly string _war = OutputFormatter.Warning.ToMarkup(); - static readonly string _dan = OutputFormatter.Danger.ToMarkup(); - - static readonly (WorkbenchView V, string Key, string Label)[] _views = - [ - (WorkbenchView.Overview, "1", "Overview"), - (WorkbenchView.Observers, "2", "Observers"), - (WorkbenchView.FailedPartitions, "3", "Failures"), - (WorkbenchView.Jobs, "4", "Jobs"), - (WorkbenchView.Recommendations, "5", "Recommendations"), - (WorkbenchView.EventLog, "6", "Event Log"), - (WorkbenchView.EventTypes, "7", "Event Types"), - (WorkbenchView.Projections, "8", "Projections"), - (WorkbenchView.ReadModels, "9", "Read Models"), - (WorkbenchView.EventStores, "0", "Event Stores"), - (WorkbenchView.Namespaces, string.Empty, "Namespaces") - ]; - - /// - /// Builds the full dashboard renderable for the given data and render state. - /// - /// The workbench snapshot to render. - /// All render-relevant state for this frame. - /// The renderable dashboard. - public static Rows Build(WorkbenchData data, WorkbenchRenderState state) => - new(BuildHeader(data, state), BuildBody(data, state), BuildFooter(data, state)); - - static Panel BuildHeader(WorkbenchData data, WorkbenchRenderState state) - { - var dot = data.IsConnected ? $"[{_suc}]●[/]" : $"[{_dan}]●[/]"; - var cs = DisplayConnectionString(data.ConnectionString); - var store = $"[bold]{data.EventStore.EscapeMarkup()}[/][{_mut}] / {data.Namespace.EscapeMarkup()}[/]"; - var titleLine = $" [{_acc}]◆[/] [bold]CHRONICLE WORKBENCH[/] [{_mut}]{cs.EscapeMarkup()}[/] {dot} {store}"; - - // Second line: breadcrumb when in a detail view, otherwise nav tabs. - string secondLine; - var breadcrumb = state.Breadcrumb; - if (breadcrumb is { Count: > 0 }) - { - var path = string.Join($" [{_mut}]›[/] ", breadcrumb.Select(s => s.EscapeMarkup())); - secondLine = $" [{_mut}]{path}[/] [{_mut}]↻ {state.Interval}s[/]"; - } - else - { - var failuresAlert = data.FailedPartitions.Count > 0; - var recsAlert = data.Recommendations.Count > 0; - var tabs = string.Join(" ", _views.Select(v => - { - var isActive = v.V == state.View; - var keyPart = string.IsNullOrEmpty(v.Key) ? string.Empty : $"[[ {v.Key} ]] "; - var alert = (v.V == WorkbenchView.FailedPartitions && failuresAlert) - || (v.V == WorkbenchView.Recommendations && recsAlert); - if (isActive) - return $"[bold {_acc}]{keyPart}{v.Label.EscapeMarkup()}[/]"; - if (alert) - return $"[bold {_war}]{keyPart}{v.Label.EscapeMarkup()}[/]"; - return string.IsNullOrEmpty(v.Key) - ? $"[{_mut}]{v.Label.EscapeMarkup()}[/]" - : $"[{_mut}]{keyPart}[/]{v.Label.EscapeMarkup()}"; - })); - var refreshIndicator = $"[{_mut}]↻ {state.Interval}s[/]"; - secondLine = $" {tabs} {refreshIndicator}"; - } - - return new Panel(new Rows(new Markup(titleLine), new Markup(secondLine))) - .Border(BoxBorder.Heavy) - .BorderColor(OutputFormatter.Accent) - .Padding(0, 0); - } - - static Rows BuildFooter(WorkbenchData data, WorkbenchRenderState state) - { - var sep = new Rule().RuleStyle(new Style(foreground: OutputFormatter.Muted)); - - Markup statusLine; - switch (state.ActionState) - { - case WorkbenchActionState.AwaitingConfirmation: - var desc = (state.PendingActionDescription ?? string.Empty).EscapeMarkup(); - statusLine = new Markup($" [{_war}]⚡ {desc}?[/] [{_war}][[ Y ]][/] Confirm [{_mut}][[ N ]][/] Cancel"); - break; - - case WorkbenchActionState.Executing: - var execDesc = (state.PendingActionDescription ?? "Working").EscapeMarkup(); - statusLine = new Markup($" [{_acc}]⟳ {execDesc}…[/]"); - break; - - case WorkbenchActionState.Completed: - var result = (state.ActionResult ?? string.Empty).EscapeMarkup(); - var resColor = state.IsActionError ? _dan : _suc; - var resIcon = state.IsActionError ? "✗" : "✓"; - statusLine = new Markup($" [{resColor}]{resIcon} {result}[/] [{_mut}](press any key to dismiss)[/]"); - break; - - default: - var isDetail = (int)state.View >= 100; - var connected = data.IsConnected ? $"[{_suc}]✓ connected[/]" : $"[{_dan}]✗ disconnected[/]"; - var seqTail = data.TailSequenceNumber.HasValue - ? $"seq# [bold]{data.TailSequenceNumber.Value:N0}[/]" - : $"[{_mut}]seq# —[/]"; - - if (isDetail) - { - var actions = GetActionHints(state.View); - var scrollHint = $"[{_mut}]↑↓ scroll [/]"; - statusLine = new Markup($" {connected} {seqTail} {scrollHint}{actions}[{_mut}][[ Esc ]][/] Back"); - } - else - { - var navHint = HasNavigation(state.View) ? $"[{_mut}]↑↓ select [/]" : string.Empty; - var enterHint = HasDrillDown(state.View) && !state.FilterInputMode ? $"[{_mut}][[ Enter ]][/] detail " : string.Empty; - var actions = !state.FilterInputMode ? GetActionHints(state.View) : string.Empty; - string filterIndicator; - if (state.FilterInputMode) - filterIndicator = $"[{_acc}]/ {state.FilterText.EscapeMarkup()}█[/] [{_mut}][[ Enter ]] confirm [[ Esc ]] cancel [/]"; - else if (!string.IsNullOrEmpty(state.FilterText)) - filterIndicator = $"[{_acc}]/{state.FilterText.EscapeMarkup()}[/] [{_mut}][[ Esc ]] clear [/]"; - else - filterIndicator = string.Empty; - var filterHint = !state.FilterInputMode && IsFilterableView(state.View) && string.IsNullOrEmpty(state.FilterText) - ? $"[{_mut}][[ / ]] filter [/]" - : string.Empty; - var quit = $"[{_mut}]← → views [[ +/- ]] interval ({state.Interval}s) [[ Q ]] Quit[/]"; - statusLine = new Markup($" {connected} {seqTail} {navHint}{enterHint}{filterHint}{actions}{filterIndicator}{quit}"); - } - - break; - } - - return new Rows(sep, statusLine); - } - - static IRenderable BuildBody(WorkbenchData data, WorkbenchRenderState state) => - state.View switch - { - WorkbenchView.Overview => BuildOverview(data), - WorkbenchView.Observers => BuildObserversView(data, state.SelectedIndex, state.FilterText), - WorkbenchView.FailedPartitions => BuildFailedView(data, state.SelectedIndex), - WorkbenchView.Jobs => BuildJobsView(data, state.SelectedIndex), - WorkbenchView.Recommendations => BuildRecommendationsView(data, state.SelectedIndex), - WorkbenchView.EventLog => BuildEventLogView(data, state.SelectedIndex, state.FilterText, state.EventLogAscending, state.EventLogPage), - WorkbenchView.EventTypes => BuildEventTypesView(data, state.SelectedIndex, state.FilterText), - WorkbenchView.Projections => BuildProjectionsView(data, state.SelectedIndex, state.FilterText), - WorkbenchView.ReadModels => BuildReadModelsView(data, state.SelectedIndex, state.FilterText), - WorkbenchView.EventStores => BuildEventStoresView(data, state.SelectedIndex), - WorkbenchView.Namespaces => BuildNamespacesView(data, state.SelectedIndex), - WorkbenchView.ObserverDetail => BuildObserverDetailPage(data, state.FocusedId, state.SelectedIndex), - WorkbenchView.FailedPartitionDetail => BuildFailedPartitionDetailPage(data, state.FocusedId, state.ScrollOffset), - WorkbenchView.EventDetail => BuildEventDetailPage(data, state.FocusedId, state.ScrollOffset), - WorkbenchView.EventTypeDetail => BuildEventTypeDetailPage(data, state.FocusedId, state.ScrollOffset), - WorkbenchView.ProjectionDetail => BuildProjectionDetailPage(data, state.FocusedId, state.ScrollOffset), - WorkbenchView.ReadModelDetail => BuildReadModelDetailPage(data, state.FocusedId, state.ScrollOffset), - _ => new Markup(string.Empty) - }; - - static bool IsFilterableView(WorkbenchView view) => - view is WorkbenchView.Observers or WorkbenchView.EventTypes - or WorkbenchView.EventLog or WorkbenchView.Projections - or WorkbenchView.ReadModels; - - static bool HasNavigation(WorkbenchView view) => - view is WorkbenchView.Observers or WorkbenchView.FailedPartitions - or WorkbenchView.Jobs or WorkbenchView.Recommendations - or WorkbenchView.EventLog or WorkbenchView.EventTypes - or WorkbenchView.Projections or WorkbenchView.ReadModels - or WorkbenchView.EventStores or WorkbenchView.Namespaces - or WorkbenchView.ObserverDetail; - - static bool HasDrillDown(WorkbenchView view) => - view is WorkbenchView.Observers or WorkbenchView.FailedPartitions - or WorkbenchView.EventLog or WorkbenchView.EventTypes - or WorkbenchView.Projections or WorkbenchView.ReadModels - or WorkbenchView.EventStores or WorkbenchView.Namespaces - or WorkbenchView.ObserverDetail; - - static string GetActionHints(WorkbenchView view) => view switch - { - WorkbenchView.Observers => $"[{_mut}][[ R ]][/] Replay ", - WorkbenchView.FailedPartitions => $"[{_mut}][[ T ]][/] Retry [{_mut}][[ P ]][/] Replay partition ", - WorkbenchView.Jobs => $"[{_mut}][[ S ]][/] Stop [{_mut}][[ U ]][/] Resume ", - WorkbenchView.Recommendations => $"[{_mut}][[ A ]][/] Apply [{_mut}][[ I ]][/] Ignore ", - WorkbenchView.EventLog => $"[{_mut}][[ S ]][/] Sort [{_mut}][[ PgDn ]][/] Older [{_mut}][[ PgUp ]][/] Newer ", - WorkbenchView.ObserverDetail => $"[{_mut}][[ R ]][/] Replay [{_mut}][[ P ]][/] Projection ", - WorkbenchView.FailedPartitionDetail => $"[{_mut}][[ T ]][/] Retry [{_mut}][[ P ]][/] Replay partition ", - WorkbenchView.EventDetail => $"[{_mut}][[ T ]][/] View event type ", - WorkbenchView.EventStores => $"[{_mut}][[ Enter ]][/] Switch store ", - WorkbenchView.Namespaces => $"[{_mut}][[ Enter ]][/] Switch namespace ", - _ => string.Empty - }; - - static string StateIcon(ObserverRunningState state) => state switch - { - ObserverRunningState.Active => $"[{_suc}]●[/]", - ObserverRunningState.Replaying => $"[{_war}]▲[/]", - ObserverRunningState.Suspended => $"[{_mut}]○[/]", - ObserverRunningState.Disconnected => $"[{_mut}]⊘[/]", - _ => $"[{_mut}]·[/]" - }; - - static string StateName(ObserverRunningState state) => state switch - { - ObserverRunningState.Active => $"[{_suc}]Active[/]", - ObserverRunningState.Replaying => $"[{_war}]Replaying[/]", - ObserverRunningState.Suspended => $"[{_mut}]Suspended[/]", - ObserverRunningState.Disconnected => $"[{_mut}]Disconnected[/]", - _ => state.ToString().EscapeMarkup() - }; - - static int StateOrder(ObserverRunningState state) => state switch - { - ObserverRunningState.Disconnected => 0, - ObserverRunningState.Replaying => 1, - ObserverRunningState.Active => 2, - ObserverRunningState.Suspended => 3, - _ => 4 - }; - - static ulong? ComputeLag(ObserverInformation obs, ulong? tail) - { - if (!tail.HasValue || obs.LastHandledEventSequenceNumber == ulong.MaxValue) - { - return tail; - } - - return tail.Value > obs.LastHandledEventSequenceNumber - ? tail.Value - obs.LastHandledEventSequenceNumber - : 0; - } - - static string ProgressBar(int success, int total, int width = 16) - { - if (total == 0) return $"[{_mut}]{new string('░', width)}[/]"; - var filled = (int)Math.Min(width, (long)success * width / total); - return $"[{_suc}]{new string('█', filled)}[/][{_mut}]{new string('░', width - filled)}[/]"; - } - - static string FormatSeq(ulong seq) => - seq == ulong.MaxValue ? $"[{_mut}]—[/]" : seq.ToString("N0"); - - /// - /// Renders a block of text with a scrollable viewport and a "lines X–Y of Z" indicator. - /// - /// The panel header title. - /// The full list of lines to display. - /// The current scroll position (first visible line index). - /// The panel border color. - /// A panel showing the visible lines with a scroll indicator. - static Panel ScrollableText(string title, IReadOnlyList lines, int scrollOffset, Color borderColor) - { - var totalLines = lines.Count; - var start = Math.Max(0, Math.Min(scrollOffset, Math.Max(0, totalLines - ScrollViewport))); - const int maxOffset = ScrollViewport - 1; - var end = Math.Min(totalLines - 1, start + maxOffset); - var visible = lines.Skip(start).Take(ScrollViewport).Select(l => (IRenderable)new Markup(l)).ToList(); - var indicator = totalLines > ScrollViewport - ? $"\n [{_mut}]lines {start + 1}–{end + 1} of {totalLines} ↑↓ scroll[/]" - : string.Empty; - - var content = visible.Count > 0 - ? (IRenderable)new Rows([.. visible, new Markup(indicator)]) - : new Markup($"[{_mut}](empty)[/]"); - - return new Panel(content) - .Header($"[{_acc}] {title.EscapeMarkup()} [/]") - .Border(BoxBorder.Rounded) - .BorderColor(borderColor) - .Expand(); - } - - static (int Start, int End) ListWindow(int count, int selectedIndex) - { - if (count <= MaxListRows) return (0, count - 1); - const int maxOffset = MaxListRows - 1; - var start = Math.Max(0, selectedIndex - (MaxListRows / 2)); - var end = Math.Min(count - 1, start + maxOffset); - start = Math.Max(0, end - maxOffset); - return (start, end); - } - - static string DisplayConnectionString(string connectionString) - { - try - { - var withoutScheme = connectionString.Replace("chronicle://", string.Empty, StringComparison.OrdinalIgnoreCase); - var afterAuth = withoutScheme.Contains('@') ? withoutScheme[(withoutScheme.IndexOf('@') + 1)..] : withoutScheme; - var hostPort = afterAuth.Split('?')[0].TrimEnd('/'); - return $"chronicle://{hostPort}"; - } - catch - { - return connectionString; - } - } -} diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/EventLogView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/EventLogView.cs new file mode 100644 index 0000000..67da423 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/views/EventLogView.cs @@ -0,0 +1,223 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using SharpConsoleUI; +using SharpConsoleUI.Builders; +using SharpConsoleUI.Controls; +using UITableRow = SharpConsoleUI.Controls.TableRow; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Event Log navigation item — filterable, sortable table of recent events with a bordered detail pane showing event content. +/// +public class EventLogView : IWorkbenchView +{ + TableControl? _table; + PanelControl? _detailPanel; + PromptControl? _filterPrompt; + string _currentFilter = string.Empty; + List _allEvents = []; + WorkbenchData? _pendingData; + + /// + /// Gets or sets the callback invoked when the filter input gains or loses focus. + /// + public Action? OnFilterFocusChanged { get; set; } + + /// + public void Dispose() + { + _table?.Dispose(); + _detailPanel?.Dispose(); + _filterPrompt?.Dispose(); + } + + /// + public IWindowControl BuildContent(ConsoleWindowSystem windowSystem) + { + _table = Controls.Table() + .AddColumn("#", SharpConsoleUI.Layout.TextJustification.Right, 10) + .AddColumn("Occurred", SharpConsoleUI.Layout.TextJustification.Left, 22) + .AddColumn("Event Type", SharpConsoleUI.Layout.TextJustification.Left, null) + .AddColumn("Source", SharpConsoleUI.Layout.TextJustification.Left, 30) + .Interactive() + .WithSorting() + .WithVerticalScrollbar(ScrollbarVisibility.Auto) + .OnSelectedRowChanged((_, _) => RefreshDetail()) + .WithName("EventLogTable") + .Build(); + + _filterPrompt = Controls.Prompt("Filter: ") + .WithHistory(true) + .WithTabCompleter((input, _) => GetCompletions()) + .OnInputChanged((_, text) => + { + _currentFilter = text ?? string.Empty; + RebuildFilteredRows(); + }) + .OnGotFocus((_, _) => OnFilterFocusChanged?.Invoke(true)) + .OnLostFocus((_, _) => OnFilterFocusChanged?.Invoke(false)) + .WithName("EventLogFilterPrompt") + .Build(); + + var leftPane = Controls.ScrollablePanel() + .AddControl(_filterPrompt) + .AddControl(_table) + .WithVerticalScroll(ScrollMode.None) + .WithName("EventLogLeftPane") + .Build(); + + _detailPanel = Controls.Panel() + .WithContent($"[{WorkbenchColors.Muted.ToMarkup()}]Select an event.[/]") + .WithHeader(" EVENT ") + .Rounded() + .WithBorderColor(WorkbenchColors.Accent) + .WithPadding(1, 0, 1, 0) + .FillVertical() + .WithName("EventLogDetailPanel") + .Build(); + + var root = HorizontalGridControl.Create() + .Column(c => c.Add(leftPane)) + .WithSplitterAfter(0) + .Column(c => c.Width(55).Add(_detailPanel)) + .Build(); + + // Apply any data that arrived before controls were ready (NavigationView lazy init). + if (_pendingData is not null) + UpdateData(_pendingData); + + return root; + } + + /// + public void ActivateFilter(Window window) + { + if (_filterPrompt is not null) + { + window.FocusControl(_filterPrompt); + } + } + + /// + public void ClearFilter() + { + _currentFilter = string.Empty; + _filterPrompt?.SetInput(string.Empty); + RebuildFilteredRows(); + } + + /// + public void UpdateData(WorkbenchData data) + { + _pendingData = data; + if (_table is null) return; + + _allEvents = [.. data.RecentEvents]; + RebuildFilteredRows(); + } + + IEnumerable GetCompletions() => + _allEvents + .Select(e => $"type:{e.Context.EventType.Id}") + .Distinct() + .Order(); + + bool MatchesFilter(AppendedEvent evt) + { + if (string.IsNullOrEmpty(_currentFilter)) return true; + + var f = _currentFilter; + + if (f.StartsWith("type:", StringComparison.OrdinalIgnoreCase)) + { + var type = f[5..]; + return evt.Context.EventType.Id.Contains(type, StringComparison.OrdinalIgnoreCase); + } + + return evt.Context.EventType.Id.Contains(f, StringComparison.OrdinalIgnoreCase) || + (evt.Context.EventSourceId ?? string.Empty).Contains(f, StringComparison.OrdinalIgnoreCase); + } + + void RebuildFilteredRows() + { + if (_table is null) return; + + var selectedKey = (_table.SelectedRow?.Tag as AppendedEvent)?.Context.SequenceNumber.ToString(); + + _table.ClearRows(); + foreach (var evt in _allEvents.Where(MatchesFilter)) + { + _table.AddRow(new UITableRow( + [ + evt.Context.SequenceNumber.ToString(), + evt.Context.Occurred.ToString(), + evt.Context.EventType.Id, + evt.Context.EventSourceId ?? string.Empty + ]) + { Tag = evt }); + } + + if (selectedKey is not null) + { + RestoreSelection(selectedKey); + } + + RefreshDetail(); + } + + void RestoreSelection(string key) + { + if (_table is null) return; + + for (var i = 0; i < _table.Rows.Count; i++) + { + if (_table.Rows[i].Tag is AppendedEvent evt && + evt.Context.SequenceNumber.ToString() == key) + { + _table.SelectedRowIndex = i; + return; + } + } + } + + void RefreshDetail() + { + if (_table is null || _detailPanel is null) return; + + if (_table.SelectedRow?.Tag is not AppendedEvent evt) + { + _detailPanel.Content = $"[{WorkbenchColors.Muted.ToMarkup()}]Select an event.[/]"; + return; + } + + var acc = WorkbenchColors.Accent.ToMarkup(); + var mut = WorkbenchColors.Muted.ToMarkup(); + + var lines = new List + { + $"[{mut}]Seq#[/] {evt.Context.SequenceNumber}", + $"[{mut}]Type[/] [{acc}]{evt.Context.EventType.Id}[/] gen {evt.Context.EventType.Generation}", + $"[{mut}]Source[/] {evt.Context.EventSourceId ?? "—"}", + $"[{mut}]Occurred[/] {evt.Context.Occurred}", + $"[{mut}]Correlation[/] {evt.Context.CorrelationId}", + string.Empty, + $"[{acc}]Content:[/]" + }; + + if (!string.IsNullOrEmpty(evt.Content)) + { + foreach (var line in evt.Content.Split('\n').Take(30)) + { + lines.Add($"[{mut}]{line.TrimEnd()}[/]"); + } + } + else + { + lines.Add($"[{mut}](no content)[/]"); + } + + _detailPanel.Content = string.Join('\n', lines); + } +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/EventStoresView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/EventStoresView.cs new file mode 100644 index 0000000..aaf4ff7 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/views/EventStoresView.cs @@ -0,0 +1,127 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using SharpConsoleUI; +using SharpConsoleUI.Builders; +using SharpConsoleUI.Controls; +using UITableRow = SharpConsoleUI.Controls.TableRow; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Event Stores tab — list of available event stores with a Switch action. +/// Selecting an entry and pressing Enter switches the active event store. +/// +public class EventStoresView : IWorkbenchView +{ + TableControl? _table; + MarkupControl? _helpPane; + WorkbenchData? _pendingData; + + /// + /// Gets or sets the callback invoked when the user switches to a different event store. + /// + public Action? OnSwitch { get; set; } + + /// + public void Dispose() + { + _table?.Dispose(); + _helpPane?.Dispose(); + } + + /// + public IWindowControl BuildContent(ConsoleWindowSystem windowSystem) + { + _table = Controls.Table() + .AddColumn("Event Store", SharpConsoleUI.Layout.TextJustification.Left, null) + .Interactive() + .WithFiltering() + .WithSorting() + .WithVerticalScrollbar(ScrollbarVisibility.Auto) + .OnRowActivated((_, _) => SwitchToSelected()) + .WithName("EventStoresTable") + .Build(); + + _helpPane = new MarkupControl( + [ + $"[{WorkbenchColors.Accent.ToMarkup()}][bold]SWITCH EVENT STORE[/][/]", + string.Empty, + $" [{WorkbenchColors.Muted.ToMarkup()}]Select a store and press[/] [bold]Enter[/] [{WorkbenchColors.Muted.ToMarkup()}]to switch.[/]" + ]) + { Name = "EventStoresHelp" }; + + var root = HorizontalGridControl.Create() + .Column(c => c.Add(_table)) + .WithSplitterAfter(0) + .Column(c => c.Width(44).Add(_helpPane)) + .Build(); + + // Apply any data that arrived before controls were ready (NavigationView lazy init). + if (_pendingData is not null) + UpdateData(_pendingData); + + return root; + } + + /// + public void UpdateData(WorkbenchData data) + { + _pendingData = data; + if (_table is null) return; + + var selectedKey = _table.SelectedRow?.Tag as string; + + _table.ClearRows(); + foreach (var name in data.EventStoreNames.Order()) + { + _table.AddRow(new UITableRow([name]) { Tag = name }); + } + + if (selectedKey is not null) + { + RestoreSelection(selectedKey); + } + + if (_helpPane is null) return; + + var acc = WorkbenchColors.Accent.ToMarkup(); + var mut = WorkbenchColors.Muted.ToMarkup(); + var suc = WorkbenchColors.Success.ToMarkup(); + + var lines = new List + { + $"[{acc}][bold]SWITCH EVENT STORE[/][/]", + string.Empty, + $"[{mut}]Active[/] [{suc}]{data.EventStore}[/]", + $"[{mut}]Available[/] {data.EventStoreNames.Count}", + string.Empty, + $" [{mut}]Select a store and press[/] [bold]Enter[/]", + $" [{mut}]to make it the active event store.[/]" + }; + + _helpPane.Text = string.Join('\n', lines); + } + + void RestoreSelection(string key) + { + if (_table is null) return; + + for (var i = 0; i < _table.Rows.Count; i++) + { + if (_table.Rows[i].Tag is string name && name == key) + { + _table.SelectedRowIndex = i; + return; + } + } + } + + void SwitchToSelected() + { + if (_table?.SelectedRow?.Tag is string storeName) + { + OnSwitch?.Invoke(storeName); + } + } +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/EventTypesView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/EventTypesView.cs new file mode 100644 index 0000000..5cbb78c --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/views/EventTypesView.cs @@ -0,0 +1,224 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using SharpConsoleUI; +using SharpConsoleUI.Builders; +using SharpConsoleUI.Controls; +using UITableRow = SharpConsoleUI.Controls.TableRow; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Event Types navigation item — filterable table of registered event types with schema details in the bordered right pane. +/// +public class EventTypesView : IWorkbenchView +{ + TableControl? _table; + PanelControl? _detailPanel; + PromptControl? _filterPrompt; + string _currentFilter = string.Empty; + List _allItems = []; + WorkbenchData? _pendingData; + + /// + /// Gets or sets the callback invoked when the filter input gains or loses focus. + /// + public Action? OnFilterFocusChanged { get; set; } + + /// + public void Dispose() + { + _table?.Dispose(); + _detailPanel?.Dispose(); + _filterPrompt?.Dispose(); + } + + /// + public IWindowControl BuildContent(ConsoleWindowSystem windowSystem) + { + _table = Controls.Table() + .AddColumn("Id", SharpConsoleUI.Layout.TextJustification.Left, null) + .AddColumn("Gen", SharpConsoleUI.Layout.TextJustification.Right, 6) + .AddColumn("Owner", SharpConsoleUI.Layout.TextJustification.Left, 20) + .Interactive() + .WithSorting() + .WithVerticalScrollbar(ScrollbarVisibility.Auto) + .OnSelectedRowChanged((_, _) => RefreshDetail()) + .WithName("EventTypesTable") + .Build(); + + _filterPrompt = Controls.Prompt("Filter: ") + .WithHistory(true) + .WithTabCompleter((input, _) => GetCompletions()) + .OnInputChanged((_, text) => + { + _currentFilter = text ?? string.Empty; + RebuildFilteredRows(); + }) + .OnGotFocus((_, _) => OnFilterFocusChanged?.Invoke(true)) + .OnLostFocus((_, _) => OnFilterFocusChanged?.Invoke(false)) + .WithName("EventTypesFilterPrompt") + .Build(); + + var leftPane = Controls.ScrollablePanel() + .AddControl(_filterPrompt) + .AddControl(_table) + .WithVerticalScroll(ScrollMode.None) + .WithName("EventTypesLeftPane") + .Build(); + + _detailPanel = Controls.Panel() + .WithContent($"[{WorkbenchColors.Muted.ToMarkup()}]Select an event type.[/]") + .WithHeader(" EVENT TYPE ") + .Rounded() + .WithBorderColor(WorkbenchColors.Accent) + .WithPadding(1, 0, 1, 0) + .FillVertical() + .WithName("EventTypeDetailPanel") + .Build(); + + var root = HorizontalGridControl.Create() + .Column(c => c.Add(leftPane)) + .WithSplitterAfter(0) + .Column(c => c.Width(55).Add(_detailPanel)) + .Build(); + + // Apply any data that arrived before controls were ready (NavigationView lazy init). + if (_pendingData is not null) + UpdateData(_pendingData); + + return root; + } + + /// + public void ActivateFilter(Window window) + { + if (_filterPrompt is not null) + { + window.FocusControl(_filterPrompt); + } + } + + /// + public void ClearFilter() + { + _currentFilter = string.Empty; + _filterPrompt?.SetInput(string.Empty); + RebuildFilteredRows(); + } + + /// + public void UpdateData(WorkbenchData data) + { + _pendingData = data; + if (_table is null) return; + + _allItems = [.. data.EventTypeRegistrations.OrderBy(r => r.Type.Id).ThenBy(r => r.Type.Generation)]; + RebuildFilteredRows(); + } + + static IEnumerable GetCompletions() => + [ + "owner:client", + "owner:server", + "gen:1", + "gen:2" + ]; + + bool MatchesFilter(EventTypeRegistration reg) + { + if (string.IsNullOrEmpty(_currentFilter)) return true; + + var f = _currentFilter; + + if (f.StartsWith("owner:", StringComparison.OrdinalIgnoreCase)) + { + var owner = f[6..]; + return reg.Owner.ToString().Contains(owner, StringComparison.OrdinalIgnoreCase); + } + + if (f.StartsWith("gen:", StringComparison.OrdinalIgnoreCase)) + { + var gen = f[4..]; + return reg.Type.Generation.ToString().Contains(gen, StringComparison.OrdinalIgnoreCase); + } + + return reg.Type.Id.Contains(f, StringComparison.OrdinalIgnoreCase); + } + + void RebuildFilteredRows() + { + if (_table is null) return; + + var selectedKey = _table.SelectedRow?.Tag is EventTypeRegistration sel + ? $"{sel.Type.Id}+{sel.Type.Generation}" + : null; + + _table.ClearRows(); + foreach (var reg in _allItems.Where(MatchesFilter)) + { + _table.AddRow(new UITableRow([reg.Type.Id, reg.Type.Generation.ToString(), reg.Owner.ToString()]) { Tag = reg }); + } + + if (selectedKey is not null) + { + RestoreSelection(selectedKey); + } + + RefreshDetail(); + } + + void RestoreSelection(string key) + { + if (_table is null) return; + + for (var i = 0; i < _table.Rows.Count; i++) + { + if (_table.Rows[i].Tag is EventTypeRegistration reg && + $"{reg.Type.Id}+{reg.Type.Generation}" == key) + { + _table.SelectedRowIndex = i; + return; + } + } + } + + void RefreshDetail() + { + if (_table is null || _detailPanel is null) return; + + if (_table.SelectedRow?.Tag is not EventTypeRegistration reg) + { + _detailPanel.Content = $"[{WorkbenchColors.Muted.ToMarkup()}]Select an event type.[/]"; + return; + } + + var acc = WorkbenchColors.Accent.ToMarkup(); + var mut = WorkbenchColors.Muted.ToMarkup(); + + var lines = new List + { + $"[{mut}]Id[/] {reg.Type.Id}", + $"[{mut}]Generation[/] {reg.Type.Generation}", + $"[{mut}]Owner[/] {reg.Owner}", + $"[{mut}]Source[/] {reg.Source}", + $"[{mut}]Tombstone[/] {reg.Type.Tombstone}", + string.Empty, + $"[{acc}]Schema:[/]" + }; + + if (!string.IsNullOrEmpty(reg.Schema)) + { + foreach (var line in reg.Schema.Split('\n').Take(40)) + { + lines.Add($"[{mut}]{line.TrimEnd()}[/]"); + } + } + else + { + lines.Add($"[{mut}](no schema)[/]"); + } + + _detailPanel.Content = string.Join('\n', lines); + } +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/FailedPartitionsView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/FailedPartitionsView.cs new file mode 100644 index 0000000..5338a8f --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/views/FailedPartitionsView.cs @@ -0,0 +1,235 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using SharpConsoleUI; +using SharpConsoleUI.Builders; +using SharpConsoleUI.Controls; +using UITableRow = SharpConsoleUI.Controls.TableRow; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Failed Partitions navigation item — filterable table of failed partitions with retry/replay actions in the bordered detail pane. +/// +public class FailedPartitionsView : IWorkbenchView +{ + TableControl? _table; + PanelControl? _detailPanel; + PromptControl? _filterPrompt; + string _currentFilter = string.Empty; + List _allItems = []; + WorkbenchData? _pendingData; + + /// + /// Gets or sets the callback invoked when the filter input gains or loses focus. + /// + public Action? OnFilterFocusChanged { get; set; } + + /// + /// Gets or sets the callback invoked when the user requests a partition retry. + /// + public Action? OnRetryPartition { get; set; } + + /// + /// Gets or sets the callback invoked when the user requests a partition replay. + /// + public Action? OnReplayPartition { get; set; } + + /// + /// Gets or sets the callback invoked when the user requests a bulk retry of all checked partitions. + /// + public Action>? OnRetryAll { get; set; } + + /// + /// Gets or sets the callback invoked when the user requests a bulk replay of all checked partitions. + /// + public Action>? OnReplayAll { get; set; } + + /// + /// Returns all failed partitions that are currently checked (checkbox mode). + /// + /// A list of checked items. + public IReadOnlyList GetCheckedItems() => + [.. (_table?.GetCheckedRows() ?? []).Select(r => r.Tag).OfType()]; + + /// + public void Dispose() + { + _table?.Dispose(); + _detailPanel?.Dispose(); + _filterPrompt?.Dispose(); + } + + /// + public IWindowControl BuildContent(ConsoleWindowSystem windowSystem) + { + _table = Controls.Table() + .AddColumn("Observer", SharpConsoleUI.Layout.TextJustification.Left, null) + .AddColumn("Partition", SharpConsoleUI.Layout.TextJustification.Left, 30) + .AddColumn("Attempts", SharpConsoleUI.Layout.TextJustification.Right, 10) + .Interactive() + .WithCheckboxMode() + .WithSorting() + .WithVerticalScrollbar(ScrollbarVisibility.Auto) + .OnSelectedRowChanged((_, _) => RefreshDetail()) + .WithName("FailedPartitionsTable") + .Build(); + + _filterPrompt = Controls.Prompt("Filter: ") + .WithHistory(true) + .OnInputChanged((_, text) => + { + _currentFilter = text ?? string.Empty; + RebuildFilteredRows(); + }) + .OnGotFocus((_, _) => OnFilterFocusChanged?.Invoke(true)) + .OnLostFocus((_, _) => OnFilterFocusChanged?.Invoke(false)) + .WithName("FailedPartitionsFilterPrompt") + .Build(); + + var leftPane = Controls.ScrollablePanel() + .AddControl(_filterPrompt) + .AddControl(_table) + .WithVerticalScroll(ScrollMode.None) + .WithName("FailedPartitionsLeftPane") + .Build(); + + _detailPanel = Controls.Panel() + .WithContent($"[{WorkbenchColors.Muted.ToMarkup()}]Select a failed partition.[/]") + .WithHeader(" FAILED PARTITION ") + .Rounded() + .WithBorderColor(WorkbenchColors.Danger) + .WithPadding(1, 0, 1, 0) + .FillVertical() + .WithName("FailedPartitionDetailPanel") + .Build(); + + var root = HorizontalGridControl.Create() + .Column(c => c.Add(leftPane)) + .WithSplitterAfter(0) + .Column(c => c.Width(50).Add(_detailPanel)) + .Build(); + + // Apply any data that arrived before controls were ready (NavigationView lazy init). + if (_pendingData is not null) + UpdateData(_pendingData); + + return root; + } + + /// + public void ActivateFilter(Window window) + { + if (_filterPrompt is not null) + { + window.FocusControl(_filterPrompt); + } + } + + /// + public void ClearFilter() + { + _currentFilter = string.Empty; + _filterPrompt?.SetInput(string.Empty); + RebuildFilteredRows(); + } + + /// + public void UpdateData(WorkbenchData data) + { + _pendingData = data; + if (_table is null) return; + + _allItems = [.. data.FailedPartitions.OrderByDescending(p => p.Attempts.Count())]; + RebuildFilteredRows(); + } + + bool MatchesFilter(FailedPartition fp) + { + if (string.IsNullOrEmpty(_currentFilter)) return true; + + return fp.ObserverId.Contains(_currentFilter, StringComparison.OrdinalIgnoreCase) || + fp.Partition.Contains(_currentFilter, StringComparison.OrdinalIgnoreCase); + } + + void RebuildFilteredRows() + { + if (_table is null) return; + + var selectedKey = _table.SelectedRow?.Tag is FailedPartition sel + ? $"{sel.ObserverId}/{sel.Partition}" + : null; + + _table.ClearRows(); + foreach (var fp in _allItems.Where(MatchesFilter)) + { + _table.AddRow(new UITableRow([fp.ObserverId, fp.Partition, fp.Attempts.Count().ToString()]) { Tag = fp }); + } + + if (selectedKey is not null) + { + RestoreSelection(selectedKey); + } + + RefreshDetail(); + } + + void RestoreSelection(string key) + { + if (_table is null) return; + + for (var i = 0; i < _table.Rows.Count; i++) + { + if (_table.Rows[i].Tag is FailedPartition fp && + $"{fp.ObserverId}/{fp.Partition}" == key) + { + _table.SelectedRowIndex = i; + return; + } + } + } + + void RefreshDetail() + { + if (_table is null || _detailPanel is null) return; + + if (_table.SelectedRow?.Tag is not FailedPartition fp) + { + _detailPanel.Content = $"[{WorkbenchColors.Muted.ToMarkup()}]Select a failed partition.[/]"; + return; + } + + var mut = WorkbenchColors.Muted.ToMarkup(); + var dan = WorkbenchColors.Danger.ToMarkup(); + var acc = WorkbenchColors.Accent.ToMarkup(); + + var lines = new List + { + $"[{mut}]Observer[/] {fp.ObserverId}", + $"[{mut}]Partition[/] [{dan}]{fp.Partition}[/]", + $"[{mut}]Attempts[/] {fp.Attempts.Count()}", + string.Empty, + $"[{acc}]Last Attempts:[/]" + }; + + foreach (var attempt in fp.Attempts.OrderByDescending(a => a.Occurred).Take(5)) + { + lines.Add($" [{mut}]{attempt.Occurred}[/]"); + var firstMessage = attempt.Messages?.FirstOrDefault(); + if (!string.IsNullOrEmpty(firstMessage)) + { + var msg = firstMessage.Length > 80 ? firstMessage[..77] + "…" : firstMessage; + lines.Add($" [{dan}]{msg}[/]"); + } + } + + if (OnRetryPartition is not null || OnReplayPartition is not null) + { + lines.Add(string.Empty); + if (OnRetryPartition is not null) lines.Add($"[{mut}]Press[/] [bold]T[/] [{mut}]to retry[/]"); + if (OnReplayPartition is not null) lines.Add($"[{mut}]Press[/] [bold]P[/] [{mut}]to replay[/]"); + } + + _detailPanel.Content = string.Join('\n', lines); + } +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/IWorkbenchView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/IWorkbenchView.cs new file mode 100644 index 0000000..a02c723 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/views/IWorkbenchView.cs @@ -0,0 +1,55 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using SharpConsoleUI; +using SharpConsoleUI.Controls; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Contract for a workbench view — a navigation-item content pane that can receive live data updates. +/// +public interface IWorkbenchView : IDisposable +{ + /// + /// Gets or sets the callback invoked when the filter input gains or loses focus. + /// = filter focused; = filter unfocused. + /// Wired by MainWindow to gate global keyboard shortcuts. + /// Views that do not have a filter bar may leave this as a no-op. + /// + Action? OnFilterFocusChanged + { + get => null; + set { } + } + + /// + /// Builds the initial control hierarchy for this view. Called once during window construction. + /// + /// The SharpConsoleUI window system. + /// The root to embed in the navigation pane. + IWindowControl BuildContent(ConsoleWindowSystem windowSystem); + + /// + /// Called on the background refresh thread whenever new data arrives. + /// Implementations must only update control properties — never create new controls here. + /// + /// The latest workbench data snapshot. + void UpdateData(WorkbenchData data); + + /// + /// Activates the filter bar for this view, if the view supports filtering. + /// Default implementation is a no-op for views that do not have a filter bar. + /// + /// The host used to set focus. + void ActivateFilter(Window window) + { + } + + /// + /// Clears the current filter and returns focus to the table. No-op for views without a filter. + /// + void ClearFilter() + { + } +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/JobsView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/JobsView.cs new file mode 100644 index 0000000..e880c7a --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/views/JobsView.cs @@ -0,0 +1,196 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Chronicle.Contracts.Jobs; +using SharpConsoleUI; +using SharpConsoleUI.Builders; +using SharpConsoleUI.Controls; +using UITableRow = SharpConsoleUI.Controls.TableRow; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Jobs tab — table of background jobs with stop/resume actions in the bordered detail pane. +/// +public class JobsView : IWorkbenchView +{ + TableControl? _table; + PanelControl? _detailPanel; + WorkbenchData? _pendingData; + + /// + /// Gets or sets the callback invoked when the user requests to stop a job. + /// + public Action? OnStopJob { get; set; } + + /// + /// Gets or sets the callback invoked when the user requests to resume a job. + /// + public Action? OnResumeJob { get; set; } + + /// + /// Gets or sets the callback invoked when the user requests a bulk stop of all checked jobs. + /// + public Action>? OnStopAll { get; set; } + + /// + /// Gets or sets the callback invoked when the user requests a bulk resume of all checked jobs. + /// + public Action>? OnResumeAll { get; set; } + + /// + /// Returns all jobs that are currently checked (checkbox mode). + /// + /// A list of checked items. + public IReadOnlyList GetCheckedItems() => + [.. (_table?.GetCheckedRows() ?? []).Select(r => r.Tag).OfType()]; + + /// + public void Dispose() + { + _table?.Dispose(); + _detailPanel?.Dispose(); + } + + /// + public IWindowControl BuildContent(ConsoleWindowSystem windowSystem) + { + _table = Controls.Table() + .AddColumn("Status", SharpConsoleUI.Layout.TextJustification.Left, 22) + .AddColumn("Type", SharpConsoleUI.Layout.TextJustification.Left, null) + .AddColumn("Progress", SharpConsoleUI.Layout.TextJustification.Right, 14) + .Interactive() + .WithCheckboxMode() + .WithFiltering() + .WithSorting() + .WithVerticalScrollbar(ScrollbarVisibility.Auto) + .OnSelectedRowChanged((_, _) => RefreshDetail()) + .WithName("JobsTable") + .Build(); + + _detailPanel = Controls.Panel() + .WithContent($"[{WorkbenchColors.Muted.ToMarkup()}]Select a job.[/]") + .WithHeader(" JOB ") + .Rounded() + .WithBorderColor(WorkbenchColors.Accent) + .WithPadding(1, 0, 1, 0) + .FillVertical() + .WithName("JobDetailPanel") + .Build(); + + var root = HorizontalGridControl.Create() + .Column(c => c.Add(_table)) + .WithSplitterAfter(0) + .Column(c => c.Width(45).Add(_detailPanel)) + .Build(); + + // Apply any data that arrived before controls were ready (NavigationView lazy init). + if (_pendingData is not null) + UpdateData(_pendingData); + + return root; + } + + /// + public void UpdateData(WorkbenchData data) + { + _pendingData = data; + if (_table is null) return; + + var selectedKey = (_table.SelectedRow?.Tag as Job)?.Id.ToString(); + + _table.ClearRows(); + foreach (var job in data.Jobs.OrderBy(j => j.Status.ToString())) + { + var statusColor = GetJobStatusColor(job.Status); + _table.AddRow(new UITableRow( + [ + $"[{statusColor}]{job.Status}[/]", + job.Type ?? job.Id.ToString(), + FormatProgress(job.Progress) + ]) + { Tag = job }); + } + + if (selectedKey is not null) + { + RestoreSelection(selectedKey); + } + + RefreshDetail(); + } + + static string GetJobStatusColor(JobStatus status) => status switch + { + JobStatus.Running => WorkbenchColors.Success.ToMarkup(), + JobStatus.Failed => WorkbenchColors.Danger.ToMarkup(), + JobStatus.Stopped => WorkbenchColors.Warning.ToMarkup(), + JobStatus.CompletedWithFailures => WorkbenchColors.Warning.ToMarkup(), + _ => WorkbenchColors.Muted.ToMarkup() + }; + + static string FormatProgress(JobProgress? p) + { + if (p is null || p.TotalSteps == 0) return "—"; + return $"{p.SuccessfulSteps}/{p.TotalSteps}"; + } + + void RestoreSelection(string key) + { + if (_table is null) return; + + for (var i = 0; i < _table.Rows.Count; i++) + { + if (_table.Rows[i].Tag is Job job && job.Id.ToString() == key) + { + _table.SelectedRowIndex = i; + return; + } + } + } + + void RefreshDetail() + { + if (_table is null || _detailPanel is null) return; + + if (_table.SelectedRow?.Tag is not Job job) + { + _detailPanel.Content = $"[{WorkbenchColors.Muted.ToMarkup()}]Select a job.[/]"; + return; + } + + var mut = WorkbenchColors.Muted.ToMarkup(); + var statusColor = GetJobStatusColor(job.Status); + + var lines = new List + { + $"[{mut}]Id[/] {job.Id}", + $"[{mut}]Type[/] {job.Type ?? "—"}", + $"[{mut}]Status[/] [{statusColor}]{job.Status}[/]", + $"[{mut}]Progress[/] {FormatProgress(job.Progress)}" + }; + + if (job.Progress is not null) + { + lines.Add($"[{mut}]Steps[/] {job.Progress.SuccessfulSteps}/{job.Progress.TotalSteps}"); + if (job.Progress.FailedSteps > 0) + { + lines.Add($"[{WorkbenchColors.Danger.ToMarkup()}]Failed[/] {job.Progress.FailedSteps}"); + } + + if (!string.IsNullOrEmpty(job.Progress.Message)) + { + lines.Add($"[{mut}]Message[/] {job.Progress.Message}"); + } + } + + if (OnStopJob is not null || OnResumeJob is not null) + { + lines.Add(string.Empty); + if (OnStopJob is not null) lines.Add($"[{mut}]Press[/] [bold]S[/] [{mut}]to stop[/]"); + if (OnResumeJob is not null) lines.Add($"[{mut}]Press[/] [bold]U[/] [{mut}]to resume[/]"); + } + + _detailPanel.Content = string.Join('\n', lines); + } +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/NamespacesView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/NamespacesView.cs new file mode 100644 index 0000000..1da00f6 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/views/NamespacesView.cs @@ -0,0 +1,128 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using SharpConsoleUI; +using SharpConsoleUI.Builders; +using SharpConsoleUI.Controls; +using UITableRow = SharpConsoleUI.Controls.TableRow; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Namespaces tab — list of available namespaces in the current event store with a Switch action. +/// Selecting an entry and pressing Enter switches the active namespace. +/// +public class NamespacesView : IWorkbenchView +{ + TableControl? _table; + MarkupControl? _helpPane; + WorkbenchData? _pendingData; + + /// + /// Gets or sets the callback invoked when the user switches to a different namespace. + /// + public Action? OnSwitch { get; set; } + + /// + public void Dispose() + { + _table?.Dispose(); + _helpPane?.Dispose(); + } + + /// + public IWindowControl BuildContent(ConsoleWindowSystem windowSystem) + { + _table = Controls.Table() + .AddColumn("Namespace", SharpConsoleUI.Layout.TextJustification.Left, null) + .Interactive() + .WithFiltering() + .WithSorting() + .WithVerticalScrollbar(ScrollbarVisibility.Auto) + .OnRowActivated((_, _) => SwitchToSelected()) + .WithName("NamespacesTable") + .Build(); + + _helpPane = new MarkupControl( + [ + $"[{WorkbenchColors.Accent.ToMarkup()}][bold]SWITCH NAMESPACE[/][/]", + string.Empty, + $" [{WorkbenchColors.Muted.ToMarkup()}]Select a namespace and press[/] [bold]Enter[/] [{WorkbenchColors.Muted.ToMarkup()}]to switch.[/]" + ]) + { Name = "NamespacesHelp" }; + + var root = HorizontalGridControl.Create() + .Column(c => c.Add(_table)) + .WithSplitterAfter(0) + .Column(c => c.Width(44).Add(_helpPane)) + .Build(); + + // Apply any data that arrived before controls were ready (NavigationView lazy init). + if (_pendingData is not null) + UpdateData(_pendingData); + + return root; + } + + /// + public void UpdateData(WorkbenchData data) + { + _pendingData = data; + if (_table is null) return; + + var selectedKey = _table.SelectedRow?.Tag as string; + + _table.ClearRows(); + foreach (var name in data.NamespaceNames.Order()) + { + _table.AddRow(new UITableRow([name]) { Tag = name }); + } + + if (selectedKey is not null) + { + RestoreSelection(selectedKey); + } + + if (_helpPane is null) return; + + var acc = WorkbenchColors.Accent.ToMarkup(); + var mut = WorkbenchColors.Muted.ToMarkup(); + var suc = WorkbenchColors.Success.ToMarkup(); + + var lines = new List + { + $"[{acc}][bold]SWITCH NAMESPACE[/][/]", + string.Empty, + $"[{mut}]Active[/] [{suc}]{data.Namespace}[/]", + $"[{mut}]Store[/] [{mut}]{data.EventStore}[/]", + $"[{mut}]Available[/] {data.NamespaceNames.Count}", + string.Empty, + $" [{mut}]Select a namespace and press[/] [bold]Enter[/]", + $" [{mut}]to make it the active namespace.[/]" + }; + + _helpPane.Text = string.Join('\n', lines); + } + + void RestoreSelection(string key) + { + if (_table is null) return; + + for (var i = 0; i < _table.Rows.Count; i++) + { + if (_table.Rows[i].Tag is string name && name == key) + { + _table.SelectedRowIndex = i; + return; + } + } + } + + void SwitchToSelected() + { + if (_table?.SelectedRow?.Tag is string nsName) + { + OnSwitch?.Invoke(nsName); + } + } +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/ObserversView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/ObserversView.cs new file mode 100644 index 0000000..e2d89d7 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/views/ObserversView.cs @@ -0,0 +1,300 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using SharpConsoleUI; +using SharpConsoleUI.Builders; +using SharpConsoleUI.Controls; +using UITableRow = SharpConsoleUI.Controls.TableRow; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Observers navigation item — sortable table with a filter prompt on the left and bordered detail pane on the right. +/// +public class ObserversView : IWorkbenchView +{ + TableControl? _table; + PanelControl? _detailPanel; + PromptControl? _filterPrompt; + ulong? _tailSequenceNumber; + string _currentFilter = string.Empty; + List _allItems = []; + WorkbenchData? _pendingData; + + /// + /// Gets or sets the callback invoked when the filter input gains or loses focus. + /// + public Action? OnFilterFocusChanged { get; set; } + + /// + /// Gets or sets the callback invoked when the user requests a replay of the selected observer. + /// + public Action? OnReplay { get; set; } + + /// + /// Gets or sets the callback invoked when the user requests a bulk replay of all checked observers. + /// + public Action>? OnReplayAll { get; set; } + + /// + /// Returns all observers that are currently checked (checkbox mode). + /// + /// A list of checked items. + public IReadOnlyList GetCheckedItems() => + [.. (_table?.GetCheckedRows() ?? []).Select(r => r.Tag).OfType()]; + + /// + public void Dispose() + { + _table?.Dispose(); + _detailPanel?.Dispose(); + _filterPrompt?.Dispose(); + } + + /// + public IWindowControl BuildContent(ConsoleWindowSystem windowSystem) + { + _table = Controls.Table() + .AddColumn("State", SharpConsoleUI.Layout.TextJustification.Left, 18) + .AddColumn("Id", SharpConsoleUI.Layout.TextJustification.Left, null) + .AddColumn("Type", SharpConsoleUI.Layout.TextJustification.Left, 16) + .AddColumn("Seq", SharpConsoleUI.Layout.TextJustification.Right, 12) + .Interactive() + .WithCheckboxMode() + .WithSorting() + .WithVerticalScrollbar(ScrollbarVisibility.Auto) + .OnSelectedRowChanged((_, _) => RefreshDetail()) + .WithName("ObserversTable") + .Build(); + + _filterPrompt = Controls.Prompt("Filter: ") + .WithHistory(true) + .WithTabCompleter((input, _) => GetCompletions(input)) + .OnInputChanged((_, text) => + { + _currentFilter = text ?? string.Empty; + RebuildFilteredRows(); + }) + .OnGotFocus((_, _) => OnFilterFocusChanged?.Invoke(true)) + .OnLostFocus((_, _) => OnFilterFocusChanged?.Invoke(false)) + .WithName("ObserversFilterPrompt") + .Build(); + + var leftPane = Controls.ScrollablePanel() + .AddControl(_filterPrompt) + .AddControl(_table) + .WithVerticalScroll(ScrollMode.None) + .WithName("ObserversLeftPane") + .Build(); + + _detailPanel = Controls.Panel() + .WithContent($"[{WorkbenchColors.Muted.ToMarkup()}]Select an observer.[/]") + .WithHeader(" OBSERVER ") + .Rounded() + .WithBorderColor(WorkbenchColors.Accent) + .WithPadding(1, 0, 1, 0) + .FillVertical() + .WithName("ObserverDetailPanel") + .Build(); + + var root = HorizontalGridControl.Create() + .Column(c => c.Add(leftPane)) + .WithSplitterAfter(0) + .Column(c => c.Width(45).Add(_detailPanel)) + .Build(); + + // Apply any data that arrived before controls were ready (NavigationView lazy init). + if (_pendingData is not null) + UpdateData(_pendingData); + + return root; + } + + /// + public void ActivateFilter(Window window) + { + if (_filterPrompt is not null) + { + window.FocusControl(_filterPrompt); + } + } + + /// + public void ClearFilter() + { + _currentFilter = string.Empty; + _filterPrompt?.SetInput(string.Empty); + RebuildFilteredRows(); + } + + /// + public void UpdateData(WorkbenchData data) + { + _pendingData = data; + if (_table is null) return; + + _tailSequenceNumber = data.TailSequenceNumber; + _allItems = [.. data.Observers.OrderBy(ObserverSortOrder).ThenBy(o => o.Id)]; + RebuildFilteredRows(); + } + + static int ObserverSortOrder(ObserverInformation o) => o.RunningState switch + { + ObserverRunningState.Disconnected => 0, + ObserverRunningState.Replaying => 1, + ObserverRunningState.Active => 2, + ObserverRunningState.Suspended => 3, + _ => 4 + }; + + static string GetObserverStateColor(ObserverInformation obs) => obs.RunningState switch + { + ObserverRunningState.Active => WorkbenchColors.Success.ToMarkup(), + ObserverRunningState.Replaying => WorkbenchColors.Warning.ToMarkup(), + ObserverRunningState.Disconnected => WorkbenchColors.Danger.ToMarkup(), + _ => WorkbenchColors.Muted.ToMarkup() + }; + + static string GetObserverIcon(ObserverInformation obs) => obs.RunningState switch + { + ObserverRunningState.Active => "●", + ObserverRunningState.Replaying => "▲", + ObserverRunningState.Disconnected => "⊘", + _ => "○" + }; + + IEnumerable GetCompletions(string input) => + [ + "state:active", + "state:replaying", + "state:disconnected", + "state:suspended", + "type:projection", + "type:reducer", + "type:reactor" + ]; + + bool MatchesFilter(ObserverInformation obs) + { + if (string.IsNullOrEmpty(_currentFilter)) return true; + + var f = _currentFilter; + + if (f.StartsWith("state:", StringComparison.OrdinalIgnoreCase)) + { + var state = f[6..]; + return obs.RunningState.ToString().Contains(state, StringComparison.OrdinalIgnoreCase); + } + + if (f.StartsWith("type:", StringComparison.OrdinalIgnoreCase)) + { + var type = f[5..]; + return obs.Type.ToString().Contains(type, StringComparison.OrdinalIgnoreCase); + } + + return obs.Id.Contains(f, StringComparison.OrdinalIgnoreCase); + } + + void RebuildFilteredRows() + { + if (_table is null) return; + + var selectedKey = (_table.SelectedRow?.Tag as ObserverInformation)?.Id; + + _table.ClearRows(); + foreach (var obs in _allItems.Where(MatchesFilter)) + { + _table.AddRow(new UITableRow(BuildObserverRow(obs)) { Tag = obs }); + } + + if (selectedKey is not null) + { + RestoreSelection(selectedKey); + } + + RefreshDetail(); + } + + void RestoreSelection(string key) + { + if (_table is null) return; + + for (var i = 0; i < _table.Rows.Count; i++) + { + if (_table.Rows[i].Tag is ObserverInformation obs && obs.Id == key) + { + _table.SelectedRowIndex = i; + return; + } + } + } + + string[] BuildObserverRow(ObserverInformation obs) + { + var stateColor = GetObserverStateColor(obs); + var icon = GetObserverIcon(obs); + + string seqCell; + if (obs.RunningState == ObserverRunningState.Replaying && _tailSequenceNumber.HasValue && _tailSequenceNumber.Value > 0) + { + var tail = _tailSequenceNumber.Value; + var current = obs.LastHandledEventSequenceNumber == ulong.MaxValue ? 0UL : obs.LastHandledEventSequenceNumber; + var pct = (int)Math.Min(100, current * 100 / tail); + var filledBars = pct / 10; + var bar = new string('█', filledBars) + new string('░', 10 - filledBars); + seqCell = $"[{WorkbenchColors.Warning.ToMarkup()}]{bar} {pct}%[/]"; + } + else + { + seqCell = obs.LastHandledEventSequenceNumber == ulong.MaxValue + ? $"[{WorkbenchColors.Muted.ToMarkup()}]—[/]" + : obs.LastHandledEventSequenceNumber.ToString("N0"); + } + + return + [ + $"[{stateColor}]{icon} {obs.RunningState}[/]", + obs.Id, + obs.Type.ToString(), + seqCell + ]; + } + + void RefreshDetail() + { + if (_table is null || _detailPanel is null) return; + + var row = _table.SelectedRow; + if (row?.Tag is not ObserverInformation obs) + { + _detailPanel.Content = $"[{WorkbenchColors.Muted.ToMarkup()}]Select an observer.[/]"; + return; + } + + var mut = WorkbenchColors.Muted.ToMarkup(); + var stateColor = GetObserverStateColor(obs); + + var lines = new List + { + $"[{mut}]Id[/] {obs.Id}", + $"[{mut}]Type[/] {obs.Type}", + $"[{mut}]State[/] [{stateColor}]{obs.RunningState}[/]", + $"[{mut}]Seq[/] {obs.LastHandledEventSequenceNumber}", + string.Empty, + $"[{mut}]Event Types:[/]" + }; + + foreach (var et in (obs.EventTypes ?? []).OrderBy(e => e.Id)) + { + lines.Add($" • {et.Id} gen {et.Generation}"); + } + + if (OnReplay is not null) + { + lines.Add(string.Empty); + lines.Add($"[{mut}]Press[/] [bold]R[/] [{mut}]to replay[/]"); + } + + _detailPanel.Content = string.Join('\n', lines); + } +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/OverviewView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/OverviewView.cs new file mode 100644 index 0000000..18d2039 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/views/OverviewView.cs @@ -0,0 +1,196 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using SharpConsoleUI; +using SharpConsoleUI.Builders; +using SharpConsoleUI.Controls; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Overview tab — server health card, observer stats, and attention items (failures + recommendations), +/// laid out as three bordered rounded panels side by side. +/// +public class OverviewView : IWorkbenchView +{ + readonly Queue _observerHistory = new(capacity: 10); + readonly Queue _eventRateHistory = new(capacity: 10); + PanelControl? _healthPanel; + PanelControl? _observerPanel; + PanelControl? _attentionPanel; + SparklineControl? _observerSparkline; + SparklineControl? _eventRateSparkline; + WorkbenchData? _pendingData; + + /// + public void Dispose() + { + _healthPanel?.Dispose(); + _observerPanel?.Dispose(); + _attentionPanel?.Dispose(); + _observerSparkline?.Dispose(); + _eventRateSparkline?.Dispose(); + } + + /// + public IWindowControl BuildContent(ConsoleWindowSystem windowSystem) + { + _healthPanel = Controls.Panel() + .WithContent("Loading...") + .WithHeader(" SERVER HEALTH ") + .Rounded() + .WithBorderColor(WorkbenchColors.Accent) + .WithPadding(1, 0, 1, 0) + .FillVertical() + .WithName("OverviewHealthPanel") + .Build(); + + _observerSparkline = new SparklineBuilder() + .WithHeight(3) + .WithBarColor(WorkbenchColors.Accent) + .WithTitle("observer count history", WorkbenchColors.Muted) + .WithData([0]) + .Build(); + + _eventRateSparkline = new SparklineBuilder() + .WithHeight(2) + .WithBarColor(WorkbenchColors.Success) + .WithTitle("events/cycle", WorkbenchColors.Muted) + .WithData([0]) + .Build(); + + _observerPanel = Controls.Panel() + .WithContent("Loading...") + .WithHeader(" OBSERVERS ") + .Rounded() + .WithBorderColor(WorkbenchColors.Accent) + .WithPadding(1, 0, 1, 0) + .FillVertical() + .WithName("OverviewObserversPanel") + .Build(); + + _attentionPanel = Controls.Panel() + .WithContent("Loading...") + .WithHeader(" ATTENTION ") + .Rounded() + .WithBorderColor(WorkbenchColors.Accent) + .WithPadding(1, 0, 1, 0) + .FillVertical() + .WithName("OverviewAttentionPanel") + .Build(); + + var root = Controls.HorizontalGrid() + .Column(col => col.Add(_healthPanel)) + .Column(col => col.Add(_observerPanel).Add(_observerSparkline).Add(_eventRateSparkline)) + .Column(col => col.Add(_attentionPanel)) + .Build(); + + // Apply any data that arrived before controls were ready (NavigationView lazy init). + if (_pendingData is not null) + UpdateData(_pendingData); + + return root; + } + + /// + public void UpdateData(WorkbenchData data) + { + _pendingData = data; + if (_healthPanel is null) return; + + var suc = WorkbenchColors.Success.ToMarkup(); + var dan = WorkbenchColors.Danger.ToMarkup(); + var mut = WorkbenchColors.Muted.ToMarkup(); + var war = WorkbenchColors.Warning.ToMarkup(); + + var connStatus = data.IsConnected + ? $"[{suc}]● Connected[/]" + : $"[{dan}]● Disconnected[/]"; + var version = data.ServerVersion is not null + ? $"[{mut}]v{data.ServerVersion}[/]" + : $"[{mut}]unknown[/]"; + var seq = data.TailSequenceNumber.HasValue + ? $"[bold]#{data.TailSequenceNumber.Value:N0}[/]" + : $"[{mut}]—[/]"; + + _healthPanel.Content = + $"[{mut}]Status[/] {connStatus}\n" + + $"[{mut}]Version[/] {version}\n" + + $"[{mut}]Tail seq[/] {seq}\n" + + $"[{mut}]Server[/] [{mut}]{data.ConnectionString}[/]"; + + string obsColor; + if (data.DisconnectedObservers > 0) obsColor = dan; + else if (data.ReplayingObservers > 0) obsColor = war; + else obsColor = suc; + + _observerPanel!.Content = + $"[{suc}]●[/] Active [bold]{data.ActiveObservers}[/]\n" + + $"[{war}]▲[/] Replaying [bold]{data.ReplayingObservers}[/]\n" + + $"[{mut}]○[/] Suspended [bold]{data.SuspendedObservers}[/]\n" + + $"[{dan}]⊘[/] Disconnected [bold]{data.DisconnectedObservers}[/]\n" + + $"[{mut}]━[/] Total [{obsColor}][bold]{data.Observers.Count}[/][/]"; + + UpdateObserverSparkline(data.Observers.Count); + + var hasAttention = data.FailedPartitions.Count > 0 || data.Recommendations.Count > 0; + if (hasAttention) + { + _attentionPanel!.BorderColor = WorkbenchColors.Warning; + } + else + { + _attentionPanel!.BorderColor = WorkbenchColors.Accent; + } + + var attentionLines = new List(); + + if (data.FailedPartitions.Count > 0) + { + attentionLines.Add($"[{dan}]⚠[/] [bold]{data.FailedPartitions.Count}[/] failed partition{(data.FailedPartitions.Count == 1 ? string.Empty : "s")} [{mut}]→ press 3[/]"); + } + else + { + attentionLines.Add($"[{suc}]✓[/] No failed partitions"); + } + + if (data.Recommendations.Count > 0) + { + attentionLines.Add($"[{war}]![/] [bold]{data.Recommendations.Count}[/] pending recommendation{(data.Recommendations.Count == 1 ? string.Empty : "s")} [{mut}]→ press 5[/]"); + } + else + { + attentionLines.Add($"[{suc}]✓[/] No pending recommendations"); + } + + _attentionPanel!.Content = string.Join('\n', attentionLines); + } + + /// + /// Updates the event-rate sparkline with the number of new events observed this refresh cycle. + /// + /// The count of new events appended since the last cycle. + public void UpdateEventDelta(int newEventsThisCycle) + { + _eventRateHistory.Enqueue(newEventsThisCycle); + while (_eventRateHistory.Count > 10) + { + _eventRateHistory.Dequeue(); + } + + _eventRateSparkline?.SetDataPoints(_eventRateHistory); + } + + void UpdateObserverSparkline(int totalObservers) + { + if (_observerSparkline is null) return; + + _observerHistory.Enqueue(totalObservers); + while (_observerHistory.Count > 10) + { + _observerHistory.Dequeue(); + } + + _observerSparkline.SetDataPoints(_observerHistory); + } +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/ProjectionsView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/ProjectionsView.cs new file mode 100644 index 0000000..512aa22 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/views/ProjectionsView.cs @@ -0,0 +1,203 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using SharpConsoleUI; +using SharpConsoleUI.Builders; +using SharpConsoleUI.Controls; +using UITableRow = SharpConsoleUI.Controls.TableRow; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Projections navigation item — filterable table of projection definitions with declaration preview in the detail pane. +/// +public class ProjectionsView : IWorkbenchView +{ + TableControl? _table; + MarkupControl? _detailPane; + PromptControl? _filterPrompt; + IReadOnlyDictionary _declarations = new Dictionary(); + string _currentFilter = string.Empty; + List _allItems = []; + WorkbenchData? _pendingData; + + /// + /// Gets or sets the callback invoked when the filter input gains or loses focus. + /// + public Action? OnFilterFocusChanged { get; set; } + + /// + public void Dispose() + { + _table?.Dispose(); + _detailPane?.Dispose(); + _filterPrompt?.Dispose(); + } + + /// + public IWindowControl BuildContent(ConsoleWindowSystem windowSystem) + { + _table = Controls.Table() + .AddColumn("Identifier", SharpConsoleUI.Layout.TextJustification.Left, null) + .AddColumn("Read Model", SharpConsoleUI.Layout.TextJustification.Left, 35) + .Interactive() + .WithSorting() + .WithVerticalScrollbar(ScrollbarVisibility.Auto) + .OnSelectedRowChanged((_, _) => RefreshDetail()) + .WithName("ProjectionsTable") + .Build(); + + _filterPrompt = Controls.Prompt("Filter: ") + .WithHistory(true) + .OnInputChanged((_, text) => + { + _currentFilter = text ?? string.Empty; + RebuildFilteredRows(); + }) + .OnGotFocus((_, _) => OnFilterFocusChanged?.Invoke(true)) + .OnLostFocus((_, _) => OnFilterFocusChanged?.Invoke(false)) + .WithName("ProjectionsFilterPrompt") + .Build(); + + var leftPane = Controls.ScrollablePanel() + .AddControl(_filterPrompt) + .AddControl(_table) + .WithVerticalScroll(ScrollMode.None) + .WithName("ProjectionsLeftPane") + .Build(); + + _detailPane = new MarkupControl([$"[{WorkbenchColors.Muted.ToMarkup()}]Select a projection.[/]"]) + { + Name = "ProjectionDetail", + Wrap = true + }; + + var detailScroll = Controls.ScrollablePanel() + .AddControl(_detailPane) + .WithVerticalScroll(ScrollMode.Scroll) + .WithPadding(1, 0, 1, 0) + .Build(); + + var root = HorizontalGridControl.Create() + .Column(c => c.Add(leftPane)) + .WithSplitterAfter(0) + .Column(c => c.Width(55).Add(detailScroll)) + .Build(); + + // Apply any data that arrived before controls were ready (NavigationView lazy init). + if (_pendingData is not null) + UpdateData(_pendingData); + + return root; + } + + /// + public void ActivateFilter(Window window) + { + if (_filterPrompt is not null) + { + window.FocusControl(_filterPrompt); + } + } + + /// + public void ClearFilter() + { + _currentFilter = string.Empty; + _filterPrompt?.SetInput(string.Empty); + RebuildFilteredRows(); + } + + /// + public void UpdateData(WorkbenchData data) + { + _pendingData = data; + if (_table is null) return; + + _declarations = data.ProjectionDeclarations; + _allItems = [.. data.ProjectionDefinitions.OrderBy(d => d.Identifier)]; + RebuildFilteredRows(); + } + + bool MatchesFilter(ProjectionDefinition def) + { + if (string.IsNullOrEmpty(_currentFilter)) return true; + + return def.Identifier.Contains(_currentFilter, StringComparison.OrdinalIgnoreCase); + } + + void RebuildFilteredRows() + { + if (_table is null) return; + + var selectedKey = (_table.SelectedRow?.Tag as ProjectionDefinition)?.Identifier; + + _table.ClearRows(); + foreach (var def in _allItems.Where(MatchesFilter)) + { + _table.AddRow(new UITableRow([def.Identifier, def.ReadModel ?? string.Empty]) { Tag = def }); + } + + if (selectedKey is not null) + { + RestoreSelection(selectedKey); + } + + RefreshDetail(); + } + + void RestoreSelection(string key) + { + if (_table is null) return; + + for (var i = 0; i < _table.Rows.Count; i++) + { + if (_table.Rows[i].Tag is ProjectionDefinition def && def.Identifier == key) + { + _table.SelectedRowIndex = i; + return; + } + } + } + + void RefreshDetail() + { + if (_table is null || _detailPane is null) return; + + if (_table.SelectedRow?.Tag is not ProjectionDefinition def) + { + _detailPane.Text = $"[{WorkbenchColors.Muted.ToMarkup()}]Select a projection.[/]"; + return; + } + + var acc = WorkbenchColors.Accent.ToMarkup(); + var mut = WorkbenchColors.Muted.ToMarkup(); + var suc = WorkbenchColors.Success.ToMarkup(); + + var lines = new List + { + $"[{acc}][bold]PROJECTION[/][/]", + string.Empty, + $" [{mut}]Identifier[/] {def.Identifier}", + $" [{mut}]Read Model[/] {def.ReadModel ?? "—"}", + $" [{mut}]Active[/] [{(def.IsActive ? suc : mut)}]{(def.IsActive ? "Yes" : "No")}[/]", + $" [{mut}]Rewindable[/] [{(def.IsRewindable ? suc : mut)}]{(def.IsRewindable ? "Yes" : "No")}[/]", + string.Empty, + $" [{acc}]Declaration (preview):[/]" + }; + + if (_declarations.TryGetValue(def.Identifier, out var declaration) && !string.IsNullOrEmpty(declaration)) + { + foreach (var line in declaration.Split('\n').Take(40)) + { + lines.Add($" [{mut}]{line.TrimEnd()}[/]"); + } + } + else + { + lines.Add($" [{mut}](no declaration available)[/]"); + } + + _detailPane.Text = string.Join('\n', lines); + } +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/ReadModelsView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/ReadModelsView.cs new file mode 100644 index 0000000..03827f1 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/views/ReadModelsView.cs @@ -0,0 +1,304 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using SharpConsoleUI; +using SharpConsoleUI.Builders; +using SharpConsoleUI.Controls; +using UITableRow = SharpConsoleUI.Controls.TableRow; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Read Models navigation item — filterable table of read model definitions with metadata in the detail pane. +/// Pressing Enter on a selected row opens a detail overlay showing definition info and live instances. +/// +public class ReadModelsView : IWorkbenchView +{ + ConsoleWindowSystem? _windowSystem; + TableControl? _table; + MarkupControl? _detailPane; + PromptControl? _filterPrompt; + string _currentFilter = string.Empty; + List _allItems = []; + WorkbenchData? _pendingData; + + /// + /// Gets or sets the callback invoked when the filter input gains or loses focus. + /// + public Action? OnFilterFocusChanged { get; set; } + + /// + /// Gets or sets the callback invoked when the user activates a read model row (Enter). + /// Receives the container name and a cancellation token; returns a + /// snapshot with read model instances populated. + /// + public Func>? OnFetchInstances { get; set; } + + /// + public void Dispose() + { + _table?.Dispose(); + _detailPane?.Dispose(); + _filterPrompt?.Dispose(); + } + + /// + public IWindowControl BuildContent(ConsoleWindowSystem windowSystem) + { + _windowSystem = windowSystem; + + _table = Controls.Table() + .AddColumn("Container", SharpConsoleUI.Layout.TextJustification.Left, null) + .AddColumn("Owner", SharpConsoleUI.Layout.TextJustification.Left, 12) + .AddColumn("Source", SharpConsoleUI.Layout.TextJustification.Left, 14) + .Interactive() + .WithSorting() + .WithVerticalScrollbar(ScrollbarVisibility.Auto) + .OnSelectedRowChanged((_, _) => RefreshDetail()) + .WithName("ReadModelsTable") + .Build(); + + _filterPrompt = Controls.Prompt("Filter: ") + .WithHistory(true) + .WithTabCompleter((input, _) => GetCompletions()) + .OnInputChanged((_, text) => + { + _currentFilter = text ?? string.Empty; + RebuildFilteredRows(); + }) + .OnGotFocus((_, _) => OnFilterFocusChanged?.Invoke(true)) + .OnLostFocus((_, _) => OnFilterFocusChanged?.Invoke(false)) + .WithName("ReadModelsFilterPrompt") + .Build(); + + var leftPane = Controls.ScrollablePanel() + .AddControl(_filterPrompt) + .AddControl(_table) + .WithVerticalScroll(ScrollMode.None) + .WithName("ReadModelsLeftPane") + .Build(); + + _detailPane = new MarkupControl([$"[{WorkbenchColors.Muted.ToMarkup()}]Select a read model.[/]"]) + { + Name = "ReadModelDetail", + Wrap = true + }; + + var detailScroll = Controls.ScrollablePanel() + .AddControl(_detailPane) + .WithVerticalScroll(ScrollMode.Scroll) + .WithPadding(1, 0, 1, 0) + .Build(); + + var root = HorizontalGridControl.Create() + .Column(c => c.Add(leftPane)) + .WithSplitterAfter(0) + .Column(c => c.Width(50).Add(detailScroll)) + .Build(); + + // Apply any data that arrived before controls were ready (NavigationView lazy init). + if (_pendingData is not null) + UpdateData(_pendingData); + + return root; + } + + /// + public void ActivateFilter(Window window) + { + if (_filterPrompt is not null) + { + window.FocusControl(_filterPrompt); + } + } + + /// + public void ClearFilter() + { + _currentFilter = string.Empty; + _filterPrompt?.SetInput(string.Empty); + RebuildFilteredRows(); + } + + /// + public void UpdateData(WorkbenchData data) + { + _pendingData = data; + if (_table is null) return; + + _allItems = [.. data.ReadModelDefinitions.OrderBy(d => d.ContainerName)]; + RebuildFilteredRows(); + } + + /// + /// Opens a detail overlay for the currently selected read model row, if any. + /// No-op when no row is selected. + /// + public void OpenSelectedDetailOverlay() + { + if (_table?.SelectedRow?.Tag is WorkbenchReadModel rm) + { + OpenDetailOverlay(rm); + } + } + + /// + /// Opens a detail overlay for the given read model, fetching instances if is wired. + /// + /// The read model to display. + public void OpenDetailOverlay(WorkbenchReadModel rm) + { + if (_windowSystem is null) return; + + var acc = WorkbenchColors.Accent.ToMarkup(); + var mut = WorkbenchColors.Muted.ToMarkup(); + var suc = WorkbenchColors.Success.ToMarkup(); + var queryableColor = rm.IsQueryable ? suc : mut; + + var infoContent = string.Join( + "\n", + $"[{acc}][bold]{rm.ContainerName}[/][/]", + string.Empty, + $"[{mut}]Container[/] {rm.ContainerName}", + $"[{mut}]Display Name[/] {rm.DisplayName}", + $"[{mut}]Owner[/] {rm.Owner}", + $"[{mut}]Source[/] {rm.Source}", + $"[{mut}]Queryable[/] [{queryableColor}]{(rm.IsQueryable ? "Yes" : "No")}[/]", + $"[{mut}]Identifier[/] {rm.Identifier}"); + + var instancesContent = BuildInstancesContent(rm); + + List<(string TabName, string Content)> tabs = + [ + ("Info", infoContent), + ("Instances", instancesContent) + ]; + + var overlay = new DetailOverlayWindow(); + var window = overlay.Build(_windowSystem, $" {rm.ContainerName} ", tabs, []); + _windowSystem.AddWindow(window, activateWindow: true); + } + + static IEnumerable GetCompletions() => + [ + "owner:client", + "owner:server" + ]; + + bool MatchesFilter(WorkbenchReadModel rm) + { + if (string.IsNullOrEmpty(_currentFilter)) return true; + + var f = _currentFilter; + + if (f.StartsWith("owner:", StringComparison.OrdinalIgnoreCase)) + { + var owner = f[6..]; + return rm.Owner.Contains(owner, StringComparison.OrdinalIgnoreCase); + } + + return rm.ContainerName.Contains(f, StringComparison.OrdinalIgnoreCase) || + rm.DisplayName.Contains(f, StringComparison.OrdinalIgnoreCase); + } + + void RebuildFilteredRows() + { + if (_table is null) return; + + var selectedKey = (_table.SelectedRow?.Tag as WorkbenchReadModel)?.ContainerName; + + _table.ClearRows(); + foreach (var rm in _allItems.Where(MatchesFilter)) + { + _table.AddRow(new UITableRow([rm.ContainerName, rm.Owner, rm.Source]) { Tag = rm }); + } + + if (selectedKey is not null) + { + RestoreSelection(selectedKey); + } + + RefreshDetail(); + } + + void RestoreSelection(string key) + { + if (_table is null) return; + + for (var i = 0; i < _table.Rows.Count; i++) + { + if (_table.Rows[i].Tag is WorkbenchReadModel rm && rm.ContainerName == key) + { + _table.SelectedRowIndex = i; + return; + } + } + } + + void RefreshDetail() + { + if (_table is null || _detailPane is null) return; + + if (_table.SelectedRow?.Tag is not WorkbenchReadModel rm) + { + _detailPane.Text = $"[{WorkbenchColors.Muted.ToMarkup()}]Select a read model.[/]"; + return; + } + + var acc = WorkbenchColors.Accent.ToMarkup(); + var mut = WorkbenchColors.Muted.ToMarkup(); + var suc = WorkbenchColors.Success.ToMarkup(); + var queryableColor = rm.IsQueryable ? suc : mut; + + _detailPane.Text = string.Join( + "\n", + $"[{acc}][bold]READ MODEL[/][/]", + string.Empty, + $" [{mut}]Container[/] {rm.ContainerName}", + $" [{mut}]Display Name[/] {rm.DisplayName}", + $" [{mut}]Owner[/] {rm.Owner}", + $" [{mut}]Source[/] {rm.Source}", + $" [{mut}]Queryable[/] [{queryableColor}]{(rm.IsQueryable ? "Yes" : "No")}[/]", + $" [{mut}]Identifier[/] {rm.Identifier}", + string.Empty, + $"[{mut}]Press[/] [bold]Enter[/] [{mut}]to view instances[/]"); + } + + string BuildInstancesContent(WorkbenchReadModel rm) + { + var mut = WorkbenchColors.Muted.ToMarkup(); + var dan = WorkbenchColors.Danger.ToMarkup(); + + if (OnFetchInstances is null) + { + return $"[{mut}](No instance loader configured)[/]"; + } + + try + { + var data = OnFetchInstances(rm.ContainerName, CancellationToken.None) + .GetAwaiter().GetResult(); + + if (data.ReadModelInstancesError is not null) + { + return $"[{dan}]Error: {data.ReadModelInstancesError}[/]"; + } + + if (data.ReadModelInstances.Count == 0) + { + return $"[{mut}](No instances found)[/]"; + } + + var separator = $"\n[{mut}]{new string('─', 60)}[/]\n"; + var total = data.ReadModelInstancesTotalCount; + var shown = data.ReadModelInstances.Count; + var header = $"[{mut}]Showing {shown} of {total} instance{(total == 1 ? string.Empty : "s")}[/]\n"; + + return header + separator + string.Join(separator, data.ReadModelInstances); + } + catch (Exception ex) + { + return $"[{dan}]Error fetching instances: {ex.Message}[/]"; + } + } +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/RecommendationsView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/RecommendationsView.cs new file mode 100644 index 0000000..b8447e6 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/views/RecommendationsView.cs @@ -0,0 +1,159 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using SharpConsoleUI; +using SharpConsoleUI.Builders; +using SharpConsoleUI.Controls; +using UITableRow = SharpConsoleUI.Controls.TableRow; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Recommendations tab — table of pending recommendations with apply/ignore actions in the bordered detail pane. +/// +public class RecommendationsView : IWorkbenchView +{ + TableControl? _table; + PanelControl? _detailPanel; + WorkbenchData? _pendingData; + + /// + /// Gets or sets the callback invoked when the user applies a recommendation. + /// + public Action? OnApply { get; set; } + + /// + /// Gets or sets the callback invoked when the user ignores a recommendation. + /// + public Action? OnIgnore { get; set; } + + /// + /// Gets or sets the callback invoked when the user requests a bulk apply of all checked recommendations. + /// + public Action>? OnApplyAll { get; set; } + + /// + /// Gets or sets the callback invoked when the user requests a bulk ignore of all checked recommendations. + /// + public Action>? OnIgnoreAll { get; set; } + + /// + /// Returns all recommendations that are currently checked (checkbox mode). + /// + /// A list of checked items. + public IReadOnlyList GetCheckedItems() => + [.. (_table?.GetCheckedRows() ?? []).Select(r => r.Tag).OfType()]; + + /// + public void Dispose() + { + _table?.Dispose(); + _detailPanel?.Dispose(); + } + + /// + public IWindowControl BuildContent(ConsoleWindowSystem windowSystem) + { + _table = Controls.Table() + .AddColumn("Name", SharpConsoleUI.Layout.TextJustification.Left, null) + .AddColumn("Type", SharpConsoleUI.Layout.TextJustification.Left, 20) + .Interactive() + .WithCheckboxMode() + .WithFiltering() + .WithSorting() + .WithVerticalScrollbar(ScrollbarVisibility.Auto) + .OnSelectedRowChanged((_, _) => RefreshDetail()) + .WithName("RecommendationsTable") + .Build(); + + _detailPanel = Controls.Panel() + .WithContent($"[{WorkbenchColors.Muted.ToMarkup()}]Select a recommendation.[/]") + .WithHeader(" RECOMMENDATION ") + .Rounded() + .WithBorderColor(WorkbenchColors.Warning) + .WithPadding(1, 0, 1, 0) + .FillVertical() + .WithName("RecommendationDetailPanel") + .Build(); + + var root = HorizontalGridControl.Create() + .Column(c => c.Add(_table)) + .WithSplitterAfter(0) + .Column(c => c.Width(50).Add(_detailPanel)) + .Build(); + + // Apply any data that arrived before controls were ready (NavigationView lazy init). + if (_pendingData is not null) + UpdateData(_pendingData); + + return root; + } + + /// + public void UpdateData(WorkbenchData data) + { + _pendingData = data; + if (_table is null) return; + + var selectedKey = (_table.SelectedRow?.Tag as Recommendation)?.Id.ToString(); + + _table.ClearRows(); + foreach (var rec in data.Recommendations) + { + _table.AddRow(new UITableRow([rec.Name ?? rec.Id.ToString(), rec.Type ?? "—"]) { Tag = rec }); + } + + if (selectedKey is not null) + { + RestoreSelection(selectedKey); + } + + RefreshDetail(); + } + + void RestoreSelection(string key) + { + if (_table is null) return; + + for (var i = 0; i < _table.Rows.Count; i++) + { + if (_table.Rows[i].Tag is Recommendation rec && rec.Id.ToString() == key) + { + _table.SelectedRowIndex = i; + return; + } + } + } + + void RefreshDetail() + { + if (_table is null || _detailPanel is null) return; + + if (_table.SelectedRow?.Tag is not Recommendation rec) + { + _detailPanel.Content = $"[{WorkbenchColors.Muted.ToMarkup()}]Select a recommendation.[/]"; + return; + } + + var mut = WorkbenchColors.Muted.ToMarkup(); + + var lines = new List + { + $"[{mut}]Name[/] {rec.Name ?? rec.Id.ToString()}", + $"[{mut}]Type[/] {rec.Type ?? "—"}", + }; + + if (!string.IsNullOrEmpty(rec.Description)) + { + lines.Add(string.Empty); + lines.Add($"[{mut}]Description:[/]"); + lines.Add($" {rec.Description}"); + } + + lines.Add(string.Empty); + if (OnApply is not null) lines.Add($"[{mut}]Press[/] [bold]A[/] [{mut}]to apply[/]"); + if (OnIgnore is not null) lines.Add($"[{mut}]Press[/] [bold]I[/] [{mut}]to ignore[/]"); + + _detailPanel.Content = string.Join('\n', lines); + } +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/windows/DetailOverlayWindow.cs b/Source/Cli/Commands/Chronicle/Workbench/windows/DetailOverlayWindow.cs new file mode 100644 index 0000000..6a29364 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/windows/DetailOverlayWindow.cs @@ -0,0 +1,84 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using SharpConsoleUI; +using SharpConsoleUI.Builders; +using SharpConsoleUI.Controls; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Builds a modal overlay window for displaying item detail in tabbed panes with optional action buttons. +/// +public class DetailOverlayWindow +{ + Window? _window; + + /// + /// Builds a detail overlay window with the specified title, tabbed content, and action buttons. + /// + /// The SharpConsoleUI window system used to close the window on Escape. + /// The overlay window title. + /// + /// A list of (TabName, Content) tuples. Each tab displays its markup content in a scrollable panel. + /// + /// + /// A list of (Label, Execute) tuples. Each entry produces a toolbar button that invokes the callback. + /// + /// A configured ready to be passed to windowSystem.AddWindow. + public Window Build( + ConsoleWindowSystem windowSystem, + string title, + IReadOnlyList<(string TabName, string Content)> tabs, + IReadOnlyList<(string Label, Action Execute)> actions) + { + var tabBuilder = Controls.TabControl(); + + foreach (var (tabName, content) in tabs) + { + var markup = new MarkupControl([content]) { Wrap = true }; + var scrollPane = Controls.ScrollablePanel() + .AddControl(markup) + .WithVerticalScroll(ScrollMode.Scroll) + .WithPadding(1, 1, 1, 1) + .Build(); + + tabBuilder.AddTab(tabName, scrollPane); + } + + var tabControl = tabBuilder.Fill().Build(); + + var toolbarBuilder = Controls.Toolbar() + .WithBelowLine(false) + .WithAboveLine(true) + .WithAboveLineColor(WorkbenchColors.Muted); + + foreach (var (label, execute) in actions) + { + toolbarBuilder.AddButton(label, (_, _) => execute()); + } + + var toolbar = toolbarBuilder.Build(); + + _window = new WindowBuilder(windowSystem) + .WithTitle(title) + .WithColors(WorkbenchColors.Foreground, WorkbenchColors.Background) + .Centered() + .WithSize(120, 35) + .AddControl(tabControl) + .AddControl(toolbar) + .OnKeyPressed(HandleKeyPress) + .Build(); + + return _window; + + void HandleKeyPress(object? sender, KeyPressedEventArgs e) + { + if (e.KeyInfo.Key == ConsoleKey.Escape && _window is not null) + { + windowSystem.CloseWindow(_window, activateParent: true, force: false); + e.Handled = true; + } + } + } +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/windows/MainWindow.cs b/Source/Cli/Commands/Chronicle/Workbench/windows/MainWindow.cs new file mode 100644 index 0000000..70170d7 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/windows/MainWindow.cs @@ -0,0 +1,1259 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Chronicle.Contracts.Jobs; +using Cratis.Chronicle.Contracts.Observation; +using Cratis.Chronicle.Contracts.Recommendations; +using SharpConsoleUI; +using SharpConsoleUI.Builders; +using SharpConsoleUI.Controls; +using SColor = SharpConsoleUI.Color; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// The main full-screen workbench window: navigation side pane, content area, live event log strip, and status bar. +/// +/// The SharpConsoleUI window system. +/// The Chronicle data service. +/// The workbench settings. +/// The Chronicle gRPC service clients. +/// Pre-fetched snapshot used to populate all views before the first frame is rendered. +public class MainWindow( + ConsoleWindowSystem windowSystem, + WorkbenchDataService dataService, + WorkbenchSettings settings, + IServices services, + WorkbenchData initialData) +{ + /// Navigation item index for Overview. + const int IndexOverview = 0; + + /// Navigation item index for Observers. + const int IndexObservers = 1; + + /// Navigation item index for Failures. + const int IndexFailures = 2; + + /// Navigation item index for Jobs. + const int IndexJobs = 3; + + /// Navigation item index for Recommendations. + const int IndexRecommendations = 4; + + /// Navigation item index for Event Types. + const int IndexEventTypes = 5; + + /// Navigation item index for Projections. + const int IndexProjections = 6; + + /// Navigation item index for Read Models. + const int IndexReadModels = 7; + + /// Navigation item index for Event Log. + const int IndexEventLog = 8; + + /// Navigation item index for Event Stores. + const int IndexEventStores = 9; + + /// Navigation item index for Namespaces. + const int IndexNamespaces = 10; + + /// Maximum number of raw event stream lines retained in the ring buffer for filter replay. + const int StreamBufferCapacity = 500; + + /// Color palette for coloring event type names in the live stream by a hash of their ID. + static readonly SColor[] _streamPalette = + [ + new(122, 162, 247, 255), // electric blue (Accent) + new(115, 218, 118, 255), // vivid green (Success) + new(224, 175, 104, 255), // amber (Warning) + new(187, 154, 247, 255), // mauve/purple + new(42, 195, 222, 255), // teal/cyan + new(247, 118, 142, 255), // coral-red (Danger) + new(255, 199, 119, 255), // gold + new(137, 220, 235, 255), // sky blue + new(166, 209, 137, 255), // sage green + new(238, 153, 160, 255), // rose + ]; + + /// + /// View instances — created once, reused across refreshes. + /// Order must match the nav index constants above. + /// + readonly IWorkbenchView[] _views = + [ + new OverviewView(), + new ObserversView(), + new FailedPartitionsView(), + new JobsView(), + new RecommendationsView(), + new EventTypesView(), + new ProjectionsView(), + new ReadModelsView(), + new EventLogView(), + new EventStoresView(), + new NamespacesView() + ]; + + readonly object _dataLock = new(); + readonly Queue _eventStreamBuffer = new(); + string? _activeEventStore; + string? _activeNamespace; + WorkbenchData? _currentData; + bool _eventStreamVisible = true; + ulong _lastEventStreamSeq; + bool _wasDisconnected; + string _streamFilter = string.Empty; + (string Description, Func Execute)? _pendingAction; + bool _textInputFocused; + NavigationView? _navView; + StatusBarControl? _statusBar; + MarkupControl? _titleBar; + LogViewerControl? _eventStreamLog; + NavigationItem? _observersItem; + NavigationItem? _failuresItem; + NavigationItem? _recommendationsItem; + + /// + /// Builds the main window with all controls and the async update thread. + /// + /// The constructed . + public Window Build() + { + WireViewCallbacks(); + + var navView = BuildNavigationView(); + var logViewer = BuildEventStreamLogViewer(); + var streamFilterPrompt = BuildStreamFilterPrompt(); + var statusBar = BuildStatusBar(); + + var splitterBar = Controls.HorizontalSplitter() + .WithMinHeightAbove(6) + .WithMinHeightBelow(4) + .WithControls(navView, logViewer) + .Build(); + + // Populate all views with the pre-fetched snapshot before the first frame is rendered, + // so every navigation pane has real data from the moment the window opens. + _currentData = initialData; + PushDataToViews(initialData); + UpdateStatusBar(initialData); + UpdateNavBadges(initialData); + + return new WindowBuilder(windowSystem) + .WithTitle("Chronicle Workbench") + .Maximized() + .WithColors(WorkbenchColors.Foreground, WorkbenchColors.Background) + .Borderless() // cspell:ignore Borderless + .HideTitle() + .HideCloseButton() + .AddControl(BuildTitleBar()) + .AddControl(navView) + .AddControl(splitterBar) + .AddControl(streamFilterPrompt) + .AddControl(logViewer) + .AddControl(statusBar) + .OnKeyPressed((_, e) => HandleKeyPress(e)) + .WithAsyncWindowThread(RunDataRefreshLoop) + .Build(); + } + + static string TruncateId(string s) => s.Length <= 40 ? s : s[..37] + "…"; + + static SColor EventTypeColor(string eventTypeId) + { + var hash = Math.Abs(eventTypeId.GetHashCode()); + return _streamPalette[hash % _streamPalette.Length]; + } + + /// + /// Extracts the host:port portion from a Chronicle connection string, + /// stripping credentials and query parameters so the title bar shows only the endpoint. + /// + /// The raw connection string, possibly including credentials. + /// A clean chronicle://host:port string, or the original if parsing fails. + static string ExtractHostFromConnectionString(string connectionString) + { + const string scheme = "chronicle://"; + if (!connectionString.StartsWith(scheme, StringComparison.OrdinalIgnoreCase)) + { + return connectionString; + } + + var afterScheme = connectionString[scheme.Length..]; + + var queryStart = afterScheme.IndexOf('?'); + if (queryStart >= 0) + { + afterScheme = afterScheme[..queryStart]; + } + + var atSign = afterScheme.IndexOf('@'); + if (atSign >= 0) + { + afterScheme = afterScheme[(atSign + 1)..]; + } + + return $"chronicle://{afterScheme}"; + } + + static NavigationItem? FindItemByText(IReadOnlyList items, string text) + { + foreach (var item in items) + { + if (item.Text == text) return item; + } + + return null; + } + + void WireViewCallbacks() + { + if (_views[IndexObservers] is ObserversView ov) + { + ov.OnReplay = obs => ExecuteAction( + $"Replay observer '{TruncateId(obs.Id)}'", + () => services.Observers.Replay(new Replay + { + EventStore = _activeEventStore ?? settings.ResolveEventStore(), + Namespace = _activeNamespace ?? settings.ResolveNamespace(), + ObserverId = obs.Id, + EventSequenceId = CliDefaults.DefaultEventSequenceId + })); + + ov.OnReplayAll = observers => ConfirmThenExecuteAll( + $"Replay {observers.Count} observer{(observers.Count == 1 ? string.Empty : "s")}", + observers, + obs => services.Observers.Replay(new Replay + { + EventStore = _activeEventStore ?? settings.ResolveEventStore(), + Namespace = _activeNamespace ?? settings.ResolveNamespace(), + ObserverId = obs.Id, + EventSequenceId = CliDefaults.DefaultEventSequenceId + })); + } + + if (_views[IndexFailures] is FailedPartitionsView fv) + { + fv.OnRetryPartition = fp => ExecuteAction( + $"Retry partition '{fp.Partition}'", + () => services.Observers.RetryPartition(new RetryPartition + { + EventStore = _activeEventStore ?? settings.ResolveEventStore(), + Namespace = _activeNamespace ?? settings.ResolveNamespace(), + ObserverId = fp.ObserverId, + Partition = fp.Partition, + EventSequenceId = CliDefaults.DefaultEventSequenceId + })); + + fv.OnReplayPartition = fp => ExecuteAction( + $"Replay partition '{fp.Partition}'", + () => services.Observers.ReplayPartition(new ReplayPartition + { + EventStore = _activeEventStore ?? settings.ResolveEventStore(), + Namespace = _activeNamespace ?? settings.ResolveNamespace(), + ObserverId = fp.ObserverId, + Partition = fp.Partition, + EventSequenceId = CliDefaults.DefaultEventSequenceId + })); + + fv.OnRetryAll = partitions => ConfirmThenExecuteAll( + $"Retry {partitions.Count} partition{(partitions.Count == 1 ? string.Empty : "s")}", + partitions, + fp => services.Observers.RetryPartition(new RetryPartition + { + EventStore = _activeEventStore ?? settings.ResolveEventStore(), + Namespace = _activeNamespace ?? settings.ResolveNamespace(), + ObserverId = fp.ObserverId, + Partition = fp.Partition, + EventSequenceId = CliDefaults.DefaultEventSequenceId + })); + + fv.OnReplayAll = partitions => ConfirmThenExecuteAll( + $"Replay {partitions.Count} partition{(partitions.Count == 1 ? string.Empty : "s")}", + partitions, + fp => services.Observers.ReplayPartition(new ReplayPartition + { + EventStore = _activeEventStore ?? settings.ResolveEventStore(), + Namespace = _activeNamespace ?? settings.ResolveNamespace(), + ObserverId = fp.ObserverId, + Partition = fp.Partition, + EventSequenceId = CliDefaults.DefaultEventSequenceId + })); + } + + if (_views[IndexJobs] is JobsView jv) + { + jv.OnStopJob = job => ExecuteAction( + $"Stop job '{TruncateId(job.Type ?? job.Id.ToString())}'", + () => services.Jobs.Stop(new StopJob + { + EventStore = _activeEventStore ?? settings.ResolveEventStore(), + Namespace = _activeNamespace ?? settings.ResolveNamespace(), + JobId = job.Id + })); + + jv.OnResumeJob = job => ExecuteAction( + $"Resume job '{TruncateId(job.Type ?? job.Id.ToString())}'", + () => services.Jobs.Resume(new ResumeJob + { + EventStore = _activeEventStore ?? settings.ResolveEventStore(), + Namespace = _activeNamespace ?? settings.ResolveNamespace(), + JobId = job.Id + })); + + jv.OnStopAll = jobs => ConfirmThenExecuteAll( + $"Stop {jobs.Count} job{(jobs.Count == 1 ? string.Empty : "s")}", + jobs, + job => services.Jobs.Stop(new StopJob + { + EventStore = _activeEventStore ?? settings.ResolveEventStore(), + Namespace = _activeNamespace ?? settings.ResolveNamespace(), + JobId = job.Id + })); + + jv.OnResumeAll = jobs => ConfirmThenExecuteAll( + $"Resume {jobs.Count} job{(jobs.Count == 1 ? string.Empty : "s")}", + jobs, + job => services.Jobs.Resume(new ResumeJob + { + EventStore = _activeEventStore ?? settings.ResolveEventStore(), + Namespace = _activeNamespace ?? settings.ResolveNamespace(), + JobId = job.Id + })); + } + + if (_views[IndexRecommendations] is RecommendationsView rv) + { + rv.OnApply = rec => ExecuteAction( + $"Apply recommendation '{TruncateId(rec.Name ?? rec.Id.ToString())}'", + () => services.Recommendations.Perform(new Perform + { + EventStore = _activeEventStore ?? settings.ResolveEventStore(), + Namespace = _activeNamespace ?? settings.ResolveNamespace(), + RecommendationId = rec.Id + })); + + rv.OnIgnore = rec => ExecuteAction( + $"Ignore recommendation '{TruncateId(rec.Name ?? rec.Id.ToString())}'", + () => services.Recommendations.Ignore(new Perform + { + EventStore = _activeEventStore ?? settings.ResolveEventStore(), + Namespace = _activeNamespace ?? settings.ResolveNamespace(), + RecommendationId = rec.Id + })); + + rv.OnApplyAll = recs => ConfirmThenExecuteAll( + $"Apply {recs.Count} recommendation{(recs.Count == 1 ? string.Empty : "s")}", + recs, + rec => services.Recommendations.Perform(new Perform + { + EventStore = _activeEventStore ?? settings.ResolveEventStore(), + Namespace = _activeNamespace ?? settings.ResolveNamespace(), + RecommendationId = rec.Id + })); + + rv.OnIgnoreAll = recs => ConfirmThenExecuteAll( + $"Ignore {recs.Count} recommendation{(recs.Count == 1 ? string.Empty : "s")}", + recs, + rec => services.Recommendations.Ignore(new Perform + { + EventStore = _activeEventStore ?? settings.ResolveEventStore(), + Namespace = _activeNamespace ?? settings.ResolveNamespace(), + RecommendationId = rec.Id + })); + } + + if (_views[IndexReadModels] is ReadModelsView rmv) + { + rmv.OnFetchInstances = async (containerName, ct) => + await dataService.FetchAsync( + _activeEventStore, + _activeNamespace, + readModelContainerName: containerName, + ct); + } + + if (_views[IndexEventStores] is EventStoresView esv) + { + esv.OnSwitch = storeName => + { + _activeEventStore = storeName; + _activeNamespace = null; + SwitchToOverview(); + }; + } + + if (_views[IndexNamespaces] is NamespacesView nsv) + { + nsv.OnSwitch = nsName => + { + _activeNamespace = nsName; + SwitchToOverview(); + }; + } + + foreach (var view in _views) + { + view.OnFilterFocusChanged = focused => _textInputFocused = focused; + } + } + + string BuildTitleContent() + { + var acc = WorkbenchColors.Accent.ToMarkup(); + var mut = WorkbenchColors.Muted.ToMarkup(); + var suc = WorkbenchColors.Success.ToMarkup(); + var host = ExtractHostFromConnectionString(settings.ResolveConnectionString()); + var eventStore = _activeEventStore ?? settings.ResolveEventStore(); + var ns = _activeNamespace ?? settings.ResolveNamespace(); + return $" [bold {acc}]◆ CHRONICLE WORKBENCH[/]" + + $" [{mut}]{host}[/]" + + $" [{suc}]●[/] [{mut}]{eventStore} / {ns}[/]" + + $" [{mut}]↻ {settings.Interval}s[/]"; + } + + MarkupControl BuildTitleBar() + { + var control = new MarkupControl([BuildTitleContent()]) + { + Name = "TitleBar" + }; + _titleBar = control; + return control; + } + + NavigationView BuildNavigationView() + { + var selectedBg = new SColor(49, 50, 68, 255); + var selectedFg = WorkbenchColors.Accent; + + var navView = Controls.NavigationView() + .WithNavWidth(22) + .WithPaneHeader($"[bold {WorkbenchColors.Accent.ToMarkup()}] CHRONICLE [/]") + .WithSelectedColors(selectedFg, selectedBg) + .WithPaneDisplayMode(NavigationViewDisplayMode.Expanded) + .WithName("MainNav") + .Fill() + .AddHeader("DASHBOARD", h => + h.AddItem("Overview", "◆", null, panel => + panel.AddControl(_views[IndexOverview].BuildContent(windowSystem)))) + .AddHeader("OBSERVATION", h => + h.AddItem("Observers", "o", null, panel => + panel.AddControl(_views[IndexObservers].BuildContent(windowSystem))) + .AddItem("Failures", "!", null, panel => + panel.AddControl(_views[IndexFailures].BuildContent(windowSystem)))) + .AddHeader("OPERATIONS", h => + h.AddItem("Jobs", "~", null, panel => + panel.AddControl(_views[IndexJobs].BuildContent(windowSystem))) + .AddItem("Recommendations", "*", null, panel => + panel.AddControl(_views[IndexRecommendations].BuildContent(windowSystem)))) + .AddHeader("SCHEMA", h => + h.AddItem("Event Types", "#", null, panel => + panel.AddControl(_views[IndexEventTypes].BuildContent(windowSystem))) + .AddItem("Projections", ">", null, panel => + panel.AddControl(_views[IndexProjections].BuildContent(windowSystem))) + .AddItem("Read Models", "=", null, panel => + panel.AddControl(_views[IndexReadModels].BuildContent(windowSystem)))) + .AddHeader("DATA", h => + h.AddItem("Event Log", "-", null, panel => + panel.AddControl(_views[IndexEventLog].BuildContent(windowSystem))) + .AddItem("Event Stores", "+", null, panel => + panel.AddControl(_views[IndexEventStores].BuildContent(windowSystem))) + .AddItem("Namespaces", "@", null, panel => + panel.AddControl(_views[IndexNamespaces].BuildContent(windowSystem)))) + .OnSelectedItemChanged((_, e) => + { + var idx = e.NewIndex; + if (idx < 0 || idx >= _views.Length) return; + + WorkbenchData? snapshot; + lock (_dataLock) + { + snapshot = _currentData; + } + + if (snapshot is not null) + { + _views[idx].UpdateData(snapshot); + } + else + { + _ = Task.Run(() => FetchAndUpdate(CancellationToken.None)); + } + }) + .Build(); + + var items = navView.Items; + _observersItem = FindItemByText(items, "Observers"); + _failuresItem = FindItemByText(items, "Failures"); + _recommendationsItem = FindItemByText(items, "Recommendations"); + + _navView = navView; + return navView; + } + + LogViewerControl BuildEventStreamLogViewer() + { + var logViewer = new LogViewerControl(windowSystem.LogService) + { + Title = "Live Event Stream", + AutoScroll = true, + Name = "EventStream" + }; + _eventStreamLog = logViewer; + return logViewer; + } + + PromptControl BuildStreamFilterPrompt() + { + var mut = WorkbenchColors.Muted.ToMarkup(); + return Controls.Prompt($"[{mut}]Stream: [/]") + .WithName("StreamFilter") + .OnInputChanged((_, text) => ApplyStreamFilter(text ?? string.Empty)) + .OnGotFocus((_, _) => _textInputFocused = true) + .OnLostFocus((_, _) => _textInputFocused = false) + .Build(); + } + + StatusBarControl BuildStatusBar() + { + var statusBar = Controls.StatusBar() + .WithName("StatusBar") + .StickyBottom() + .AddLeft("1-0", "Jump", null) + .AddLeft("<-", "Sidebar", null) + .AddLeft("T", "Toggle Log", ToggleEventStream) + .AddLeft("C", "Clear Log", ClearEventStream) + .AddLeft("Q", "Quit", null) + .AddRight(string.Empty, "Connecting...", null) + .Build(); + + _statusBar = statusBar; + return statusBar; + } + + async Task RunDataRefreshLoop(Window window, CancellationToken ct) + { + await FetchAndUpdate(ct); + + while (!ct.IsCancellationRequested) + { + try + { + await Task.Delay(TimeSpan.FromSeconds(settings.Interval), ct); + } + catch (OperationCanceledException) + { + break; + } + + await FetchAndUpdate(ct); + } + } + + async Task FetchAndUpdate(CancellationToken ct) + { + try + { + var data = await dataService.FetchAsync( + _activeEventStore, + _activeNamespace, + readModelContainerName: null, + ct); + + lock (_dataLock) + { + _currentData = data; + } + + PushDataToViews(data); + FetchNewEvents(data, ct); + + if (_wasDisconnected && data.IsConnected) + { + var suc = WorkbenchColors.Success.ToMarkup(); + _ = Task.Run( + async () => + { + UpdateStatusRight($"[{suc}]✓ Reconnected[/]"); + await Task.Delay(3000, ct); + UpdateStatusBar(_currentData); + }, + ct); + } + + _wasDisconnected = !data.IsConnected; + + UpdateStatusBar(data); + UpdateNavBadges(data); + + if (_titleBar is MarkupControl titleBar) + { + titleBar.Text = BuildTitleContent(); + } + } + catch (OperationCanceledException) + { + } + catch + { + // Swallow — connectivity errors shown in status bar via IsConnected + } + } + + void PushDataToViews(WorkbenchData data) + { + foreach (var view in _views) + { + view.UpdateData(data); + } + } + + void FetchNewEvents(WorkbenchData data, CancellationToken ct) + { + if (data.TailSequenceNumber is null || _eventStreamLog is null) return; + + var afterSeq = _lastEventStreamSeq; + if (data.TailSequenceNumber.Value <= afterSeq) return; + + _ = Task.Run( + async () => + { + try + { + var newEvents = await dataService.FetchNewEventsAsync( + afterSeq, + _activeEventStore, + _activeNamespace, + ct); + + foreach (var evt in newEvents) + { + var typeColor = EventTypeColor(evt.Context.EventType.Id).ToMarkup(); + var line = + $"[{WorkbenchColors.Muted.ToMarkup()}]{evt.Context.Occurred}[/] [{typeColor}]{evt.Context.EventType.Id}[/] [{WorkbenchColors.Muted.ToMarkup()}]{evt.Context.EventSourceId ?? string.Empty}[/] [bold]#{evt.Context.SequenceNumber:N0}[/]"; + + AppendToStreamBuffer(line); + } + + if (newEvents.Count > 0) + { + _lastEventStreamSeq = newEvents[^1].Context.SequenceNumber; + } + + if (_views[IndexOverview] is OverviewView ov) + { + ov.UpdateEventDelta(newEvents.Count); + } + } + catch + { + // Best-effort display — ignore fetch errors for live stream + } + }, + ct); + } + + void AppendToStreamBuffer(string line) + { + lock (_eventStreamBuffer) + { + while (_eventStreamBuffer.Count >= StreamBufferCapacity) + { + _eventStreamBuffer.Dequeue(); + } + + _eventStreamBuffer.Enqueue(line); + } + + if (string.IsNullOrEmpty(_streamFilter) || + line.Contains(_streamFilter, StringComparison.OrdinalIgnoreCase)) + { + windowSystem.LogService.LogInfo(line, "events"); + } + } + + void ApplyStreamFilter(string filter) + { + _streamFilter = filter; + + windowSystem.LogService.ClearLogs(); + + string[] buffered; + lock (_eventStreamBuffer) + { + buffered = [.. _eventStreamBuffer]; + } + + foreach (var line in buffered) + { + if (string.IsNullOrEmpty(filter) || + line.Contains(filter, StringComparison.OrdinalIgnoreCase)) + { + windowSystem.LogService.LogInfo(line, "events"); + } + } + } + + void UpdateStatusBar(WorkbenchData? data = null) + { + if (_statusBar is null) return; + + data ??= _currentData; + if (data is null) return; + + _statusBar.ClearRight(); + + var connDot = data.IsConnected + ? $"[{WorkbenchColors.Success.ToMarkup()}]●[/] connected" + : $"[{WorkbenchColors.Danger.ToMarkup()}]●[/] disconnected"; + + var seqText = data.TailSequenceNumber.HasValue + ? $" seq# {data.TailSequenceNumber.Value:N0}" + : string.Empty; + + var mut = WorkbenchColors.Muted.ToMarkup(); + var acc = WorkbenchColors.Accent.ToMarkup(); + + var eventStore = _activeEventStore ?? settings.ResolveEventStore(); + var ns = _activeNamespace ?? settings.ResolveNamespace(); + + _statusBar.AddRightText( + $"{connDot}{seqText} [bold {acc}][E][/] [{mut}]{eventStore}[/] [bold {acc}][N][/] [{mut}]{ns}[/] ↻{settings.Interval}s", + null); + } + + void UpdateNavBadges(WorkbenchData data) + { + var problemCount = data.DisconnectedObservers + data.ReplayingObservers; + + if (_observersItem is NavigationItem observersItem) + { + observersItem.Subtitle = problemCount > 0 ? $"⚠{problemCount}" : string.Empty; + } + + if (_failuresItem is NavigationItem failuresItem) + { + failuresItem.Subtitle = data.FailedPartitions.Count > 0 + ? data.FailedPartitions.Count.ToString() + : string.Empty; + } + + if (_recommendationsItem is NavigationItem recommendationsItem) + { + recommendationsItem.Subtitle = data.Recommendations.Count > 0 + ? data.Recommendations.Count.ToString() + : string.Empty; + } + + _navView?.Invalidate(); + } + + void ToggleEventStream() + { + _eventStreamVisible = !_eventStreamVisible; + if (_eventStreamLog is LogViewerControl log) + { + log.Visible = _eventStreamVisible; + } + } + + void ClearEventStream() => windowSystem.LogService.ClearLogs(); + + void HandleKeyPress(KeyPressedEventArgs e) + { + if (_navView is null) return; + + // When a destructive action is pending, intercept Y/N/Escape for confirmation. + if (_pendingAction is not null) + { + switch (e.KeyInfo.Key) + { + case ConsoleKey.Y: + var pending = _pendingAction.Value; + _pendingAction = null; + RunPendingAction(pending.Description, pending.Execute); + e.Handled = true; + return; + + case ConsoleKey.N: + case ConsoleKey.Escape: + _pendingAction = null; + UpdateStatusBar(); + e.Handled = true; + return; + } + + // Swallow all other keys while confirmation is shown. + e.Handled = true; + return; + } + + // When a text input has focus, suppress all global shortcuts except Escape (to clear filter). + if (_textInputFocused) + { + if (e.KeyInfo.Key == ConsoleKey.Escape) + { + var idx = _navView.SelectedIndex; + if (idx >= 0 && idx < _views.Length) + { + _views[idx].ClearFilter(); + } + + e.Handled = true; + } + + return; + } + + switch (e.KeyInfo.Key) + { + case ConsoleKey.D1: _navView.SelectedIndex = IndexOverview; e.Handled = true; break; + case ConsoleKey.D2: _navView.SelectedIndex = IndexObservers; e.Handled = true; break; + case ConsoleKey.D3: _navView.SelectedIndex = IndexFailures; e.Handled = true; break; + case ConsoleKey.D4: _navView.SelectedIndex = IndexJobs; e.Handled = true; break; + case ConsoleKey.D5: _navView.SelectedIndex = IndexRecommendations; e.Handled = true; break; + case ConsoleKey.D6: _navView.SelectedIndex = IndexEventTypes; e.Handled = true; break; + case ConsoleKey.D7: _navView.SelectedIndex = IndexProjections; e.Handled = true; break; + case ConsoleKey.D8: _navView.SelectedIndex = IndexReadModels; e.Handled = true; break; + case ConsoleKey.D9: _navView.SelectedIndex = IndexEventLog; e.Handled = true; break; + case ConsoleKey.D0: _navView.SelectedIndex = IndexEventStores; e.Handled = true; break; + case ConsoleKey.LeftArrow: FocusNavigation(); e.Handled = true; break; + case ConsoleKey.E: OpenEventStorePicker(); e.Handled = true; break; + case ConsoleKey.N: OpenNamespacePicker(); e.Handled = true; break; + case ConsoleKey.Enter when _navView.SelectedIndex == IndexReadModels: + OpenReadModelDetail(); + e.Handled = true; + break; + case ConsoleKey.Oem2 when e.KeyInfo.Modifiers == ConsoleModifiers.Shift: + OpenHelpOverlay(); + e.Handled = true; + break; + case ConsoleKey.T: ToggleEventStream(); e.Handled = true; break; + case ConsoleKey.C: ClearEventStream(); e.Handled = true; break; + case ConsoleKey.P when e.KeyInfo.Modifiers.HasFlag(ConsoleModifiers.Control): + OpenCommandPalette(); + e.Handled = true; + break; + case ConsoleKey.F: + ActivateCurrentFilter(); + e.Handled = true; + break; + case ConsoleKey.Q: Environment.Exit(0); break; + } + } + + void OpenHelpOverlay() + { + var mut = WorkbenchColors.Muted.ToMarkup(); + var acc = WorkbenchColors.Accent.ToMarkup(); + + var helpText = + $"[bold {acc}]NAVIGATION[/]\n" + + $" [{mut}]1–9/0[/] Jump to section\n" + + $" [{mut}]↑ ↓[/] Move selection\n" + + $" [{mut}]← / →[/] Sidebar ↔ Content\n" + + $" [{mut}]Enter[/] Open detail overlay\n" + + $" [{mut}]Esc[/] Close overlay\n" + + "\n" + + $"[bold {acc}]QUICK SWITCH[/]\n" + + $" [{mut}]E[/] Switch event store\n" + + $" [{mut}]N[/] Switch namespace\n" + + "\n" + + $"[bold {acc}]FILTER[/]\n" + + $" [{mut}]F[/] Focus filter prompt for current view\n" + + $" [{mut}]Escape[/] Clear filter and return focus to table\n" + + "\n" + + $"[bold {acc}]ACTIONS (when row selected)[/]\n" + + $" [{mut}]R[/] Replay observer\n" + + $" [{mut}]T[/] Retry partition\n" + + $" [{mut}]P[/] Replay partition\n" + + $" [{mut}]S / U[/] Stop / Resume job\n" + + $" [{mut}]A / I[/] Apply / Ignore recommendation\n" + + $" [{mut}]Y / N[/] Confirm / Cancel action\n" + + "\n" + + $"[bold {acc}]LIVE STREAM[/]\n" + + $" [{mut}]T[/] Toggle event stream\n" + + $" [{mut}]C[/] Clear event stream\n" + + "\n" + + $"[bold {acc}]GENERAL[/]\n" + + $" [{mut}]+ / -[/] Increase / decrease refresh interval\n" + + $" [{mut}]Ctrl+P[/] Command palette\n" + + $" [{mut}]?[/] This help screen\n" + + $" [{mut}]Q[/] Quit"; + + var markup = new MarkupControl([helpText]) { Wrap = true }; + var content = Controls.ScrollablePanel() + .AddControl(markup) + .WithVerticalScroll(ScrollMode.Scroll) + .WithPadding(2, 1, 2, 1) + .Build(); + + Window? helpWindow = null; + helpWindow = new WindowBuilder(windowSystem) + .WithTitle(" Keyboard Shortcuts ") + .WithColors(WorkbenchColors.Foreground, WorkbenchColors.Background) + .WithSize(70, 35) + .Centered() + .AddControl(content) + .OnKeyPressed((_, e) => + { + if (e.KeyInfo.Key == ConsoleKey.Escape || + (e.KeyInfo.Key == ConsoleKey.Oem2 && e.KeyInfo.Modifiers == ConsoleModifiers.Shift)) + { + windowSystem.CloseWindow(helpWindow, activateParent: true, force: false); + } + + e.Handled = true; + }) + .Build(); + + windowSystem.AddWindow(helpWindow, activateWindow: true); + } + + void NavigateObservers() + { + if (_navView is NavigationView nav) nav.SelectedIndex = IndexObservers; + } + + void NavigateEventTypes() + { + if (_navView is NavigationView nav) nav.SelectedIndex = IndexEventTypes; + } + + void NavigateProjections() + { + if (_navView is NavigationView nav) nav.SelectedIndex = IndexProjections; + } + + void NavigateReadModels() + { + if (_navView is NavigationView nav) nav.SelectedIndex = IndexReadModels; + } + + void NavigateFailures() + { + if (_navView is NavigationView nav) nav.SelectedIndex = IndexFailures; + } + + void OpenCommandPalette() + { + WorkbenchData? snapshot; + lock (_dataLock) + { + snapshot = _currentData; + } + + if (snapshot is null) return; + + var mut = WorkbenchColors.Muted.ToMarkup(); + var acc = WorkbenchColors.Accent.ToMarkup(); + + var resultsTable = Controls.Table() + .AddColumn("Kind", SharpConsoleUI.Layout.TextJustification.Left, 20) + .AddColumn("Name", SharpConsoleUI.Layout.TextJustification.Left, null) + .Interactive() + .WithVerticalScrollbar(ScrollbarVisibility.Auto) + .WithName("CommandPaletteResults") + .Build(); + + var searchPrompt = Controls.Prompt($"[{acc}]>[/] ") + .WithName("CommandPaletteSearch") + .OnGotFocus((_, _) => _textInputFocused = true) + .OnLostFocus((_, _) => _textInputFocused = false) + .Build(); + + void PopulateResults(string query) + { + resultsTable.ClearRows(); + + if (string.IsNullOrWhiteSpace(query)) return; + + var matches = new List<(string Kind, string Label, Action Navigate)>(); + + foreach (var obs in snapshot.Observers) + { + if (obs.Id.Contains(query, StringComparison.OrdinalIgnoreCase)) + { + matches.Add(("Observer", $"{obs.Id} [{obs.RunningState}]", NavigateObservers)); + } + } + + foreach (var et in snapshot.EventTypeRegistrations) + { + if (et.Type.Id.Contains(query, StringComparison.OrdinalIgnoreCase)) + { + matches.Add(("Event Type", $"{et.Type.Id} gen {et.Type.Generation}", NavigateEventTypes)); + } + } + + foreach (var pd in snapshot.ProjectionDefinitions) + { + if (pd.Identifier.Contains(query, StringComparison.OrdinalIgnoreCase)) + { + matches.Add(("Projection", pd.Identifier, NavigateProjections)); + } + } + + foreach (var rm in snapshot.ReadModelDefinitions) + { + if (rm.ContainerName.Contains(query, StringComparison.OrdinalIgnoreCase) || + rm.DisplayName.Contains(query, StringComparison.OrdinalIgnoreCase)) + { + matches.Add(("Read Model", rm.DisplayName.Length > 0 ? rm.DisplayName : rm.ContainerName, NavigateReadModels)); + } + } + + foreach (var fp in snapshot.FailedPartitions) + { + if (fp.ObserverId.Contains(query, StringComparison.OrdinalIgnoreCase) || + fp.Partition.Contains(query, StringComparison.OrdinalIgnoreCase)) + { + matches.Add(("Failure", $"{fp.ObserverId}/{fp.Partition}", NavigateFailures)); + } + } + + foreach (var (kind, label, navigate) in matches.Take(10)) + { + resultsTable.AddRow(new SharpConsoleUI.Controls.TableRow([kind, label]) { Tag = navigate }); + } + } + + searchPrompt.InputChanged += (_, text) => PopulateResults(text ?? string.Empty); + + Window? paletteWindow = null; + paletteWindow = new WindowBuilder(windowSystem) + .WithTitle(" Command Palette ") + .WithColors(WorkbenchColors.Foreground, WorkbenchColors.Background) + .WithSize(80, 16) + .Centered() + .AddControl(searchPrompt) + .AddControl(resultsTable) + .OnKeyPressed((_, e) => + { + if (e.KeyInfo.Key == ConsoleKey.Escape) + { + windowSystem.CloseWindow(paletteWindow, activateParent: true, force: false); + e.Handled = true; + return; + } + + if (e.KeyInfo.Key == ConsoleKey.Enter) + { + var selectedRow = resultsTable.SelectedRow; + if (selectedRow?.Tag is Action navigate) + { + windowSystem.CloseWindow(paletteWindow, activateParent: true, force: false); + navigate(); + } + + e.Handled = true; + } + }) + .Build(); + + windowSystem.AddWindow(paletteWindow, activateWindow: true); + } + + void FocusNavigation() + { + if (_navView is null) return; + + var window = windowSystem.GetWindowAtPoint(new System.Drawing.Point(0, 0)); + window?.FocusControl(_navView); + } + + void ActivateCurrentFilter() + { + var idx = _navView?.SelectedIndex ?? -1; + if (idx < 0 || idx >= _views.Length) return; + + var window = windowSystem.GetWindowAtPoint(new System.Drawing.Point(0, 0)); + if (window is null) return; + + _views[idx].ActivateFilter(window); + } + + void OpenEventStorePicker() + { + WorkbenchData? snapshot; + lock (_dataLock) + { + snapshot = _currentData; + } + + if (snapshot is null) return; + + var stores = snapshot.EventStoreNames.Order().ToList(); + var active = _activeEventStore ?? settings.ResolveEventStore(); + var acc = WorkbenchColors.Accent.ToMarkup(); + + var pickerTable = Controls.Table() + .AddColumn("Event Store", SharpConsoleUI.Layout.TextJustification.Left, null) + .Interactive() + .WithVerticalScrollbar(ScrollbarVisibility.Auto) + .WithName("EventStorePickerTable") + .Build(); + + foreach (var name in stores) + { + var label = name == active ? $"[{acc}]► {name}[/]" : name; + pickerTable.AddRow(new SharpConsoleUI.Controls.TableRow([label]) { Tag = name }); + } + + Window? picker = null; + var height = Math.Min(stores.Count + 4, 20); + picker = new WindowBuilder(windowSystem) + .WithTitle(" Switch Event Store ") + .WithColors(WorkbenchColors.Foreground, WorkbenchColors.Background) + .WithSize(50, height) + .Centered() + .AddControl(pickerTable) + .OnKeyPressed((_, ke) => + { + if (ke.KeyInfo.Key == ConsoleKey.Escape) + { + windowSystem.CloseWindow(picker, activateParent: true, force: false); + ke.Handled = true; + } + }) + .Build(); + + pickerTable.RowActivated += (_, _) => + { + if (pickerTable.SelectedRow?.Tag is string storeName) + { + _activeEventStore = storeName; + _activeNamespace = null; + windowSystem.CloseWindow(picker, activateParent: true, force: false); + _ = Task.Run(() => FetchAndUpdate(CancellationToken.None)); + } + }; + + windowSystem.AddWindow(picker, activateWindow: true); + } + + void OpenNamespacePicker() + { + WorkbenchData? snapshot; + lock (_dataLock) + { + snapshot = _currentData; + } + + if (snapshot is null) return; + + var namespaces = snapshot.NamespaceNames.Order().ToList(); + var active = _activeNamespace ?? settings.ResolveNamespace(); + var acc = WorkbenchColors.Accent.ToMarkup(); + + var pickerTable = Controls.Table() + .AddColumn("Namespace", SharpConsoleUI.Layout.TextJustification.Left, null) + .Interactive() + .WithVerticalScrollbar(ScrollbarVisibility.Auto) + .WithName("NamespacePickerTable") + .Build(); + + foreach (var name in namespaces) + { + var label = name == active ? $"[{acc}]► {name}[/]" : name; + pickerTable.AddRow(new SharpConsoleUI.Controls.TableRow([label]) { Tag = name }); + } + + Window? picker = null; + var height = Math.Min(namespaces.Count + 4, 20); + picker = new WindowBuilder(windowSystem) + .WithTitle(" Switch Namespace ") + .WithColors(WorkbenchColors.Foreground, WorkbenchColors.Background) + .WithSize(50, height) + .Centered() + .AddControl(pickerTable) + .OnKeyPressed((_, ke) => + { + if (ke.KeyInfo.Key == ConsoleKey.Escape) + { + windowSystem.CloseWindow(picker, activateParent: true, force: false); + ke.Handled = true; + } + }) + .Build(); + + pickerTable.RowActivated += (_, _) => + { + if (pickerTable.SelectedRow?.Tag is string nsName) + { + _activeNamespace = nsName; + windowSystem.CloseWindow(picker, activateParent: true, force: false); + _ = Task.Run(() => FetchAndUpdate(CancellationToken.None)); + } + }; + + windowSystem.AddWindow(picker, activateWindow: true); + } + + void SwitchToOverview() + { + if (_navView is null) return; + _navView.SelectedIndex = IndexOverview; + } + + void OpenReadModelDetail() + { + if (_views[IndexReadModels] is not ReadModelsView rmv) return; + + // Retrieve the selected read model from the view's table via the public overlay method. + // The view owns the table reference, so we delegate entirely to it. + rmv.OpenSelectedDetailOverlay(); + } + + void ExecuteAction(string description, Func action) + { + _pendingAction = (description, action); + var war = WorkbenchColors.Warning.ToMarkup(); + var acc = WorkbenchColors.Accent.ToMarkup(); + var mut = WorkbenchColors.Muted.ToMarkup(); + UpdateStatusRight( + $"[{war}]⚡ {description}?[/] [bold {acc}][Y][/] [{mut}]Confirm[/] [bold {acc}][N][/] [{mut}]Cancel[/]"); + } + + void ConfirmThenExecuteAll(string description, IReadOnlyList items, Func perItem) + { + ExecuteAction(description, async () => + { + foreach (var item in items) + { + await perItem(item); + } + }); + } + + void RunPendingAction(string description, Func action) + { + _ = Task.Run(async () => + { + try + { + UpdateStatusRight($"[{WorkbenchColors.Warning.ToMarkup()}]⟳ {description}...[/]"); + await action(); + UpdateStatusRight($"[{WorkbenchColors.Success.ToMarkup()}]✓ Done[/]"); + + await Task.Delay(3000); + UpdateStatusBar(); + } + catch (Exception ex) + { + var msg = ex.Message.Length > 60 ? ex.Message[..60] : ex.Message; + UpdateStatusRight($"[{WorkbenchColors.Danger.ToMarkup()}]✗ {msg}[/]"); + } + }); + } + + void UpdateStatusRight(string text) + { + if (_statusBar is null) return; + _statusBar.ClearRight(); + _statusBar.AddRightText(text, null); + } +} From 271070e5fa026046a8a2e341c090e1dddebad94d Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 20 May 2026 07:58:03 +0200 Subject: [PATCH 2/5] feat: add SharpConsoleUI package version 2.4.61 --- Directory.Packages.props | 1 + 1 file changed, 1 insertion(+) diff --git a/Directory.Packages.props b/Directory.Packages.props index 76fc301..f2c0d8e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,6 +12,7 @@ + From 5768d3715b7b4b894ad2484f946c45f9015ff106 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 21 May 2026 21:38:11 +0200 Subject: [PATCH 3/5] Add WorkbenchActionHandler and WorkbenchNavigation classes for enhanced UI interaction - Implemented WorkbenchActionHandler to manage destructive action confirmations, including queuing actions, handling user input, and executing actions with status updates. - Created WorkbenchNavigation to build and manage the navigation side pane, including event store and namespace picker overlays, and updating badge counts based on the latest data. --- .../Chronicle/Workbench/WorkbenchApp.cs | 5 +- .../Chronicle/Workbench/WorkbenchCommand.cs | 14 +- .../Chronicle/Workbench/WorkbenchData.cs | 18 +- .../Workbench/WorkbenchDataService.cs | 55 +- .../Chronicle/Workbench/WorkbenchState.cs | 59 + .../Chronicle/Workbench/WorkbenchView.cs | 22 +- .../Workbench/views/ApplicationsView.cs | 68 ++ .../Chronicle/Workbench/views/EventLogView.cs | 223 ---- .../Workbench/views/EventSequencesView.cs | 152 +++ .../Workbench/views/EventStoresView.cs | 3 + .../Workbench/views/EventTypesView.cs | 245 ++-- .../Workbench/views/FailedPartitionsView.cs | 212 +--- .../Workbench/views/FilterableTableView.cs | 738 ++++++++++++ .../Workbench/views/IWorkbenchView.cs | 47 + .../Workbench/views/IdentitiesView.cs | 58 + .../Chronicle/Workbench/views/JobsView.cs | 212 ++-- .../Workbench/views/JsonYamlFormatter.cs | 102 ++ .../Workbench/views/NamespacesView.cs | 3 + .../Workbench/views/ObserversView.cs | 333 ++--- .../Chronicle/Workbench/views/OverviewView.cs | 35 +- .../Workbench/views/ProjectionsView.cs | 182 +-- .../Workbench/views/ReadModelsView.cs | 231 +--- .../Workbench/views/RecommendationsView.cs | 153 +-- .../Workbench/views/SubscriptionsView.cs | 71 ++ .../Chronicle/Workbench/views/UsersView.cs | 70 ++ .../Workbench/windows/DetailOverlayWindow.cs | 17 +- .../Chronicle/Workbench/windows/MainWindow.cs | 1071 ++++++++--------- .../windows/WorkbenchActionHandler.cs | 116 ++ .../Workbench/windows/WorkbenchNavigation.cs | 353 ++++++ 29 files changed, 2944 insertions(+), 1924 deletions(-) create mode 100644 Source/Cli/Commands/Chronicle/Workbench/WorkbenchState.cs create mode 100644 Source/Cli/Commands/Chronicle/Workbench/views/ApplicationsView.cs delete mode 100644 Source/Cli/Commands/Chronicle/Workbench/views/EventLogView.cs create mode 100644 Source/Cli/Commands/Chronicle/Workbench/views/EventSequencesView.cs create mode 100644 Source/Cli/Commands/Chronicle/Workbench/views/FilterableTableView.cs create mode 100644 Source/Cli/Commands/Chronicle/Workbench/views/IdentitiesView.cs create mode 100644 Source/Cli/Commands/Chronicle/Workbench/views/JsonYamlFormatter.cs create mode 100644 Source/Cli/Commands/Chronicle/Workbench/views/SubscriptionsView.cs create mode 100644 Source/Cli/Commands/Chronicle/Workbench/views/UsersView.cs create mode 100644 Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchActionHandler.cs create mode 100644 Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchNavigation.cs diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchApp.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchApp.cs index a89155a..fe37ac0 100644 --- a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchApp.cs +++ b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchApp.cs @@ -13,7 +13,8 @@ namespace Cratis.Cli.Commands.Chronicle.Workbench; /// The workbench command settings. /// The Chronicle gRPC service clients. /// Pre-fetched data snapshot to populate all views before the first frame is rendered. -public class WorkbenchApp(WorkbenchDataService dataService, WorkbenchSettings settings, IServices services, WorkbenchData initialData) +/// Persisted workbench state from the previous session. +public class WorkbenchApp(WorkbenchDataService dataService, WorkbenchSettings settings, IServices services, WorkbenchData initialData, WorkbenchState state) { /// /// Runs the workbench TUI and blocks until the user exits. @@ -23,7 +24,7 @@ public int Run() { var windowSystem = new ConsoleWindowSystem(new NetConsoleDriver(RenderMode.Buffer)); - var mainWindow = new MainWindow(windowSystem, dataService, settings, services, initialData); + var mainWindow = new MainWindow(windowSystem, dataService, settings, services, initialData, state); windowSystem.AddWindow(mainWindow.Build(), activateWindow: true); return windowSystem.Run(); diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchCommand.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchCommand.cs index 3bdf24d..5e5a350 100644 --- a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchCommand.cs +++ b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchCommand.cs @@ -30,15 +30,27 @@ protected override async Task ExecuteCommandAsync(IServices services, Workb return ExitCodes.ValidationError; } + // Restore persisted state from the previous session. + var state = WorkbenchState.Load(); + if (settings.Interval == 5) + { + // Only apply saved interval when the user hasn't explicitly set one via --interval. + settings.Interval = state.Interval; + } + var dataService = new WorkbenchDataService(services, settings); // Pre-fetch before launching the window so every view has real data from the first frame — // mirroring the old render-loop approach where data was fetched before the first render. var initialData = await dataService.FetchAsync(null, null, null, CancellationToken.None).ConfigureAwait(false); - var app = new WorkbenchApp(dataService, settings, services, initialData); + var app = new WorkbenchApp(dataService, settings, services, initialData, state); app.Run(); + // Persist final state so next session restores the same context. + state.Interval = settings.Interval; + state.Save(); + AnsiConsole.MarkupLine($" [{OutputFormatter.Muted.ToMarkup()}]Workbench closed.[/]"); return ExitCodes.Success; } diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchData.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchData.cs index 169a44f..75f0b22 100644 --- a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchData.cs +++ b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchData.cs @@ -1,7 +1,9 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using Cratis.Chronicle.Contracts.Identities; using Cratis.Chronicle.Contracts.Jobs; +using Cratis.Chronicle.Contracts.Observation.EventStoreSubscriptions; namespace Cratis.Cli.Commands.Chronicle.Workbench; @@ -47,6 +49,10 @@ public record WorkbenchReadModel( /// Instances for the currently focused read model (populated in ReadModelDetail view only). /// Total instance count for the currently focused read model. /// Error message from the last read model instances fetch, or . +/// All registered OAuth applications on the Chronicle server. +/// All registered users on the Chronicle server. +/// All known identities in the current event store namespace. +/// All event store subscriptions configured for the current event store. public record WorkbenchData( string ConnectionString, string EventStore, @@ -69,7 +75,11 @@ public record WorkbenchData( IReadOnlyList NamespaceNames, IReadOnlyList ReadModelInstances, int ReadModelInstancesTotalCount, - string? ReadModelInstancesError) + string? ReadModelInstancesError, + IReadOnlyList Applications, + IReadOnlyList Users, + IReadOnlyList Identities, + IReadOnlyList EventStoreSubscriptions) { /// /// Gets the number of observers in the state. @@ -118,5 +128,9 @@ public record WorkbenchData( NamespaceNames: [], ReadModelInstances: [], ReadModelInstancesTotalCount: 0, - ReadModelInstancesError: null); + ReadModelInstancesError: null, + Applications: [], + Users: [], + Identities: [], + EventStoreSubscriptions: []); } diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchDataService.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchDataService.cs index 90f2d36..573b363 100644 --- a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchDataService.cs +++ b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchDataService.cs @@ -2,7 +2,9 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System.Text.Json; +using Cratis.Chronicle.Contracts.Identities; using Cratis.Chronicle.Contracts.Jobs; +using Cratis.Chronicle.Contracts.Observation.EventStoreSubscriptions; using Cratis.Cli.Commands.Chronicle.ReadModels; namespace Cratis.Cli.Commands.Chronicle.Workbench; @@ -52,8 +54,12 @@ public async Task FetchAsync( var declarationsTask = FetchProjectionDeclarationsAsync(eventStore); var readModelsTask = FetchReadModelDefinitionsAsync(eventStore); var namespacesTask = FetchNamespacesAsync(eventStore); + var applicationsTask = FetchApplicationsAsync(); + var usersTask = FetchUsersAsync(); + var identitiesTask = FetchIdentitiesAsync(eventStore, ns); + var subscriptionsTask = FetchSubscriptionsAsync(eventStore); - await Task.WhenAll(versionTask, eventStoresTask, observersTask, failedPartitionsTask, jobsTask, recommendationsTask, tailTask, eventTypesTask, projectionsTask, declarationsTask, readModelsTask, namespacesTask).ConfigureAwait(false); + await Task.WhenAll(versionTask, eventStoresTask, observersTask, failedPartitionsTask, jobsTask, recommendationsTask, tailTask, eventTypesTask, projectionsTask, declarationsTask, readModelsTask, namespacesTask, applicationsTask, usersTask, identitiesTask, subscriptionsTask).ConfigureAwait(false); // All tasks are complete — awaiting them returns immediately. var (serverVersion, isConnected) = await versionTask.ConfigureAwait(false); @@ -68,6 +74,10 @@ public async Task FetchAsync( var projectionDeclarations = await declarationsTask.ConfigureAwait(false); var readModelDefinitions = await readModelsTask.ConfigureAwait(false); var namespaceNames = await namespacesTask.ConfigureAwait(false); + var applications = await applicationsTask.ConfigureAwait(false); + var users = await usersTask.ConfigureAwait(false); + var identities = await identitiesTask.ConfigureAwait(false); + var subscriptions = await subscriptionsTask.ConfigureAwait(false); // Recent events depends on the tail — fetch after the parallel batch. var recentEvents = await FetchRecentEventsAsync(eventStore, ns, tailSequenceNumber).ConfigureAwait(false); @@ -98,7 +108,11 @@ public async Task FetchAsync( NamespaceNames: namespaceNames, ReadModelInstances: readModelInstances, ReadModelInstancesTotalCount: readModelInstancesTotalCount, - ReadModelInstancesError: readModelInstancesError); + ReadModelInstancesError: readModelInstancesError, + Applications: applications, + Users: users, + Identities: identities, + EventStoreSubscriptions: subscriptions); } /// @@ -301,6 +315,43 @@ async Task> FetchNamespacesAsync(string eventStore) catch { return []; } } + async Task> FetchApplicationsAsync() + { + try { return [.. await services.Applications.GetAll().ConfigureAwait(false) ?? []]; } + catch { return []; } + } + + async Task> FetchUsersAsync() + { + try { return [.. await services.Users.GetAll().ConfigureAwait(false) ?? []]; } + catch { return []; } + } + + async Task> FetchIdentitiesAsync(string eventStore, string ns) + { + try + { + return [.. await services.Identities.GetIdentities(new GetIdentitiesRequest + { + EventStore = eventStore, + Namespace = ns + }).ConfigureAwait(false)]; + } + catch { return []; } + } + + async Task> FetchSubscriptionsAsync(string eventStore) + { + try + { + return [.. await services.EventStoreSubscriptions.GetSubscriptions(new GetEventStoreSubscriptionsRequest + { + TargetEventStore = eventStore + }).ConfigureAwait(false)]; + } + catch { return []; } + } + async Task<(IReadOnlyList Instances, int TotalCount, string? Error)> FetchReadModelInstancesAsync( string eventStore, string ns, diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchState.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchState.cs new file mode 100644 index 0000000..777a841 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchState.cs @@ -0,0 +1,59 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text.Json; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Persisted workbench state saved between sessions under ~/.cratis/workbench-state.json. +/// +public class WorkbenchState +{ + static readonly string _path = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".cratis", + "workbench-state.json"); + + static readonly JsonSerializerOptions _options = new() { WriteIndented = true }; + + /// Gets or sets the refresh interval in seconds. + public int Interval { get; set; } = 5; + + /// Gets or sets the index of the last active navigation item. + public int LastNavIndex { get; set; } + + /// + /// Loads the workbench state from disk, or returns defaults if the file does not exist. + /// + /// The persisted , or a new default instance. + public static WorkbenchState Load() + { + try + { + if (!File.Exists(_path)) return new WorkbenchState(); + var json = File.ReadAllText(_path); + return JsonSerializer.Deserialize(json) ?? new WorkbenchState(); + } + catch + { + return new WorkbenchState(); + } + } + + /// + /// Saves the current state to disk. Failures are silently ignored. + /// + public void Save() + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(_path)!); + File.WriteAllText(_path, JsonSerializer.Serialize(this, _options)); + } + catch + { + // Best-effort persistence — never throw from a background save. + } + } +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchView.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchView.cs index 6577181..286fee4 100644 --- a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchView.cs +++ b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchView.cs @@ -8,9 +8,7 @@ namespace Cratis.Cli.Commands.Chronicle.Workbench; /// public enum WorkbenchView { - // Primary views — cycled with ← → and accessed with keys 1–8. - - /// Overview showing server health, observer counts, and any failures. + /// Overview showing server health, observer counts, context, and any failures. Overview = 0, /// Full list of all observers with their running states and sequence lag. @@ -25,8 +23,8 @@ public enum WorkbenchView /// Pending recommendations from the Chronicle server, with apply and ignore actions. Recommendations = 4, - /// The live event log — last 50 events, newest first. - EventLog = 5, + /// The event sequences view — recent events from the event log, newest first. + EventSequences = 5, /// All registered event types with their JSON schemas. EventTypes = 6, @@ -43,8 +41,20 @@ public enum WorkbenchView /// All namespaces in the current event store, with the ability to switch the active namespace. Namespaces = 10, + /// Registered OAuth applications on the Chronicle server. + Applications = 11, + + /// Registered users on the Chronicle server. + Users = 12, + + /// Known identities (actors) in the current event store namespace. + Identities = 13, + + /// Event store subscriptions configured for the current event store. + Subscriptions = 14, + // Detail views — entered with Enter from a primary view, exited with Escape. - // Values ≥ 100 are never shown in the tab bar. + // Values ≥ 100 are never shown in the nav pane. /// Full-screen detail for a single observer, with a navigable event-type sub-list. ObserverDetail = 100, diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/ApplicationsView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/ApplicationsView.cs new file mode 100644 index 0000000..ee79014 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/views/ApplicationsView.cs @@ -0,0 +1,68 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Chronicle.Contracts.Security; +using SharpConsoleUI.Layout; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Applications navigation item — filterable table of registered OAuth applications with a detail pane. +/// +public class ApplicationsView : FilterableTableView +{ + /// + protected override IReadOnlyList<(string Name, TextJustification Justify, int? Width)> Columns => + [ + ("ClientId", TextJustification.Left, null), + ("Active", TextJustification.Left, 8), + ("Created", TextJustification.Left, 30) + ]; + + /// + protected override string DetailPanelHeader => "APPLICATION"; + + /// + protected override IEnumerable GetItems(WorkbenchData data) => + data.Applications.OrderBy(a => a.ClientId); + + /// + protected override string GetKey(Application item) => item.Id.ToString(); + + /// + protected override string[] BuildRow(Application item) + { + var activeColor = item.IsActive ? WorkbenchColors.Success.ToMarkup() : WorkbenchColors.Muted.ToMarkup(); + return + [ + item.ClientId, + $"[{activeColor}]{(item.IsActive ? "Yes" : "No")}[/]", + item.CreatedAt.ToString() + ]; + } + + /// + protected override string RenderDetail(Application? item, WorkbenchData? data) + { + if (item is null) + { + return $"[{WorkbenchColors.Muted.ToMarkup()}]Select an application.[/]"; + } + + var mut = WorkbenchColors.Muted.ToMarkup(); + var suc = WorkbenchColors.Success.ToMarkup(); + var activeColor = item.IsActive ? suc : mut; + + return string.Join( + "\n", + $"[{mut}]Id[/] {item.Id}", + $"[{mut}]ClientId[/] {item.ClientId}", + $"[{mut}]Active[/] [{activeColor}]{(item.IsActive ? "Yes" : "No")}[/]", + $"[{mut}]Created[/] {item.CreatedAt}"); + } + + /// + protected override bool MatchesFilter(Application item, string filter) => + item.ClientId.Contains(filter, StringComparison.OrdinalIgnoreCase) || + item.Id.ToString().Contains(filter, StringComparison.OrdinalIgnoreCase); +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/EventLogView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/EventLogView.cs deleted file mode 100644 index 67da423..0000000 --- a/Source/Cli/Commands/Chronicle/Workbench/views/EventLogView.cs +++ /dev/null @@ -1,223 +0,0 @@ -// Copyright (c) Cratis. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - -using SharpConsoleUI; -using SharpConsoleUI.Builders; -using SharpConsoleUI.Controls; -using UITableRow = SharpConsoleUI.Controls.TableRow; - -namespace Cratis.Cli.Commands.Chronicle.Workbench; - -/// -/// Event Log navigation item — filterable, sortable table of recent events with a bordered detail pane showing event content. -/// -public class EventLogView : IWorkbenchView -{ - TableControl? _table; - PanelControl? _detailPanel; - PromptControl? _filterPrompt; - string _currentFilter = string.Empty; - List _allEvents = []; - WorkbenchData? _pendingData; - - /// - /// Gets or sets the callback invoked when the filter input gains or loses focus. - /// - public Action? OnFilterFocusChanged { get; set; } - - /// - public void Dispose() - { - _table?.Dispose(); - _detailPanel?.Dispose(); - _filterPrompt?.Dispose(); - } - - /// - public IWindowControl BuildContent(ConsoleWindowSystem windowSystem) - { - _table = Controls.Table() - .AddColumn("#", SharpConsoleUI.Layout.TextJustification.Right, 10) - .AddColumn("Occurred", SharpConsoleUI.Layout.TextJustification.Left, 22) - .AddColumn("Event Type", SharpConsoleUI.Layout.TextJustification.Left, null) - .AddColumn("Source", SharpConsoleUI.Layout.TextJustification.Left, 30) - .Interactive() - .WithSorting() - .WithVerticalScrollbar(ScrollbarVisibility.Auto) - .OnSelectedRowChanged((_, _) => RefreshDetail()) - .WithName("EventLogTable") - .Build(); - - _filterPrompt = Controls.Prompt("Filter: ") - .WithHistory(true) - .WithTabCompleter((input, _) => GetCompletions()) - .OnInputChanged((_, text) => - { - _currentFilter = text ?? string.Empty; - RebuildFilteredRows(); - }) - .OnGotFocus((_, _) => OnFilterFocusChanged?.Invoke(true)) - .OnLostFocus((_, _) => OnFilterFocusChanged?.Invoke(false)) - .WithName("EventLogFilterPrompt") - .Build(); - - var leftPane = Controls.ScrollablePanel() - .AddControl(_filterPrompt) - .AddControl(_table) - .WithVerticalScroll(ScrollMode.None) - .WithName("EventLogLeftPane") - .Build(); - - _detailPanel = Controls.Panel() - .WithContent($"[{WorkbenchColors.Muted.ToMarkup()}]Select an event.[/]") - .WithHeader(" EVENT ") - .Rounded() - .WithBorderColor(WorkbenchColors.Accent) - .WithPadding(1, 0, 1, 0) - .FillVertical() - .WithName("EventLogDetailPanel") - .Build(); - - var root = HorizontalGridControl.Create() - .Column(c => c.Add(leftPane)) - .WithSplitterAfter(0) - .Column(c => c.Width(55).Add(_detailPanel)) - .Build(); - - // Apply any data that arrived before controls were ready (NavigationView lazy init). - if (_pendingData is not null) - UpdateData(_pendingData); - - return root; - } - - /// - public void ActivateFilter(Window window) - { - if (_filterPrompt is not null) - { - window.FocusControl(_filterPrompt); - } - } - - /// - public void ClearFilter() - { - _currentFilter = string.Empty; - _filterPrompt?.SetInput(string.Empty); - RebuildFilteredRows(); - } - - /// - public void UpdateData(WorkbenchData data) - { - _pendingData = data; - if (_table is null) return; - - _allEvents = [.. data.RecentEvents]; - RebuildFilteredRows(); - } - - IEnumerable GetCompletions() => - _allEvents - .Select(e => $"type:{e.Context.EventType.Id}") - .Distinct() - .Order(); - - bool MatchesFilter(AppendedEvent evt) - { - if (string.IsNullOrEmpty(_currentFilter)) return true; - - var f = _currentFilter; - - if (f.StartsWith("type:", StringComparison.OrdinalIgnoreCase)) - { - var type = f[5..]; - return evt.Context.EventType.Id.Contains(type, StringComparison.OrdinalIgnoreCase); - } - - return evt.Context.EventType.Id.Contains(f, StringComparison.OrdinalIgnoreCase) || - (evt.Context.EventSourceId ?? string.Empty).Contains(f, StringComparison.OrdinalIgnoreCase); - } - - void RebuildFilteredRows() - { - if (_table is null) return; - - var selectedKey = (_table.SelectedRow?.Tag as AppendedEvent)?.Context.SequenceNumber.ToString(); - - _table.ClearRows(); - foreach (var evt in _allEvents.Where(MatchesFilter)) - { - _table.AddRow(new UITableRow( - [ - evt.Context.SequenceNumber.ToString(), - evt.Context.Occurred.ToString(), - evt.Context.EventType.Id, - evt.Context.EventSourceId ?? string.Empty - ]) - { Tag = evt }); - } - - if (selectedKey is not null) - { - RestoreSelection(selectedKey); - } - - RefreshDetail(); - } - - void RestoreSelection(string key) - { - if (_table is null) return; - - for (var i = 0; i < _table.Rows.Count; i++) - { - if (_table.Rows[i].Tag is AppendedEvent evt && - evt.Context.SequenceNumber.ToString() == key) - { - _table.SelectedRowIndex = i; - return; - } - } - } - - void RefreshDetail() - { - if (_table is null || _detailPanel is null) return; - - if (_table.SelectedRow?.Tag is not AppendedEvent evt) - { - _detailPanel.Content = $"[{WorkbenchColors.Muted.ToMarkup()}]Select an event.[/]"; - return; - } - - var acc = WorkbenchColors.Accent.ToMarkup(); - var mut = WorkbenchColors.Muted.ToMarkup(); - - var lines = new List - { - $"[{mut}]Seq#[/] {evt.Context.SequenceNumber}", - $"[{mut}]Type[/] [{acc}]{evt.Context.EventType.Id}[/] gen {evt.Context.EventType.Generation}", - $"[{mut}]Source[/] {evt.Context.EventSourceId ?? "—"}", - $"[{mut}]Occurred[/] {evt.Context.Occurred}", - $"[{mut}]Correlation[/] {evt.Context.CorrelationId}", - string.Empty, - $"[{acc}]Content:[/]" - }; - - if (!string.IsNullOrEmpty(evt.Content)) - { - foreach (var line in evt.Content.Split('\n').Take(30)) - { - lines.Add($"[{mut}]{line.TrimEnd()}[/]"); - } - } - else - { - lines.Add($"[{mut}](no content)[/]"); - } - - _detailPanel.Content = string.Join('\n', lines); - } -} diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/EventSequencesView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/EventSequencesView.cs new file mode 100644 index 0000000..a639f25 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/views/EventSequencesView.cs @@ -0,0 +1,152 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using SharpConsoleUI.Layout; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Event Sequences navigation item — filterable, sortable table of recent events with a detail pane showing event content. +/// +public class EventSequencesView : FilterableTableView +{ + /// Gets the currently selected event, or if none is selected. + public AppendedEvent? SelectedEvent => SelectedItem; + + /// + /// Gets or sets the callback invoked when the user requests to view the event type definition. + /// + public Action? OnViewEventTypeDefinition { get; set; } + + /// + /// Gets or sets the callback invoked when the user requests to view observers for this event type. + /// + public Action? OnViewObserversForType { get; set; } + + /// + public override string ViewHelp => + "Shows recent events appended to the event log.\n" + + " [D] Navigate to the selected event's type definition\n" + + " [V] Find observers subscribed to this event type"; + + /// + protected override IReadOnlyList<(string Name, TextJustification Justify, int? Width)> Columns => + [ + ("#", TextJustification.Right, 14), + ("Occurred", TextJustification.Left, 22), + ("Event Type", TextJustification.Left, null), + ("Source", TextJustification.Left, 30) + ]; + + /// + protected override string DetailPanelHeader => "EVENT"; + + /// + protected override IEnumerable GetItems(WorkbenchData data) => data.RecentEvents; + + /// + protected override string GetKey(AppendedEvent item) => item.Context.SequenceNumber.ToString(); + + /// + protected override string[] BuildRow(AppendedEvent item) => + [ + item.Context.SequenceNumber.ToString().PadLeft(14), + FormatRelativeTime(item.Context.Occurred), + item.Context.EventType.Id, + item.Context.EventSourceId ?? string.Empty + ]; + + /// + protected override IEnumerable<(string Label, string? Shortcut, Action Execute)> GetContextMenuActions(AppendedEvent item) + { + if (OnViewEventTypeDefinition is not null) + { + yield return ("View event type definition", "D", () => OnViewEventTypeDefinition(item)); + } + + if (OnViewObserversForType is not null) + { + yield return ("View observers for this type", "V", () => OnViewObserversForType(item)); + } + } + + /// + protected override string GetSortValue(AppendedEvent item, int columnIndex) => columnIndex switch + { + // Sort numerically (not by display string) by padding to a fixed width. + 0 => item.Context.SequenceNumber.ToString("D20"), + + // Sort chronologically using UTC ticks, not the relative-time display string. + 1 => ((DateTimeOffset)item.Context.Occurred).UtcDateTime.Ticks.ToString("D20"), + + _ => base.GetSortValue(item, columnIndex) + }; + + /// + protected override string RenderDetail(AppendedEvent? item, WorkbenchData? data) + { + if (item is null) + { + return $"[{WorkbenchColors.Muted.ToMarkup()}]Select an event.[/]"; + } + + var acc = WorkbenchColors.Accent.ToMarkup(); + var mut = WorkbenchColors.Muted.ToMarkup(); + var contentText = !string.IsNullOrEmpty(item.Content) + ? JsonYamlFormatter.FormatAsYaml(item.Content, mut) + : $"[{mut}](no content)[/]"; + + return string.Join('\n', new[] + { + $"[{mut}]Seq#[/] {item.Context.SequenceNumber:N0}", + $"[{mut}]Type[/] [{acc}]{item.Context.EventType.Id}[/] gen {item.Context.EventType.Generation}", + $"[{mut}]Source[/] {item.Context.EventSourceId ?? "—"}", + $"[{mut}]Occurred[/] {item.Context.Occurred}", + $"[{mut}]Correlation[/] {item.Context.CorrelationId}", + string.Empty, + $"[{acc}]Content:[/]", + $" [{mut}][D][/] View event type definition", + $" [{mut}][V][/] View observers for this type", + contentText + }); + } + + /// + protected override bool MatchesFilter(AppendedEvent item, string filter) + { + if (filter.StartsWith("type:", StringComparison.OrdinalIgnoreCase)) + { + return item.Context.EventType.Id.Contains(filter[5..], StringComparison.OrdinalIgnoreCase); + } + + return item.Context.EventType.Id.Contains(filter, StringComparison.OrdinalIgnoreCase) || + (item.Context.EventSourceId ?? string.Empty).Contains(filter, StringComparison.OrdinalIgnoreCase); + } + + /// + protected override IEnumerable GetCompletions(string input) + { + var items = PendingData?.RecentEvents ?? []; + return items + .Select(e => $"type:{e.Context.EventType.Id}") + .Distinct() + .Where(c => c.Contains(input, StringComparison.OrdinalIgnoreCase)) + .Order(); + } + + /// + /// Formats a as a human-readable relative time (e.g., "3s ago", "12m ago"). + /// Falls back to an absolute format for timestamps older than 7 days. + /// + /// The event timestamp. + /// A relative or absolute time string. + static string FormatRelativeTime(DateTimeOffset occurred) + { + var diff = DateTimeOffset.UtcNow - occurred.ToUniversalTime(); + if (diff.TotalSeconds < 60) return $"{(int)diff.TotalSeconds}s ago"; + if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m ago"; + if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}h ago"; + if (diff.TotalDays < 7) return $"{(int)diff.TotalDays}d ago"; + return occurred.LocalDateTime.ToString("yyyy-MM-dd HH:mm"); + } +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/EventStoresView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/EventStoresView.cs index aaf4ff7..f771dcd 100644 --- a/Source/Cli/Commands/Chronicle/Workbench/views/EventStoresView.cs +++ b/Source/Cli/Commands/Chronicle/Workbench/views/EventStoresView.cs @@ -18,6 +18,9 @@ public class EventStoresView : IWorkbenchView MarkupControl? _helpPane; WorkbenchData? _pendingData; + /// + public bool IsActive { get; set; } + /// /// Gets or sets the callback invoked when the user switches to a different event store. /// diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/EventTypesView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/EventTypesView.cs index 5cbb78c..69d01ac 100644 --- a/Source/Cli/Commands/Chronicle/Workbench/views/EventTypesView.cs +++ b/Source/Cli/Commands/Chronicle/Workbench/views/EventTypesView.cs @@ -1,224 +1,121 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -using SharpConsoleUI; -using SharpConsoleUI.Builders; using SharpConsoleUI.Controls; -using UITableRow = SharpConsoleUI.Controls.TableRow; +using SharpConsoleUI.Layout; namespace Cratis.Cli.Commands.Chronicle.Workbench; /// -/// Event Types navigation item — filterable table of registered event types with schema details in the bordered right pane. +/// Event Types navigation item — filterable table of registered event types with schema details in the right pane. /// -public class EventTypesView : IWorkbenchView +public class EventTypesView : FilterableTableView { - TableControl? _table; - PanelControl? _detailPanel; - PromptControl? _filterPrompt; - string _currentFilter = string.Empty; - List _allItems = []; - WorkbenchData? _pendingData; + /// Gets the currently selected event type registration, or if none is selected. + public EventTypeRegistration? SelectedEventType => SelectedItem; /// - /// Gets or sets the callback invoked when the filter input gains or loses focus. + /// Gets or sets the callback invoked when the user requests to view observers for the selected event type. /// - public Action? OnFilterFocusChanged { get; set; } + public Action? OnViewObservers { get; set; } /// - public void Dispose() - { - _table?.Dispose(); - _detailPanel?.Dispose(); - _filterPrompt?.Dispose(); - } + public override string ViewHelp => + "Lists all registered event types and their schemas.\n" + + " [V] Find observers subscribed to the selected event type"; /// - public IWindowControl BuildContent(ConsoleWindowSystem windowSystem) - { - _table = Controls.Table() - .AddColumn("Id", SharpConsoleUI.Layout.TextJustification.Left, null) - .AddColumn("Gen", SharpConsoleUI.Layout.TextJustification.Right, 6) - .AddColumn("Owner", SharpConsoleUI.Layout.TextJustification.Left, 20) - .Interactive() - .WithSorting() - .WithVerticalScrollbar(ScrollbarVisibility.Auto) - .OnSelectedRowChanged((_, _) => RefreshDetail()) - .WithName("EventTypesTable") - .Build(); - - _filterPrompt = Controls.Prompt("Filter: ") - .WithHistory(true) - .WithTabCompleter((input, _) => GetCompletions()) - .OnInputChanged((_, text) => - { - _currentFilter = text ?? string.Empty; - RebuildFilteredRows(); - }) - .OnGotFocus((_, _) => OnFilterFocusChanged?.Invoke(true)) - .OnLostFocus((_, _) => OnFilterFocusChanged?.Invoke(false)) - .WithName("EventTypesFilterPrompt") - .Build(); - - var leftPane = Controls.ScrollablePanel() - .AddControl(_filterPrompt) - .AddControl(_table) - .WithVerticalScroll(ScrollMode.None) - .WithName("EventTypesLeftPane") - .Build(); - - _detailPanel = Controls.Panel() - .WithContent($"[{WorkbenchColors.Muted.ToMarkup()}]Select an event type.[/]") - .WithHeader(" EVENT TYPE ") - .Rounded() - .WithBorderColor(WorkbenchColors.Accent) - .WithPadding(1, 0, 1, 0) - .FillVertical() - .WithName("EventTypeDetailPanel") - .Build(); - - var root = HorizontalGridControl.Create() - .Column(c => c.Add(leftPane)) - .WithSplitterAfter(0) - .Column(c => c.Width(55).Add(_detailPanel)) - .Build(); - - // Apply any data that arrived before controls were ready (NavigationView lazy init). - if (_pendingData is not null) - UpdateData(_pendingData); - - return root; - } + protected override IReadOnlyList<(string Name, TextJustification Justify, int? Width)> Columns => + [ + ("Id", TextJustification.Left, null), + ("Gen", TextJustification.Right, 6), + ("Owner", TextJustification.Left, 20) + ]; /// - public void ActivateFilter(Window window) - { - if (_filterPrompt is not null) - { - window.FocusControl(_filterPrompt); - } - } + protected override string DetailPanelHeader => "EVENT TYPE"; /// - public void ClearFilter() - { - _currentFilter = string.Empty; - _filterPrompt?.SetInput(string.Empty); - RebuildFilteredRows(); - } + protected override int DefaultSortColumn => 0; /// - public void UpdateData(WorkbenchData data) - { - _pendingData = data; - if (_table is null) return; - - _allItems = [.. data.EventTypeRegistrations.OrderBy(r => r.Type.Id).ThenBy(r => r.Type.Generation)]; - RebuildFilteredRows(); - } + protected override SortDirection DefaultSortDirection => SortDirection.Ascending; - static IEnumerable GetCompletions() => - [ - "owner:client", - "owner:server", - "gen:1", - "gen:2" - ]; + /// + protected override bool IsSortableColumn(int columnIndex) => columnIndex == 0; - bool MatchesFilter(EventTypeRegistration reg) + /// + protected override IEnumerable<(string Label, string? Shortcut, Action Execute)> GetContextMenuActions(EventTypeRegistration item) { - if (string.IsNullOrEmpty(_currentFilter)) return true; - - var f = _currentFilter; - - if (f.StartsWith("owner:", StringComparison.OrdinalIgnoreCase)) - { - var owner = f[6..]; - return reg.Owner.ToString().Contains(owner, StringComparison.OrdinalIgnoreCase); - } - - if (f.StartsWith("gen:", StringComparison.OrdinalIgnoreCase)) + if (OnViewObservers is not null) { - var gen = f[4..]; - return reg.Type.Generation.ToString().Contains(gen, StringComparison.OrdinalIgnoreCase); + yield return ("View observers for this type", "V", () => OnViewObservers(item)); } - - return reg.Type.Id.Contains(f, StringComparison.OrdinalIgnoreCase); } - void RebuildFilteredRows() - { - if (_table is null) return; - - var selectedKey = _table.SelectedRow?.Tag is EventTypeRegistration sel - ? $"{sel.Type.Id}+{sel.Type.Generation}" - : null; - - _table.ClearRows(); - foreach (var reg in _allItems.Where(MatchesFilter)) - { - _table.AddRow(new UITableRow([reg.Type.Id, reg.Type.Generation.ToString(), reg.Owner.ToString()]) { Tag = reg }); - } - - if (selectedKey is not null) - { - RestoreSelection(selectedKey); - } - - RefreshDetail(); - } + /// + protected override IEnumerable GetItems(WorkbenchData data) => + data.EventTypeRegistrations.OrderBy(r => r.Type.Id).ThenBy(r => r.Type.Generation); - void RestoreSelection(string key) - { - if (_table is null) return; + /// + protected override string GetKey(EventTypeRegistration item) => $"{item.Type.Id}+{item.Type.Generation}"; - for (var i = 0; i < _table.Rows.Count; i++) - { - if (_table.Rows[i].Tag is EventTypeRegistration reg && - $"{reg.Type.Id}+{reg.Type.Generation}" == key) - { - _table.SelectedRowIndex = i; - return; - } - } - } + /// + protected override string[] BuildRow(EventTypeRegistration item) => + [item.Type.Id, item.Type.Generation.ToString().PadLeft(6), item.Owner.ToString()]; - void RefreshDetail() + /// + protected override string RenderDetail(EventTypeRegistration? item, WorkbenchData? data) { - if (_table is null || _detailPanel is null) return; - - if (_table.SelectedRow?.Tag is not EventTypeRegistration reg) + if (item is null) { - _detailPanel.Content = $"[{WorkbenchColors.Muted.ToMarkup()}]Select an event type.[/]"; - return; + return $"[{WorkbenchColors.Muted.ToMarkup()}]Select an event type.[/]"; } var acc = WorkbenchColors.Accent.ToMarkup(); var mut = WorkbenchColors.Muted.ToMarkup(); + var schemaContent = !string.IsNullOrEmpty(item.Schema) + ? JsonYamlFormatter.FormatAsYaml(item.Schema, mut) + : $"[{mut}](no schema)[/]"; - var lines = new List + return string.Join('\n', new[] { - $"[{mut}]Id[/] {reg.Type.Id}", - $"[{mut}]Generation[/] {reg.Type.Generation}", - $"[{mut}]Owner[/] {reg.Owner}", - $"[{mut}]Source[/] {reg.Source}", - $"[{mut}]Tombstone[/] {reg.Type.Tombstone}", + $"[{mut}]Id[/] {item.Type.Id}", + $"[{mut}]Generation[/] {item.Type.Generation}", + $"[{mut}]Owner[/] {item.Owner}", + $"[{mut}]Source[/] {item.Source}", + $"[{mut}]Tombstone[/] {item.Type.Tombstone}", string.Empty, - $"[{acc}]Schema:[/]" - }; + $"[{acc}]Schema:[/]", + schemaContent, + string.Empty, + $"[{acc}]Actions:[/]", + $" [{mut}][V][/] View observers for this type" + }); + } - if (!string.IsNullOrEmpty(reg.Schema)) + /// + protected override bool MatchesFilter(EventTypeRegistration item, string filter) + { + if (filter.StartsWith("owner:", StringComparison.OrdinalIgnoreCase)) { - foreach (var line in reg.Schema.Split('\n').Take(40)) - { - lines.Add($"[{mut}]{line.TrimEnd()}[/]"); - } + return item.Owner.ToString().Contains(filter[6..], StringComparison.OrdinalIgnoreCase); } - else + + if (filter.StartsWith("gen:", StringComparison.OrdinalIgnoreCase)) { - lines.Add($"[{mut}](no schema)[/]"); + return item.Type.Generation.ToString().Contains(filter[4..], StringComparison.OrdinalIgnoreCase); } - _detailPanel.Content = string.Join('\n', lines); + return item.Type.Id.Contains(filter, StringComparison.OrdinalIgnoreCase); } + + /// + protected override IEnumerable GetCompletions(string input) => + [ + "owner:client", + "owner:server", + "gen:1", + "gen:2" + ]; } diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/FailedPartitionsView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/FailedPartitionsView.cs index 5338a8f..2ad2648 100644 --- a/Source/Cli/Commands/Chronicle/Workbench/views/FailedPartitionsView.cs +++ b/Source/Cli/Commands/Chronicle/Workbench/views/FailedPartitionsView.cs @@ -1,29 +1,17 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -using SharpConsoleUI; -using SharpConsoleUI.Builders; -using SharpConsoleUI.Controls; -using UITableRow = SharpConsoleUI.Controls.TableRow; +using SharpConsoleUI.Layout; namespace Cratis.Cli.Commands.Chronicle.Workbench; /// -/// Failed Partitions navigation item — filterable table of failed partitions with retry/replay actions in the bordered detail pane. +/// Failed Partitions navigation item — filterable table of failed partitions with retry/replay actions. /// -public class FailedPartitionsView : IWorkbenchView +public class FailedPartitionsView : FilterableTableView { - TableControl? _table; - PanelControl? _detailPanel; - PromptControl? _filterPrompt; - string _currentFilter = string.Empty; - List _allItems = []; - WorkbenchData? _pendingData; - - /// - /// Gets or sets the callback invoked when the filter input gains or loses focus. - /// - public Action? OnFilterFocusChanged { get; set; } + /// Gets the currently selected failed partition, or if none is selected. + public FailedPartition? SelectedPartition => SelectedItem; /// /// Gets or sets the callback invoked when the user requests a partition retry. @@ -46,157 +34,69 @@ public class FailedPartitionsView : IWorkbenchView public Action>? OnReplayAll { get; set; } /// - /// Returns all failed partitions that are currently checked (checkbox mode). + /// Gets all failed partitions that are currently checked (checkbox mode). /// - /// A list of checked items. - public IReadOnlyList GetCheckedItems() => - [.. (_table?.GetCheckedRows() ?? []).Select(r => r.Tag).OfType()]; + public IReadOnlyList Checked => CheckedItems; /// - public void Dispose() - { - _table?.Dispose(); - _detailPanel?.Dispose(); - _filterPrompt?.Dispose(); - } + protected override IReadOnlyList<(string Name, TextJustification Justify, int? Width)> Columns => + [ + ("Observer", TextJustification.Left, null), + ("Partition", TextJustification.Left, 30), + ("Attempts", TextJustification.Right, 10) + ]; /// - public IWindowControl BuildContent(ConsoleWindowSystem windowSystem) - { - _table = Controls.Table() - .AddColumn("Observer", SharpConsoleUI.Layout.TextJustification.Left, null) - .AddColumn("Partition", SharpConsoleUI.Layout.TextJustification.Left, 30) - .AddColumn("Attempts", SharpConsoleUI.Layout.TextJustification.Right, 10) - .Interactive() - .WithCheckboxMode() - .WithSorting() - .WithVerticalScrollbar(ScrollbarVisibility.Auto) - .OnSelectedRowChanged((_, _) => RefreshDetail()) - .WithName("FailedPartitionsTable") - .Build(); - - _filterPrompt = Controls.Prompt("Filter: ") - .WithHistory(true) - .OnInputChanged((_, text) => - { - _currentFilter = text ?? string.Empty; - RebuildFilteredRows(); - }) - .OnGotFocus((_, _) => OnFilterFocusChanged?.Invoke(true)) - .OnLostFocus((_, _) => OnFilterFocusChanged?.Invoke(false)) - .WithName("FailedPartitionsFilterPrompt") - .Build(); - - var leftPane = Controls.ScrollablePanel() - .AddControl(_filterPrompt) - .AddControl(_table) - .WithVerticalScroll(ScrollMode.None) - .WithName("FailedPartitionsLeftPane") - .Build(); - - _detailPanel = Controls.Panel() - .WithContent($"[{WorkbenchColors.Muted.ToMarkup()}]Select a failed partition.[/]") - .WithHeader(" FAILED PARTITION ") - .Rounded() - .WithBorderColor(WorkbenchColors.Danger) - .WithPadding(1, 0, 1, 0) - .FillVertical() - .WithName("FailedPartitionDetailPanel") - .Build(); - - var root = HorizontalGridControl.Create() - .Column(c => c.Add(leftPane)) - .WithSplitterAfter(0) - .Column(c => c.Width(50).Add(_detailPanel)) - .Build(); - - // Apply any data that arrived before controls were ready (NavigationView lazy init). - if (_pendingData is not null) - UpdateData(_pendingData); - - return root; - } + protected override string DetailPanelHeader => "FAILED PARTITION"; /// - public void ActivateFilter(Window window) - { - if (_filterPrompt is not null) - { - window.FocusControl(_filterPrompt); - } - } + protected override SharpConsoleUI.Color DetailBorderColor => WorkbenchColors.Danger; /// - public void ClearFilter() - { - _currentFilter = string.Empty; - _filterPrompt?.SetInput(string.Empty); - RebuildFilteredRows(); - } + protected override bool HasCheckboxMode => true; /// - public void UpdateData(WorkbenchData data) - { - _pendingData = data; - if (_table is null) return; - - _allItems = [.. data.FailedPartitions.OrderByDescending(p => p.Attempts.Count())]; - RebuildFilteredRows(); - } - - bool MatchesFilter(FailedPartition fp) + protected override IEnumerable<(string Label, string? Shortcut, Action Execute)> GetContextMenuActions(FailedPartition item) { - if (string.IsNullOrEmpty(_currentFilter)) return true; - - return fp.ObserverId.Contains(_currentFilter, StringComparison.OrdinalIgnoreCase) || - fp.Partition.Contains(_currentFilter, StringComparison.OrdinalIgnoreCase); - } - - void RebuildFilteredRows() - { - if (_table is null) return; - - var selectedKey = _table.SelectedRow?.Tag is FailedPartition sel - ? $"{sel.ObserverId}/{sel.Partition}" - : null; - - _table.ClearRows(); - foreach (var fp in _allItems.Where(MatchesFilter)) + if (OnRetryPartition is not null) { - _table.AddRow(new UITableRow([fp.ObserverId, fp.Partition, fp.Attempts.Count().ToString()]) { Tag = fp }); + yield return ("Retry partition", "T", () => OnRetryPartition(item)); } - if (selectedKey is not null) + if (OnReplayPartition is not null) { - RestoreSelection(selectedKey); + yield return ("Replay partition", "P", () => OnReplayPartition(item)); } - RefreshDetail(); - } - - void RestoreSelection(string key) - { - if (_table is null) return; + var checkedCount = Checked.Count; + if (OnRetryAll is not null && checkedCount > 1) + { + yield return ($"Retry {checkedCount} checked", null, () => OnRetryAll(Checked)); + } - for (var i = 0; i < _table.Rows.Count; i++) + if (OnReplayAll is not null && checkedCount > 1) { - if (_table.Rows[i].Tag is FailedPartition fp && - $"{fp.ObserverId}/{fp.Partition}" == key) - { - _table.SelectedRowIndex = i; - return; - } + yield return ($"Replay {checkedCount} checked", null, () => OnReplayAll(Checked)); } } - void RefreshDetail() - { - if (_table is null || _detailPanel is null) return; + /// + protected override IEnumerable GetItems(WorkbenchData data) => + data.FailedPartitions.OrderByDescending(p => p.Attempts.Count()); + + /// + protected override string GetKey(FailedPartition item) => $"{item.ObserverId}/{item.Partition}"; - if (_table.SelectedRow?.Tag is not FailedPartition fp) + /// + protected override string[] BuildRow(FailedPartition item) => + [item.ObserverId, item.Partition, item.Attempts.Count().ToString().PadLeft(10)]; + + /// + protected override string RenderDetail(FailedPartition? item, WorkbenchData? data) + { + if (item is null) { - _detailPanel.Content = $"[{WorkbenchColors.Muted.ToMarkup()}]Select a failed partition.[/]"; - return; + return $"[{WorkbenchColors.Muted.ToMarkup()}]Select a failed partition.[/]"; } var mut = WorkbenchColors.Muted.ToMarkup(); @@ -205,14 +105,14 @@ void RefreshDetail() var lines = new List { - $"[{mut}]Observer[/] {fp.ObserverId}", - $"[{mut}]Partition[/] [{dan}]{fp.Partition}[/]", - $"[{mut}]Attempts[/] {fp.Attempts.Count()}", + $"[{mut}]Observer[/] {item.ObserverId}", + $"[{mut}]Partition[/] [{dan}]{item.Partition}[/]", + $"[{mut}]Attempts[/] {item.Attempts.Count()}", string.Empty, $"[{acc}]Last Attempts:[/]" }; - foreach (var attempt in fp.Attempts.OrderByDescending(a => a.Occurred).Take(5)) + foreach (var attempt in item.Attempts.OrderByDescending(a => a.Occurred).Take(5)) { lines.Add($" [{mut}]{attempt.Occurred}[/]"); var firstMessage = attempt.Messages?.FirstOrDefault(); @@ -226,10 +126,22 @@ void RefreshDetail() if (OnRetryPartition is not null || OnReplayPartition is not null) { lines.Add(string.Empty); - if (OnRetryPartition is not null) lines.Add($"[{mut}]Press[/] [bold]T[/] [{mut}]to retry[/]"); - if (OnReplayPartition is not null) lines.Add($"[{mut}]Press[/] [bold]P[/] [{mut}]to replay[/]"); + if (OnRetryPartition is not null) + { + lines.Add($"[{mut}]Press[/] [bold]T[/] [{mut}]to retry[/]"); + } + + if (OnReplayPartition is not null) + { + lines.Add($"[{mut}]Press[/] [bold]P[/] [{mut}]to replay[/]"); + } } - _detailPanel.Content = string.Join('\n', lines); + return string.Join('\n', lines); } + + /// + protected override bool MatchesFilter(FailedPartition item, string filter) => + item.ObserverId.Contains(filter, StringComparison.OrdinalIgnoreCase) || + item.Partition.Contains(filter, StringComparison.OrdinalIgnoreCase); } diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/FilterableTableView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/FilterableTableView.cs new file mode 100644 index 0000000..24e9f01 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/views/FilterableTableView.cs @@ -0,0 +1,738 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.ComponentModel; +using SharpConsoleUI; +using SharpConsoleUI.Animation; +using SharpConsoleUI.Builders; +using SharpConsoleUI.Controls; +using SharpConsoleUI.Events; +using SharpConsoleUI.Helpers; +using SharpConsoleUI.Layout; +using UITableRow = SharpConsoleUI.Controls.TableRow; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Abstract base for workbench views that display a filterable, sortable table with a detail panel. +/// Subclasses only implement domain-specific concerns — all table/filter/selection/pagination boilerplate lives here. +/// +/// The domain item type displayed in each row. +public abstract class FilterableTableView : IWorkbenchView +{ + /// + /// UI rows consumed by non-table chrome: title bar, filter prompt, page nav, status bar, and borders. + /// Subtracted from terminal height to compute the usable table row count. + /// + const int NonTableRowOverhead = 16; + + /// + /// Minimum number of table rows to show regardless of terminal height. + /// + const int MinPageSize = 5; + + ConsoleWindowSystem? _windowSystem; + HorizontalGridControl? _root; + TableControl? _table; + PanelControl? _detailPanel; + PromptControl? _filterPrompt; + MarkupControl? _pageIndicator; + ButtonControl? _prevPageButton; + ButtonControl? _nextPageButton; + bool _detailPaneVisible = true; + WorkbenchData? _pendingData; + string _currentFilter = string.Empty; + List _allItems = []; + int _pageIndex; + int _sortColumnIndex = -1; + SortDirection _sortDirection = SortDirection.None; + int _lastSetSortColumn = -1; + SortDirection _lastSetSortDirection = SortDirection.None; + bool _suppressSortSync; + + /// + public Action? OnFilterFocusChanged { get; set; } + + /// + public bool IsActive { get; set; } + + /// + /// Gets the primary focus target for this view (the table itself; use F to reach the filter bar). + /// + public IWindowControl? PrimaryFocusTarget => _table; + + /// + public string? DetailContent => _detailPanel?.Content; + + /// + /// Gets the per-view help text shown in the help overlay. + /// Override to provide a brief description and a list of view-specific shortcuts. + /// + public virtual string ViewHelp => string.Empty; + + /// + /// Gets column definitions: (name, justification, fixed width or null for flex). + /// + protected abstract IReadOnlyList<(string Name, TextJustification Justify, int? Width)> Columns { get; } + + /// + /// Gets the header label shown on the right detail panel. + /// + protected virtual string DetailPanelHeader => "DETAIL"; + + /// + /// Gets the border color for the right detail panel. + /// + protected virtual SharpConsoleUI.Color DetailBorderColor => WorkbenchColors.Accent; + + /// + /// Gets a value indicating whether to enable checkbox multi-select mode on the table. + /// + protected virtual bool HasCheckboxMode => false; + + /// + /// Gets the zero-based column index to sort by when the view is first displayed. + /// Return -1 (default) for no initial sort. + /// + protected virtual int DefaultSortColumn => -1; + + /// + /// Gets the sort direction applied alongside on first display. + /// + protected virtual SortDirection DefaultSortDirection => SortDirection.None; + + /// + /// Gets the width of the right-hand detail pane in character columns. + /// Defaults to one-third of the terminal width, with a minimum of 30. + /// Subclasses may override to fix or adjust the width for their content. + /// + protected virtual int DetailPaneWidth => Math.Max(30, Console.WindowWidth / 3); + + /// + /// Gets the pending data snapshot (populated during and after ). + /// + protected WorkbenchData? PendingData => _pendingData; + + /// + /// Gets the currently selected item, or if no row is selected. + /// + protected TItem? SelectedItem => + _table?.SelectedRow?.Tag is TItem item ? item : default; + + /// + /// Gets all items that are currently checked (checkbox mode only). + /// + protected IReadOnlyList CheckedItems + { + get + { + if (_table is null) + { + return []; + } + + return [.. _table.GetCheckedRows().Select(r => r.Tag).OfType()]; + } + } + + /// + /// Number of table rows visible per page, computed from the current terminal height. + /// + int PageSize => Math.Max(MinPageSize, Console.WindowHeight - NonTableRowOverhead); + + /// + public virtual IWindowControl BuildContent(ConsoleWindowSystem windowSystem) + { + _windowSystem = windowSystem; + var tableBuilder = Controls.Table(); + + foreach (var (name, justify, width) in Columns) + { + tableBuilder.AddColumn(name, justify, width); + } + + tableBuilder = tableBuilder + .Interactive() + .WithSorting() + .WithVerticalScrollbar(ScrollbarVisibility.Auto) + .OnSelectedRowChanged((_, _) => RefreshDetail()) + .OnRowActivated((_, _) => ActivateSelected()) + .WithName($"{GetType().Name}Table"); + + if (HasCheckboxMode) + { + tableBuilder = tableBuilder.WithCheckboxMode(); + } + + _table = tableBuilder.Build(); + + _sortColumnIndex = DefaultSortColumn; + _sortDirection = DefaultSortDirection; + _lastSetSortColumn = _sortColumnIndex; + _lastSetSortDirection = _sortDirection; + + _table.PropertyChanged += OnTablePropertyChanged; + _table.MouseRightClick += OnTableRightClick; + + _pageIndicator = new MarkupControl([string.Empty]) { Name = $"{GetType().Name}Page" }; + + _prevPageButton = Controls.Button(" ◄ ") + .OnClick((_, _) => PreviousPage()) + .WithName($"{GetType().Name}PrevPage") + .Build(); + + _nextPageButton = Controls.Button(" ► ") + .OnClick((_, _) => NextPage()) + .WithName($"{GetType().Name}NextPage") + .Build(); + + _filterPrompt = Controls.Prompt("🔍 Filter: ") + .WithHistory(true) + .WithTabCompleter((input, _) => GetCompletions(input)) + .OnInputChanged((_, text) => + { + _currentFilter = text ?? string.Empty; + _pageIndex = 0; + RebuildRows(); + }) + .OnGotFocus((_, _) => OnFilterFocusChanged?.Invoke(true)) + .OnLostFocus((_, _) => OnFilterFocusChanged?.Invoke(false)) + .WithName($"{GetType().Name}Filter") + .Build(); + + _detailPanel = Controls.Panel() + .WithContent($"[{WorkbenchColors.Muted.ToMarkup()}]Select an item.[/]") + .WithHeader($" {DetailPanelHeader} ") + .Rounded() + .WithBorderColor(DetailBorderColor) + .WithPadding(1, 0, 1, 0) + .FillVertical() + .WithName($"{GetType().Name}Detail") + .Build(); + + var pageNavRow = HorizontalGridControl.Create() + .Column(c => c.Width(5).Add(_prevPageButton)) + .Column(c => c.Add(_pageIndicator)) + .Column(c => c.Width(5).Add(_nextPageButton)) + .Build(); + + var leftPane = Controls.ScrollablePanel() + .AddControl(_filterPrompt) + .AddControl(_table) + .AddControl(pageNavRow) + .WithVerticalScroll(ScrollMode.None) + .Build(); + + _root = HorizontalGridControl.Create() + .Column(c => c.Add(leftPane)) + .WithSplitterAfter(0) + .Column(c => c.Width(DetailPaneWidth).Add(_detailPanel)) + .Build(); + + if (_pendingData is not null) + { + // Force-rebuild even if IsActive is already true — BuildContent runs during NavigationView + // lazy init, which may happen after IsActive is set. + var wasActive = IsActive; + IsActive = false; + UpdateData(_pendingData); + IsActive = wasActive; + _filterPrompt.Input = _currentFilter; + } + + return _root; + } + + /// + public void UpdateData(WorkbenchData data) + { + _pendingData = data; + + if (_table is null) + { + return; + } + + _allItems = [.. GetItems(data)]; + + if (!IsActive) + { + RebuildRows(); + } + } + + /// + public void ActivateFilter(Window window) + { + if (_filterPrompt is not null) + { + window.FocusControl(_filterPrompt); + } + } + + /// + public void ClearFilter() + { + _currentFilter = string.Empty; + SetFilterInput(string.Empty); + RebuildRows(); + } + + /// + public void ToggleDetailPane() + { + if (_root is null) + { + return; + } + + _detailPaneVisible = !_detailPaneVisible; + var targetWidth = _detailPaneVisible ? DetailPaneWidth : 0; + _root.AnimateColumnWidth(1, targetWidth, TimeSpan.FromMilliseconds(180), EasingFunctions.EaseInOut); + } + + /// + public void Dispose() + { + UnsubscribeTableEvents(); + _root?.Dispose(); + _table?.Dispose(); + _detailPanel?.Dispose(); + _filterPrompt?.Dispose(); + _pageIndicator?.Dispose(); + _prevPageButton?.Dispose(); + _nextPageButton?.Dispose(); + } + + /// + /// Sets the filter text and rebuilds the table rows. Can be called externally to pre-filter the view. + /// + /// The filter string to apply. + public void SetFilter(string key) + { + _currentFilter = key; + _pageIndex = 0; + SetFilterInput(key); + RebuildRows(); + } + + /// + public void NextPage() + { + if (_table is null) + { + return; + } + + var totalPages = ComputeTotalPages(GetFiltered().Count); + if (_pageIndex < totalPages - 1) + { + _pageIndex++; + RebuildRows(); + } + } + + /// + public void PreviousPage() + { + if (_table is null || _pageIndex <= 0) + { + return; + } + + _pageIndex--; + RebuildRows(); + } + + /// + /// Extracts the relevant items from the snapshot. + /// + /// The current workbench data snapshot. + /// The items to display in the table. + protected abstract IEnumerable GetItems(WorkbenchData data); + + /// + /// Returns a stable string key that uniquely identifies this item (used for selection restore). + /// + /// The item. + /// A unique string key. + protected abstract string GetKey(TItem item); + + /// + /// Returns cell values for the table row (must match count). + /// + /// The item to render. + /// Cell values for each column, may contain SharpConsoleUI markup. + protected abstract string[] BuildRow(TItem item); + + /// + /// Returns the markup string shown in the right detail panel for the selected item. + /// + /// The selected item, or if nothing is selected. + /// The current workbench data snapshot. + /// Markup content for the detail panel. + protected abstract string RenderDetail(TItem? item, WorkbenchData? data); + + /// + /// Returns true if the item matches the given filter text. + /// + /// The item to test. + /// The current filter string. + /// if the item passes the filter. + protected abstract bool MatchesFilter(TItem item, string filter); + + /// + /// Returns the plain-text value used for sorting column . + /// Default: strips markup from the corresponding cell. + /// Subclasses may override to provide numeric-aware or custom sort keys. + /// + /// The item to sort. + /// The zero-based column index being sorted. + /// A plain-text sort key. + protected virtual string GetSortValue(TItem item, int columnIndex) + { + var cells = BuildRow(item); + return columnIndex < cells.Length ? Markup.Remove(cells[columnIndex]) : string.Empty; + } + + /// + /// Tab-completion tokens for the filter prompt. Default: none. + /// + /// The current user input. + /// Completion suggestions. + protected virtual IEnumerable GetCompletions(string input) => []; + + /// + /// Called when Enter is pressed on a row (or row double-clicked). Default: no-op. + /// + /// The activated item. + protected virtual void OnRowActivated(TItem item) + { + } + + /// + /// Returns the context-menu actions available when the user right-clicks the given item. + /// The base implementation returns an empty sequence — override to provide view-specific actions. + /// + /// The row item that was right-clicked. + /// + /// A sequence of (Label, Shortcut, Execute) tuples. Shortcut may be . + /// + protected virtual IEnumerable<(string Label, string? Shortcut, Action Execute)> GetContextMenuActions(TItem item) => []; + + /// + /// Returns when the user may sort by the given column index. + /// Override to restrict sorting to specific columns only. + /// + /// The zero-based column index to test. + /// if the column is sortable; otherwise. + protected virtual bool IsSortableColumn(int columnIndex) => true; + + static int ContextMenuWidth(List<(string Label, string? Shortcut, Action Execute)> actions) + { + var maxLabel = actions.Max(a => a.Label.Length); + var maxShortcut = actions.Max(a => a.Shortcut?.Length ?? 0); + return maxLabel + (maxShortcut > 0 ? maxShortcut + 4 : 0) + 4; + } + + static void SetButtonEnabled(ButtonControl? button, bool enabled) + { + if (button is null) + { + return; + } + + button.IsEnabled = enabled; + } + + int ComputeTotalPages(int itemCount) + { + var pageSize = PageSize; + return Math.Max(1, (itemCount + pageSize - 1) / pageSize); + } + + List GetFiltered() => + string.IsNullOrEmpty(_currentFilter) + ? _allItems + : [.. _allItems.Where(item => MatchesFilter(item, _currentFilter))]; + + void SetFilterInput(string value) + { + if (_filterPrompt is null) + { + return; + } + + _filterPrompt.Input = value; + } + + void OnTableRightClick(object? sender, MouseEventArgs e) + { + if (_windowSystem is null || SelectedItem is not TItem item) + { + return; + } + + var actions = GetContextMenuActions(item).ToList(); + if (actions.Count == 0) + { + return; + } + + ShowContextMenu(e.AbsolutePosition.X, e.AbsolutePosition.Y, actions); + } + + void ShowContextMenu(int x, int y, List<(string Label, string? Shortcut, Action Execute)> actions) + { + if (_windowSystem is null) + { + return; + } + + var menuBuilder = Controls.Menu().Vertical() + .WithMenuBarColors(WorkbenchColors.Background, WorkbenchColors.Foreground, WorkbenchColors.Accent, WorkbenchColors.Background) + .WithDropdownColors(WorkbenchColors.Background, WorkbenchColors.Foreground, WorkbenchColors.Accent, WorkbenchColors.Background); + + foreach (var (label, shortcut, execute) in actions) + { + menuBuilder.AddItem(label, shortcut ?? string.Empty, execute); + } + + var menu = menuBuilder.Build(); + Window? contextWindow = null; + + menu.ItemSelected += (_, _) => _windowSystem.CloseWindow(contextWindow, activateParent: true, force: false); + + var width = Math.Max(20, ContextMenuWidth(actions)); + var height = actions.Count + 2; + var clampedX = Math.Max(0, Math.Min(x, Console.WindowWidth - width)); + var clampedY = Math.Max(0, Math.Min(y, Console.WindowHeight - height)); + + contextWindow = new WindowBuilder(_windowSystem) + .WithTitle(string.Empty) + .HideTitle() + .HideCloseButton() + .WithColors(WorkbenchColors.Foreground, WorkbenchColors.Background) + .WithSize(width, height) + .AtPosition(clampedX, clampedY) + .WithCloseOnDeactivate(true) + .AddControl(menu) + .OnKeyPressed((_, ke) => + { + if (ke.KeyInfo.Key == ConsoleKey.Escape) + { + _windowSystem.CloseWindow(contextWindow, activateParent: true, force: false); + ke.Handled = true; + } + }) + .Build(); + + _windowSystem.AddWindow(contextWindow, activateWindow: true); + } + + void UnsubscribeTableEvents() + { + if (_table is null) + { + return; + } + + _table.PropertyChanged -= OnTablePropertyChanged; + _table.MouseRightClick -= OnTableRightClick; + } + + /// + /// Responds to PropertyChanged events on the table — specifically SortColumnIndex and + /// CurrentSortDirection which fire when the user clicks a column header. Captures the new + /// sort intent, rejects sorts on columns where returns , + /// and immediately triggers a full-dataset rebuild so the correct order is shown + /// without waiting for the next data refresh cycle. + /// + /// The event source. + /// The property changed event args containing the changed property name. + void OnTablePropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (_suppressSortSync) + { + return; + } + + if (e.PropertyName is not (nameof(TableControl.SortColumnIndex) or nameof(TableControl.CurrentSortDirection))) + { + return; + } + + var newCol = _table!.SortColumnIndex; + var newDir = _table.CurrentSortDirection; + + if (newCol >= 0 && !IsSortableColumn(newCol)) + { + RestoreSortIndicator(); + return; + } + + _sortColumnIndex = newCol; + _sortDirection = newDir; + _lastSetSortColumn = _sortColumnIndex; + _lastSetSortDirection = _sortDirection; + RebuildRows(); + } + + /// + /// Sorts by the active sort column, falling back to + /// and when no user-initiated sort is active. + /// Sorting is applied to the full dataset before pagination so cross-page order is consistent. + /// + /// The filtered item list to sort. + List ApplySort(List items) + { + var col = _sortColumnIndex >= 0 ? _sortColumnIndex : DefaultSortColumn; + var dir = _sortDirection != SortDirection.None ? _sortDirection : DefaultSortDirection; + + if (col < 0 || dir == SortDirection.None) + { + return items; + } + + return dir == SortDirection.Ascending + ? [.. items.OrderBy(i => GetSortValue(i, col), StringComparer.OrdinalIgnoreCase)] + : [.. items.OrderByDescending(i => GetSortValue(i, col), StringComparer.OrdinalIgnoreCase)]; + } + + void RebuildRows() + { + if (_table is null) + { + return; + } + + SyncSortFromTable(); + + var selectedKey = SelectedItem is TItem sel ? GetKey(sel) : null; + var pageSize = PageSize; + var filtered = GetFiltered(); + var sorted = ApplySort(filtered); + var totalPages = ComputeTotalPages(sorted.Count); + + if (_pageIndex >= totalPages) + { + _pageIndex = totalPages - 1; + } + + _table.ClearRows(); + + foreach (var item in sorted.Skip(_pageIndex * pageSize).Take(pageSize)) + { + _table.AddRow(new UITableRow(BuildRow(item)) { Tag = item }); + } + + RestoreSortIndicator(); + + if (selectedKey is not null) + { + RestoreSelection(selectedKey); + } + + UpdatePageIndicator(sorted.Count, totalPages, pageSize); + RefreshDetail(); + } + + /// + /// Detects sort changes made by clicking column headers (SharpConsoleUI updates SortColumnIndex + /// immediately on click) and persists the new intent into our own fields so it survives across rebuilds. + /// + void SyncSortFromTable() + { + var tableCol = _table!.SortColumnIndex; + var tableDir = _table.CurrentSortDirection; + + // A difference from what we last set means the user changed the sort via column header. + if (tableCol != _lastSetSortColumn || tableDir != _lastSetSortDirection) + { + _sortColumnIndex = tableCol; + _sortDirection = tableDir; + } + } + + /// + /// Restores the sort direction indicator on the column header. + /// The rows are already in sorted order from ; SharpConsoleUI's + /// in-table re-sort on the current page is a stable no-op. + /// Uses to prevent the PropertyChanged callbacks + /// fired by ClearSort and SortByColumn from triggering a recursive rebuild. + /// + void RestoreSortIndicator() + { + _suppressSortSync = true; + try + { + _table!.ClearSort(); + _lastSetSortColumn = _sortColumnIndex; + _lastSetSortDirection = _sortDirection; + + if (_sortColumnIndex < 0 || _sortDirection == SortDirection.None) + { + return; + } + + _table.SortByColumn(_sortColumnIndex); + if (_sortDirection == SortDirection.Descending) + { + _table.SortByColumn(_sortColumnIndex); + } + } + finally + { + _suppressSortSync = false; + } + } + + void UpdatePageIndicator(int totalItems, int totalPages, int pageSize) + { + if (_pageIndicator is not null) + { + var mut = WorkbenchColors.Muted.ToMarkup(); + _pageIndicator.Text = totalItems <= pageSize + ? $"[{mut}]{totalItems} item{(totalItems == 1 ? string.Empty : "s")}[/]" + : $"[{mut}]{(_pageIndex * pageSize) + 1}–{Math.Min((_pageIndex + 1) * pageSize, totalItems)} of {totalItems}[/]"; + } + + SetButtonEnabled(_prevPageButton, _pageIndex > 0); + SetButtonEnabled(_nextPageButton, _pageIndex < totalPages - 1); + } + + void RestoreSelection(string key) + { + if (_table is null) + { + return; + } + + for (var i = 0; i < _table.Rows.Count; i++) + { + if (_table.Rows[i].Tag is TItem item && GetKey(item) == key) + { + _table.SelectedRowIndex = i; + return; + } + } + } + + void RefreshDetail() + { + if (_detailPanel is null) + { + return; + } + + _detailPanel.Content = RenderDetail(SelectedItem, _pendingData); + } + + void ActivateSelected() + { + if (SelectedItem is TItem item) + { + OnRowActivated(item); + } + } +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/IWorkbenchView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/IWorkbenchView.cs index a02c723..20ffe73 100644 --- a/Source/Cli/Commands/Chronicle/Workbench/views/IWorkbenchView.cs +++ b/Source/Cli/Commands/Chronicle/Workbench/views/IWorkbenchView.cs @@ -23,6 +23,39 @@ public interface IWorkbenchView : IDisposable set { } } + /// + /// Gets the primary focus target for this view (filter prompt or table). + /// Returns for views that do not support keyboard focus routing. + /// + IWindowControl? PrimaryFocusTarget => null; + + /// + /// Gets or sets whether this view is currently visible to the user. + /// When , background data refreshes update the internal cache but do not + /// rebuild the table — preserving the user's sort order, selection, and scroll position. + /// + bool IsActive { get; set; } + + /// + /// Gets the current content of the detail panel, or if no detail is shown. + /// Returns for views that do not have a detail panel. + /// + string? DetailContent => null; + + /// + /// Gets the per-view help text shown in the help overlay — a brief description plus key shortcuts. + /// Returns an empty string when the view provides no extra help. + /// + string ViewHelp => string.Empty; + + /// + /// Toggles the detail pane open or closed with an animation. + /// Views without a detail pane may leave this as a no-op. + /// + void ToggleDetailPane() + { + } + /// /// Builds the initial control hierarchy for this view. Called once during window construction. /// @@ -52,4 +85,18 @@ void ActivateFilter(Window window) void ClearFilter() { } + + /// + /// Advances to the next page of results. No-op for views without pagination. + /// + void NextPage() + { + } + + /// + /// Goes back to the previous page of results. No-op for views without pagination. + /// + void PreviousPage() + { + } } diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/IdentitiesView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/IdentitiesView.cs new file mode 100644 index 0000000..f24da01 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/views/IdentitiesView.cs @@ -0,0 +1,58 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Chronicle.Contracts.Identities; +using SharpConsoleUI.Layout; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Identities navigation item — filterable table of known identities with a detail pane. +/// +public class IdentitiesView : FilterableTableView +{ + /// + protected override IReadOnlyList<(string Name, TextJustification Justify, int? Width)> Columns => + [ + ("Name", TextJustification.Left, null), + ("UserName", TextJustification.Left, 30), + ("Subject", TextJustification.Left, 40) + ]; + + /// + protected override string DetailPanelHeader => "IDENTITY"; + + /// + protected override IEnumerable GetItems(WorkbenchData data) => + data.Identities.OrderBy(i => i.Name); + + /// + protected override string GetKey(Identity item) => item.Subject; + + /// + protected override string[] BuildRow(Identity item) => + [item.Name, item.UserName, item.Subject]; + + /// + protected override string RenderDetail(Identity? item, WorkbenchData? data) + { + if (item is null) + { + return $"[{WorkbenchColors.Muted.ToMarkup()}]Select an identity.[/]"; + } + + var mut = WorkbenchColors.Muted.ToMarkup(); + + return string.Join( + "\n", + $"[{mut}]Subject[/] {item.Subject}", + $"[{mut}]Name[/] {item.Name}", + $"[{mut}]UserName[/] {item.UserName}"); + } + + /// + protected override bool MatchesFilter(Identity item, string filter) => + item.Name.Contains(filter, StringComparison.OrdinalIgnoreCase) || + item.UserName.Contains(filter, StringComparison.OrdinalIgnoreCase) || + item.Subject.Contains(filter, StringComparison.OrdinalIgnoreCase); +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/JobsView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/JobsView.cs index e880c7a..a7406e9 100644 --- a/Source/Cli/Commands/Chronicle/Workbench/views/JobsView.cs +++ b/Source/Cli/Commands/Chronicle/Workbench/views/JobsView.cs @@ -2,21 +2,17 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using Cratis.Chronicle.Contracts.Jobs; -using SharpConsoleUI; -using SharpConsoleUI.Builders; -using SharpConsoleUI.Controls; -using UITableRow = SharpConsoleUI.Controls.TableRow; +using SharpConsoleUI.Layout; namespace Cratis.Cli.Commands.Chronicle.Workbench; /// -/// Jobs tab — table of background jobs with stop/resume actions in the bordered detail pane. +/// Jobs tab — filterable table of background jobs with stop/resume actions in the detail pane. /// -public class JobsView : IWorkbenchView +public class JobsView : FilterableTableView { - TableControl? _table; - PanelControl? _detailPanel; - WorkbenchData? _pendingData; + /// Gets the currently selected job, or if none is selected. + public Job? SelectedJob => SelectedItem; /// /// Gets or sets the callback invoked when the user requests to stop a job. @@ -39,158 +35,140 @@ public class JobsView : IWorkbenchView public Action>? OnResumeAll { get; set; } /// - /// Returns all jobs that are currently checked (checkbox mode). + /// Gets all jobs that are currently checked (checkbox mode). /// - /// A list of checked items. - public IReadOnlyList GetCheckedItems() => - [.. (_table?.GetCheckedRows() ?? []).Select(r => r.Tag).OfType()]; + public IReadOnlyList Checked => CheckedItems; /// - public void Dispose() - { - _table?.Dispose(); - _detailPanel?.Dispose(); - } + protected override IReadOnlyList<(string Name, TextJustification Justify, int? Width)> Columns => + [ + ("Status", TextJustification.Left, 22), + ("Type", TextJustification.Left, null), + ("Progress", TextJustification.Right, 14) + ]; /// - public IWindowControl BuildContent(ConsoleWindowSystem windowSystem) - { - _table = Controls.Table() - .AddColumn("Status", SharpConsoleUI.Layout.TextJustification.Left, 22) - .AddColumn("Type", SharpConsoleUI.Layout.TextJustification.Left, null) - .AddColumn("Progress", SharpConsoleUI.Layout.TextJustification.Right, 14) - .Interactive() - .WithCheckboxMode() - .WithFiltering() - .WithSorting() - .WithVerticalScrollbar(ScrollbarVisibility.Auto) - .OnSelectedRowChanged((_, _) => RefreshDetail()) - .WithName("JobsTable") - .Build(); - - _detailPanel = Controls.Panel() - .WithContent($"[{WorkbenchColors.Muted.ToMarkup()}]Select a job.[/]") - .WithHeader(" JOB ") - .Rounded() - .WithBorderColor(WorkbenchColors.Accent) - .WithPadding(1, 0, 1, 0) - .FillVertical() - .WithName("JobDetailPanel") - .Build(); - - var root = HorizontalGridControl.Create() - .Column(c => c.Add(_table)) - .WithSplitterAfter(0) - .Column(c => c.Width(45).Add(_detailPanel)) - .Build(); - - // Apply any data that arrived before controls were ready (NavigationView lazy init). - if (_pendingData is not null) - UpdateData(_pendingData); - - return root; - } + protected override string DetailPanelHeader => "JOB"; /// - public void UpdateData(WorkbenchData data) - { - _pendingData = data; - if (_table is null) return; + protected override bool HasCheckboxMode => true; - var selectedKey = (_table.SelectedRow?.Tag as Job)?.Id.ToString(); + /// + protected override IEnumerable<(string Label, string? Shortcut, Action Execute)> GetContextMenuActions(Job item) + { + if (OnStopJob is not null) + { + yield return ("Stop job", "S", () => OnStopJob(item)); + } - _table.ClearRows(); - foreach (var job in data.Jobs.OrderBy(j => j.Status.ToString())) + if (OnResumeJob is not null) { - var statusColor = GetJobStatusColor(job.Status); - _table.AddRow(new UITableRow( - [ - $"[{statusColor}]{job.Status}[/]", - job.Type ?? job.Id.ToString(), - FormatProgress(job.Progress) - ]) - { Tag = job }); + yield return ("Resume job", "U", () => OnResumeJob(item)); } - if (selectedKey is not null) + var checkedCount = Checked.Count; + if (OnStopAll is not null && checkedCount > 1) { - RestoreSelection(selectedKey); + yield return ($"Stop {checkedCount} checked", null, () => OnStopAll(Checked)); } - RefreshDetail(); + if (OnResumeAll is not null && checkedCount > 1) + { + yield return ($"Resume {checkedCount} checked", null, () => OnResumeAll(Checked)); + } } - static string GetJobStatusColor(JobStatus status) => status switch - { - JobStatus.Running => WorkbenchColors.Success.ToMarkup(), - JobStatus.Failed => WorkbenchColors.Danger.ToMarkup(), - JobStatus.Stopped => WorkbenchColors.Warning.ToMarkup(), - JobStatus.CompletedWithFailures => WorkbenchColors.Warning.ToMarkup(), - _ => WorkbenchColors.Muted.ToMarkup() - }; + /// + protected override IEnumerable GetItems(WorkbenchData data) => + data.Jobs.OrderBy(j => j.Status.ToString()); - static string FormatProgress(JobProgress? p) - { - if (p is null || p.TotalSteps == 0) return "—"; - return $"{p.SuccessfulSteps}/{p.TotalSteps}"; - } + /// + protected override string GetKey(Job item) => item.Id.ToString(); - void RestoreSelection(string key) + /// + protected override string[] BuildRow(Job item) { - if (_table is null) return; - - for (var i = 0; i < _table.Rows.Count; i++) - { - if (_table.Rows[i].Tag is Job job && job.Id.ToString() == key) - { - _table.SelectedRowIndex = i; - return; - } - } + var statusColor = GetJobStatusColor(item.Status); + return + [ + $"[{statusColor}]{item.Status}[/]", + item.Type ?? item.Id.ToString(), + FormatProgress(item.Progress) + ]; } - void RefreshDetail() + /// + protected override string RenderDetail(Job? item, WorkbenchData? data) { - if (_table is null || _detailPanel is null) return; - - if (_table.SelectedRow?.Tag is not Job job) + if (item is null) { - _detailPanel.Content = $"[{WorkbenchColors.Muted.ToMarkup()}]Select a job.[/]"; - return; + return $"[{WorkbenchColors.Muted.ToMarkup()}]Select a job.[/]"; } var mut = WorkbenchColors.Muted.ToMarkup(); - var statusColor = GetJobStatusColor(job.Status); + var statusColor = GetJobStatusColor(item.Status); var lines = new List { - $"[{mut}]Id[/] {job.Id}", - $"[{mut}]Type[/] {job.Type ?? "—"}", - $"[{mut}]Status[/] [{statusColor}]{job.Status}[/]", - $"[{mut}]Progress[/] {FormatProgress(job.Progress)}" + $"[{mut}]Id[/] {item.Id}", + $"[{mut}]Type[/] {item.Type ?? "—"}", + $"[{mut}]Status[/] [{statusColor}]{item.Status}[/]", + $"[{mut}]Progress[/] {FormatProgress(item.Progress)}" }; - if (job.Progress is not null) + if (item.Progress is not null) { - lines.Add($"[{mut}]Steps[/] {job.Progress.SuccessfulSteps}/{job.Progress.TotalSteps}"); - if (job.Progress.FailedSteps > 0) + lines.Add($"[{mut}]Steps[/] {item.Progress.SuccessfulSteps}/{item.Progress.TotalSteps}"); + if (item.Progress.FailedSteps > 0) { - lines.Add($"[{WorkbenchColors.Danger.ToMarkup()}]Failed[/] {job.Progress.FailedSteps}"); + lines.Add($"[{WorkbenchColors.Danger.ToMarkup()}]Failed[/] {item.Progress.FailedSteps}"); } - if (!string.IsNullOrEmpty(job.Progress.Message)) + if (!string.IsNullOrEmpty(item.Progress.Message)) { - lines.Add($"[{mut}]Message[/] {job.Progress.Message}"); + lines.Add($"[{mut}]Message[/] {item.Progress.Message}"); } } if (OnStopJob is not null || OnResumeJob is not null) { lines.Add(string.Empty); - if (OnStopJob is not null) lines.Add($"[{mut}]Press[/] [bold]S[/] [{mut}]to stop[/]"); - if (OnResumeJob is not null) lines.Add($"[{mut}]Press[/] [bold]U[/] [{mut}]to resume[/]"); + if (OnStopJob is not null) + { + lines.Add($"[{mut}]Press[/] [bold]S[/] [{mut}]to stop[/]"); + } + + if (OnResumeJob is not null) + { + lines.Add($"[{mut}]Press[/] [bold]U[/] [{mut}]to resume[/]"); + } + } + + return string.Join('\n', lines); + } + + /// + protected override bool MatchesFilter(Job item, string filter) => + (item.Type ?? string.Empty).Contains(filter, StringComparison.OrdinalIgnoreCase) || + item.Id.ToString().Contains(filter, StringComparison.OrdinalIgnoreCase) || + item.Status.ToString().Contains(filter, StringComparison.OrdinalIgnoreCase); + + static string GetJobStatusColor(JobStatus status) => status switch + { + JobStatus.Running => WorkbenchColors.Success.ToMarkup(), + JobStatus.Failed => WorkbenchColors.Danger.ToMarkup(), + JobStatus.Stopped => WorkbenchColors.Warning.ToMarkup(), + JobStatus.CompletedWithFailures => WorkbenchColors.Warning.ToMarkup(), + _ => WorkbenchColors.Muted.ToMarkup() + }; + + static string FormatProgress(JobProgress? p) + { + if (p is null || p.TotalSteps == 0) + { + return "—"; } - _detailPanel.Content = string.Join('\n', lines); + return $"{p.SuccessfulSteps}/{p.TotalSteps}"; } } diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/JsonYamlFormatter.cs b/Source/Cli/Commands/Chronicle/Workbench/views/JsonYamlFormatter.cs new file mode 100644 index 0000000..3c9af78 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/views/JsonYamlFormatter.cs @@ -0,0 +1,102 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text; +using System.Text.Json; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Formats JSON strings as human-readable YAML-style markup for display in workbench detail panes. +/// +static class JsonYamlFormatter +{ + /// + /// Formats a JSON string as an indented, human-readable YAML-style representation. + /// Falls back to wrapping the raw string in muted markup when parsing fails. + /// + /// The JSON string to format. + /// The SharpConsoleUI markup tag applied to property key labels. + /// A formatted markup string. + internal static string FormatAsYaml(string json, string mutedMarkup) + { + if (string.IsNullOrWhiteSpace(json)) + { + return $"[{mutedMarkup}](empty)[/]"; + } + + try + { + var doc = JsonDocument.Parse(json); + var sb = new StringBuilder(); + AppendElement(doc.RootElement, sb, 0, mutedMarkup); + return sb.ToString().TrimEnd(); + } + catch + { + return $"[{mutedMarkup}]{json}[/]"; + } + } + + static void AppendElement(JsonElement element, StringBuilder sb, int indent, string mut) + { + var pad = new string(' ', indent * 2); + switch (element.ValueKind) + { + case JsonValueKind.Object: + foreach (var prop in element.EnumerateObject()) + { + // Skip JSON Schema noise that clutters the display without adding value. + if (IsJsonSchemaNoise(prop.Name)) + { + continue; + } + + if (prop.Value.ValueKind is JsonValueKind.Object or JsonValueKind.Array) + { + sb.AppendLine($"{pad}[{mut}]{prop.Name}:[/]"); + AppendElement(prop.Value, sb, indent + 1, mut); + } + else + { + sb.AppendLine($"{pad}[{mut}]{prop.Name}:[/] {ScalarText(prop.Value)}"); + } + } + + break; + + case JsonValueKind.Array: + foreach (var item in element.EnumerateArray()) + { + if (item.ValueKind is JsonValueKind.Object or JsonValueKind.Array) + { + sb.AppendLine($"{pad}-"); + AppendElement(item, sb, indent + 1, mut); + } + else + { + sb.AppendLine($"{pad}- {ScalarText(item)}"); + } + } + + break; + + default: + sb.AppendLine($"{pad}{ScalarText(element)}"); + break; + } + } + + static bool IsJsonSchemaNoise(string key) => + string.Equals(key, "required", StringComparison.Ordinal) || + string.Equals(key, "$schema", StringComparison.Ordinal) || + string.Equals(key, "definitions", StringComparison.Ordinal) || + string.Equals(key, "$defs", StringComparison.Ordinal); + + static string ScalarText(JsonElement element) => element.ValueKind switch + { + JsonValueKind.String => element.GetString() ?? string.Empty, + JsonValueKind.Null => "~", + _ => element.GetRawText() + }; +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/NamespacesView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/NamespacesView.cs index 1da00f6..a98677a 100644 --- a/Source/Cli/Commands/Chronicle/Workbench/views/NamespacesView.cs +++ b/Source/Cli/Commands/Chronicle/Workbench/views/NamespacesView.cs @@ -18,6 +18,9 @@ public class NamespacesView : IWorkbenchView MarkupControl? _helpPane; WorkbenchData? _pendingData; + /// + public bool IsActive { get; set; } + /// /// Gets or sets the callback invoked when the user switches to a different namespace. /// diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/ObserversView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/ObserversView.cs index e2d89d7..bc7d34d 100644 --- a/Source/Cli/Commands/Chronicle/Workbench/views/ObserversView.cs +++ b/Source/Cli/Commands/Chronicle/Workbench/views/ObserversView.cs @@ -1,30 +1,17 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -using SharpConsoleUI; -using SharpConsoleUI.Builders; -using SharpConsoleUI.Controls; -using UITableRow = SharpConsoleUI.Controls.TableRow; +using SharpConsoleUI.Layout; namespace Cratis.Cli.Commands.Chronicle.Workbench; /// -/// Observers navigation item — sortable table with a filter prompt on the left and bordered detail pane on the right. +/// Observers navigation item — sortable, filterable table with a detail pane showing observer state and event types. /// -public class ObserversView : IWorkbenchView +public class ObserversView : FilterableTableView { - TableControl? _table; - PanelControl? _detailPanel; - PromptControl? _filterPrompt; - ulong? _tailSequenceNumber; - string _currentFilter = string.Empty; - List _allItems = []; - WorkbenchData? _pendingData; - - /// - /// Gets or sets the callback invoked when the filter input gains or loses focus. - /// - public Action? OnFilterFocusChanged { get; set; } + /// Gets the currently selected observer, or if none is selected. + public ObserverInformation? SelectedObserver => SelectedItem; /// /// Gets or sets the callback invoked when the user requests a replay of the selected observer. @@ -37,254 +24,82 @@ public class ObserversView : IWorkbenchView public Action>? OnReplayAll { get; set; } /// - /// Returns all observers that are currently checked (checkbox mode). + /// Gets all observers that are currently checked (checkbox mode). /// - /// A list of checked items. - public IReadOnlyList GetCheckedItems() => - [.. (_table?.GetCheckedRows() ?? []).Select(r => r.Tag).OfType()]; + public IReadOnlyList Checked => CheckedItems; /// - public void Dispose() - { - _table?.Dispose(); - _detailPanel?.Dispose(); - _filterPrompt?.Dispose(); - } + protected override IReadOnlyList<(string Name, TextJustification Justify, int? Width)> Columns => + [ + ("State", TextJustification.Left, 18), + ("Id", TextJustification.Left, null), + ("Type", TextJustification.Left, 16) + ]; /// - public IWindowControl BuildContent(ConsoleWindowSystem windowSystem) - { - _table = Controls.Table() - .AddColumn("State", SharpConsoleUI.Layout.TextJustification.Left, 18) - .AddColumn("Id", SharpConsoleUI.Layout.TextJustification.Left, null) - .AddColumn("Type", SharpConsoleUI.Layout.TextJustification.Left, 16) - .AddColumn("Seq", SharpConsoleUI.Layout.TextJustification.Right, 12) - .Interactive() - .WithCheckboxMode() - .WithSorting() - .WithVerticalScrollbar(ScrollbarVisibility.Auto) - .OnSelectedRowChanged((_, _) => RefreshDetail()) - .WithName("ObserversTable") - .Build(); - - _filterPrompt = Controls.Prompt("Filter: ") - .WithHistory(true) - .WithTabCompleter((input, _) => GetCompletions(input)) - .OnInputChanged((_, text) => - { - _currentFilter = text ?? string.Empty; - RebuildFilteredRows(); - }) - .OnGotFocus((_, _) => OnFilterFocusChanged?.Invoke(true)) - .OnLostFocus((_, _) => OnFilterFocusChanged?.Invoke(false)) - .WithName("ObserversFilterPrompt") - .Build(); - - var leftPane = Controls.ScrollablePanel() - .AddControl(_filterPrompt) - .AddControl(_table) - .WithVerticalScroll(ScrollMode.None) - .WithName("ObserversLeftPane") - .Build(); - - _detailPanel = Controls.Panel() - .WithContent($"[{WorkbenchColors.Muted.ToMarkup()}]Select an observer.[/]") - .WithHeader(" OBSERVER ") - .Rounded() - .WithBorderColor(WorkbenchColors.Accent) - .WithPadding(1, 0, 1, 0) - .FillVertical() - .WithName("ObserverDetailPanel") - .Build(); - - var root = HorizontalGridControl.Create() - .Column(c => c.Add(leftPane)) - .WithSplitterAfter(0) - .Column(c => c.Width(45).Add(_detailPanel)) - .Build(); - - // Apply any data that arrived before controls were ready (NavigationView lazy init). - if (_pendingData is not null) - UpdateData(_pendingData); - - return root; - } + protected override string DetailPanelHeader => "OBSERVER"; /// - public void ActivateFilter(Window window) - { - if (_filterPrompt is not null) - { - window.FocusControl(_filterPrompt); - } - } - - /// - public void ClearFilter() - { - _currentFilter = string.Empty; - _filterPrompt?.SetInput(string.Empty); - RebuildFilteredRows(); - } + protected override bool HasCheckboxMode => true; /// - public void UpdateData(WorkbenchData data) - { - _pendingData = data; - if (_table is null) return; - - _tailSequenceNumber = data.TailSequenceNumber; - _allItems = [.. data.Observers.OrderBy(ObserverSortOrder).ThenBy(o => o.Id)]; - RebuildFilteredRows(); - } - - static int ObserverSortOrder(ObserverInformation o) => o.RunningState switch - { - ObserverRunningState.Disconnected => 0, - ObserverRunningState.Replaying => 1, - ObserverRunningState.Active => 2, - ObserverRunningState.Suspended => 3, - _ => 4 - }; - - static string GetObserverStateColor(ObserverInformation obs) => obs.RunningState switch - { - ObserverRunningState.Active => WorkbenchColors.Success.ToMarkup(), - ObserverRunningState.Replaying => WorkbenchColors.Warning.ToMarkup(), - ObserverRunningState.Disconnected => WorkbenchColors.Danger.ToMarkup(), - _ => WorkbenchColors.Muted.ToMarkup() - }; - - static string GetObserverIcon(ObserverInformation obs) => obs.RunningState switch - { - ObserverRunningState.Active => "●", - ObserverRunningState.Replaying => "▲", - ObserverRunningState.Disconnected => "⊘", - _ => "○" - }; - - IEnumerable GetCompletions(string input) => - [ - "state:active", - "state:replaying", - "state:disconnected", - "state:suspended", - "type:projection", - "type:reducer", - "type:reactor" - ]; - - bool MatchesFilter(ObserverInformation obs) - { - if (string.IsNullOrEmpty(_currentFilter)) return true; - - var f = _currentFilter; - - if (f.StartsWith("state:", StringComparison.OrdinalIgnoreCase)) - { - var state = f[6..]; - return obs.RunningState.ToString().Contains(state, StringComparison.OrdinalIgnoreCase); - } - - if (f.StartsWith("type:", StringComparison.OrdinalIgnoreCase)) - { - var type = f[5..]; - return obs.Type.ToString().Contains(type, StringComparison.OrdinalIgnoreCase); - } - - return obs.Id.Contains(f, StringComparison.OrdinalIgnoreCase); - } - - void RebuildFilteredRows() + protected override IEnumerable<(string Label, string? Shortcut, Action Execute)> GetContextMenuActions(ObserverInformation item) { - if (_table is null) return; - - var selectedKey = (_table.SelectedRow?.Tag as ObserverInformation)?.Id; - - _table.ClearRows(); - foreach (var obs in _allItems.Where(MatchesFilter)) + if (OnReplay is not null) { - _table.AddRow(new UITableRow(BuildObserverRow(obs)) { Tag = obs }); + yield return ("Replay observer", "R", () => OnReplay(item)); } - if (selectedKey is not null) + var checkedCount = Checked.Count; + if (OnReplayAll is not null && checkedCount > 1) { - RestoreSelection(selectedKey); + yield return ($"Replay {checkedCount} checked", null, () => OnReplayAll(Checked)); } - - RefreshDetail(); } - void RestoreSelection(string key) - { - if (_table is null) return; + /// + protected override IEnumerable GetItems(WorkbenchData data) => + data.Observers.OrderBy(ObserverSortOrder).ThenBy(o => o.Id); - for (var i = 0; i < _table.Rows.Count; i++) - { - if (_table.Rows[i].Tag is ObserverInformation obs && obs.Id == key) - { - _table.SelectedRowIndex = i; - return; - } - } - } + /// + protected override string GetKey(ObserverInformation item) => item.Id; - string[] BuildObserverRow(ObserverInformation obs) + /// + protected override string[] BuildRow(ObserverInformation item) { - var stateColor = GetObserverStateColor(obs); - var icon = GetObserverIcon(obs); - - string seqCell; - if (obs.RunningState == ObserverRunningState.Replaying && _tailSequenceNumber.HasValue && _tailSequenceNumber.Value > 0) - { - var tail = _tailSequenceNumber.Value; - var current = obs.LastHandledEventSequenceNumber == ulong.MaxValue ? 0UL : obs.LastHandledEventSequenceNumber; - var pct = (int)Math.Min(100, current * 100 / tail); - var filledBars = pct / 10; - var bar = new string('█', filledBars) + new string('░', 10 - filledBars); - seqCell = $"[{WorkbenchColors.Warning.ToMarkup()}]{bar} {pct}%[/]"; - } - else - { - seqCell = obs.LastHandledEventSequenceNumber == ulong.MaxValue - ? $"[{WorkbenchColors.Muted.ToMarkup()}]—[/]" - : obs.LastHandledEventSequenceNumber.ToString("N0"); - } + var stateColor = GetObserverStateColor(item); + var icon = GetObserverIcon(item); return [ - $"[{stateColor}]{icon} {obs.RunningState}[/]", - obs.Id, - obs.Type.ToString(), - seqCell + $"[{stateColor}]{icon} {item.RunningState}[/]", + item.Id, + item.Type.ToString() ]; } - void RefreshDetail() + /// + protected override string RenderDetail(ObserverInformation? item, WorkbenchData? data) { - if (_table is null || _detailPanel is null) return; - - var row = _table.SelectedRow; - if (row?.Tag is not ObserverInformation obs) + if (item is null) { - _detailPanel.Content = $"[{WorkbenchColors.Muted.ToMarkup()}]Select an observer.[/]"; - return; + return $"[{WorkbenchColors.Muted.ToMarkup()}]Select an observer.[/]"; } var mut = WorkbenchColors.Muted.ToMarkup(); - var stateColor = GetObserverStateColor(obs); + var stateColor = GetObserverStateColor(item); var lines = new List { - $"[{mut}]Id[/] {obs.Id}", - $"[{mut}]Type[/] {obs.Type}", - $"[{mut}]State[/] [{stateColor}]{obs.RunningState}[/]", - $"[{mut}]Seq[/] {obs.LastHandledEventSequenceNumber}", + $"[{mut}]Id[/] {item.Id}", + $"[{mut}]Type[/] {item.Type}", + $"[{mut}]State[/] [{stateColor}]{item.RunningState}[/]", + $"[{mut}]Last seq[/] {(item.LastHandledEventSequenceNumber == ulong.MaxValue ? "N/A" : item.LastHandledEventSequenceNumber.ToString("N0"))}", string.Empty, $"[{mut}]Event Types:[/]" }; - foreach (var et in (obs.EventTypes ?? []).OrderBy(e => e.Id)) + foreach (var et in (item.EventTypes ?? []).OrderBy(e => e.Id)) { lines.Add($" • {et.Id} gen {et.Generation}"); } @@ -295,6 +110,66 @@ void RefreshDetail() lines.Add($"[{mut}]Press[/] [bold]R[/] [{mut}]to replay[/]"); } - _detailPanel.Content = string.Join('\n', lines); + return string.Join('\n', lines); } + + /// + protected override bool MatchesFilter(ObserverInformation item, string filter) + { + if (filter.StartsWith("state:", StringComparison.OrdinalIgnoreCase)) + { + return item.RunningState.ToString().Contains(filter[6..], StringComparison.OrdinalIgnoreCase); + } + + if (filter.StartsWith("type:", StringComparison.OrdinalIgnoreCase)) + { + return item.Type.ToString().Contains(filter[5..], StringComparison.OrdinalIgnoreCase); + } + + // event:TypeId — match observers that subscribe to the given event type ID + if (filter.StartsWith("event:", StringComparison.OrdinalIgnoreCase)) + { + var eventTypeId = filter[6..]; + return (item.EventTypes ?? []).Any(et => et.Id.Contains(eventTypeId, StringComparison.OrdinalIgnoreCase)); + } + + return item.Id.Contains(filter, StringComparison.OrdinalIgnoreCase); + } + + /// + protected override IEnumerable GetCompletions(string input) => + [ + "state:active", + "state:replaying", + "state:disconnected", + "state:suspended", + "type:projection", + "type:reducer", + "type:reactor" + ]; + + static int ObserverSortOrder(ObserverInformation o) => o.RunningState switch + { + ObserverRunningState.Disconnected => 0, + ObserverRunningState.Replaying => 1, + ObserverRunningState.Active => 2, + ObserverRunningState.Suspended => 3, + _ => 4 + }; + + static string GetObserverStateColor(ObserverInformation obs) => obs.RunningState switch + { + ObserverRunningState.Active => WorkbenchColors.Success.ToMarkup(), + ObserverRunningState.Replaying => WorkbenchColors.Warning.ToMarkup(), + ObserverRunningState.Disconnected => WorkbenchColors.Danger.ToMarkup(), + _ => WorkbenchColors.Muted.ToMarkup() + }; + + static string GetObserverIcon(ObserverInformation obs) => obs.RunningState switch + { + ObserverRunningState.Active => "●", + ObserverRunningState.Replaying => "▲", + ObserverRunningState.Disconnected => "⊘", + _ => "○" + }; } diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/OverviewView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/OverviewView.cs index 18d2039..8e47178 100644 --- a/Source/Cli/Commands/Chronicle/Workbench/views/OverviewView.cs +++ b/Source/Cli/Commands/Chronicle/Workbench/views/OverviewView.cs @@ -14,14 +14,15 @@ namespace Cratis.Cli.Commands.Chronicle.Workbench; public class OverviewView : IWorkbenchView { readonly Queue _observerHistory = new(capacity: 10); - readonly Queue _eventRateHistory = new(capacity: 10); PanelControl? _healthPanel; PanelControl? _observerPanel; PanelControl? _attentionPanel; SparklineControl? _observerSparkline; - SparklineControl? _eventRateSparkline; WorkbenchData? _pendingData; + /// + public bool IsActive { get; set; } + /// public void Dispose() { @@ -29,7 +30,6 @@ public void Dispose() _observerPanel?.Dispose(); _attentionPanel?.Dispose(); _observerSparkline?.Dispose(); - _eventRateSparkline?.Dispose(); } /// @@ -52,13 +52,6 @@ public IWindowControl BuildContent(ConsoleWindowSystem windowSystem) .WithData([0]) .Build(); - _eventRateSparkline = new SparklineBuilder() - .WithHeight(2) - .WithBarColor(WorkbenchColors.Success) - .WithTitle("events/cycle", WorkbenchColors.Muted) - .WithData([0]) - .Build(); - _observerPanel = Controls.Panel() .WithContent("Loading...") .WithHeader(" OBSERVERS ") @@ -81,7 +74,7 @@ public IWindowControl BuildContent(ConsoleWindowSystem windowSystem) var root = Controls.HorizontalGrid() .Column(col => col.Add(_healthPanel)) - .Column(col => col.Add(_observerPanel).Add(_observerSparkline).Add(_eventRateSparkline)) + .Column(col => col.Add(_observerPanel).Add(_observerSparkline)) .Column(col => col.Add(_attentionPanel)) .Build(); @@ -113,7 +106,12 @@ public void UpdateData(WorkbenchData data) ? $"[bold]#{data.TailSequenceNumber.Value:N0}[/]" : $"[{mut}]—[/]"; + var acc = WorkbenchColors.Accent.ToMarkup(); _healthPanel.Content = + "[bold]CONTEXT[/]\n" + + $" [{mut}]Store[/] [{acc}]{data.EventStore}[/]\n" + + $" [{mut}]Namespace[/] [{acc}]{data.Namespace}[/]\n" + + "\n" + $"[{mut}]Status[/] {connStatus}\n" + $"[{mut}]Version[/] {version}\n" + $"[{mut}]Tail seq[/] {seq}\n" + @@ -166,21 +164,6 @@ public void UpdateData(WorkbenchData data) _attentionPanel!.Content = string.Join('\n', attentionLines); } - /// - /// Updates the event-rate sparkline with the number of new events observed this refresh cycle. - /// - /// The count of new events appended since the last cycle. - public void UpdateEventDelta(int newEventsThisCycle) - { - _eventRateHistory.Enqueue(newEventsThisCycle); - while (_eventRateHistory.Count > 10) - { - _eventRateHistory.Dequeue(); - } - - _eventRateSparkline?.SetDataPoints(_eventRateHistory); - } - void UpdateObserverSparkline(int totalObservers) { if (_observerSparkline is null) return; diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/ProjectionsView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/ProjectionsView.cs index 512aa22..68be210 100644 --- a/Source/Cli/Commands/Chronicle/Workbench/views/ProjectionsView.cs +++ b/Source/Cli/Commands/Chronicle/Workbench/views/ProjectionsView.cs @@ -1,173 +1,42 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -using SharpConsoleUI; -using SharpConsoleUI.Builders; -using SharpConsoleUI.Controls; -using UITableRow = SharpConsoleUI.Controls.TableRow; +using SharpConsoleUI.Layout; namespace Cratis.Cli.Commands.Chronicle.Workbench; /// /// Projections navigation item — filterable table of projection definitions with declaration preview in the detail pane. /// -public class ProjectionsView : IWorkbenchView +public class ProjectionsView : FilterableTableView { - TableControl? _table; - MarkupControl? _detailPane; - PromptControl? _filterPrompt; - IReadOnlyDictionary _declarations = new Dictionary(); - string _currentFilter = string.Empty; - List _allItems = []; - WorkbenchData? _pendingData; - - /// - /// Gets or sets the callback invoked when the filter input gains or loses focus. - /// - public Action? OnFilterFocusChanged { get; set; } - /// - public void Dispose() - { - _table?.Dispose(); - _detailPane?.Dispose(); - _filterPrompt?.Dispose(); - } + protected override IReadOnlyList<(string Name, TextJustification Justify, int? Width)> Columns => + [ + ("Identifier", TextJustification.Left, null), + ("Read Model", TextJustification.Left, 35) + ]; /// - public IWindowControl BuildContent(ConsoleWindowSystem windowSystem) - { - _table = Controls.Table() - .AddColumn("Identifier", SharpConsoleUI.Layout.TextJustification.Left, null) - .AddColumn("Read Model", SharpConsoleUI.Layout.TextJustification.Left, 35) - .Interactive() - .WithSorting() - .WithVerticalScrollbar(ScrollbarVisibility.Auto) - .OnSelectedRowChanged((_, _) => RefreshDetail()) - .WithName("ProjectionsTable") - .Build(); - - _filterPrompt = Controls.Prompt("Filter: ") - .WithHistory(true) - .OnInputChanged((_, text) => - { - _currentFilter = text ?? string.Empty; - RebuildFilteredRows(); - }) - .OnGotFocus((_, _) => OnFilterFocusChanged?.Invoke(true)) - .OnLostFocus((_, _) => OnFilterFocusChanged?.Invoke(false)) - .WithName("ProjectionsFilterPrompt") - .Build(); - - var leftPane = Controls.ScrollablePanel() - .AddControl(_filterPrompt) - .AddControl(_table) - .WithVerticalScroll(ScrollMode.None) - .WithName("ProjectionsLeftPane") - .Build(); - - _detailPane = new MarkupControl([$"[{WorkbenchColors.Muted.ToMarkup()}]Select a projection.[/]"]) - { - Name = "ProjectionDetail", - Wrap = true - }; - - var detailScroll = Controls.ScrollablePanel() - .AddControl(_detailPane) - .WithVerticalScroll(ScrollMode.Scroll) - .WithPadding(1, 0, 1, 0) - .Build(); - - var root = HorizontalGridControl.Create() - .Column(c => c.Add(leftPane)) - .WithSplitterAfter(0) - .Column(c => c.Width(55).Add(detailScroll)) - .Build(); - - // Apply any data that arrived before controls were ready (NavigationView lazy init). - if (_pendingData is not null) - UpdateData(_pendingData); - - return root; - } + protected override string DetailPanelHeader => "PROJECTION"; /// - public void ActivateFilter(Window window) - { - if (_filterPrompt is not null) - { - window.FocusControl(_filterPrompt); - } - } + protected override IEnumerable GetItems(WorkbenchData data) => + data.ProjectionDefinitions.OrderBy(d => d.Identifier); /// - public void ClearFilter() - { - _currentFilter = string.Empty; - _filterPrompt?.SetInput(string.Empty); - RebuildFilteredRows(); - } + protected override string GetKey(ProjectionDefinition item) => item.Identifier; /// - public void UpdateData(WorkbenchData data) - { - _pendingData = data; - if (_table is null) return; - - _declarations = data.ProjectionDeclarations; - _allItems = [.. data.ProjectionDefinitions.OrderBy(d => d.Identifier)]; - RebuildFilteredRows(); - } - - bool MatchesFilter(ProjectionDefinition def) - { - if (string.IsNullOrEmpty(_currentFilter)) return true; - - return def.Identifier.Contains(_currentFilter, StringComparison.OrdinalIgnoreCase); - } - - void RebuildFilteredRows() - { - if (_table is null) return; - - var selectedKey = (_table.SelectedRow?.Tag as ProjectionDefinition)?.Identifier; - - _table.ClearRows(); - foreach (var def in _allItems.Where(MatchesFilter)) - { - _table.AddRow(new UITableRow([def.Identifier, def.ReadModel ?? string.Empty]) { Tag = def }); - } - - if (selectedKey is not null) - { - RestoreSelection(selectedKey); - } - - RefreshDetail(); - } - - void RestoreSelection(string key) - { - if (_table is null) return; + protected override string[] BuildRow(ProjectionDefinition item) => + [item.Identifier, item.ReadModel ?? string.Empty]; - for (var i = 0; i < _table.Rows.Count; i++) - { - if (_table.Rows[i].Tag is ProjectionDefinition def && def.Identifier == key) - { - _table.SelectedRowIndex = i; - return; - } - } - } - - void RefreshDetail() + /// + protected override string RenderDetail(ProjectionDefinition? item, WorkbenchData? data) { - if (_table is null || _detailPane is null) return; - - if (_table.SelectedRow?.Tag is not ProjectionDefinition def) + if (item is null) { - _detailPane.Text = $"[{WorkbenchColors.Muted.ToMarkup()}]Select a projection.[/]"; - return; + return $"[{WorkbenchColors.Muted.ToMarkup()}]Select a projection.[/]"; } var acc = WorkbenchColors.Accent.ToMarkup(); @@ -178,15 +47,16 @@ void RefreshDetail() { $"[{acc}][bold]PROJECTION[/][/]", string.Empty, - $" [{mut}]Identifier[/] {def.Identifier}", - $" [{mut}]Read Model[/] {def.ReadModel ?? "—"}", - $" [{mut}]Active[/] [{(def.IsActive ? suc : mut)}]{(def.IsActive ? "Yes" : "No")}[/]", - $" [{mut}]Rewindable[/] [{(def.IsRewindable ? suc : mut)}]{(def.IsRewindable ? "Yes" : "No")}[/]", + $" [{mut}]Identifier[/] {item.Identifier}", + $" [{mut}]Read Model[/] {item.ReadModel ?? "—"}", + $" [{mut}]Active[/] [{(item.IsActive ? suc : mut)}]{(item.IsActive ? "Yes" : "No")}[/]", + $" [{mut}]Rewindable[/] [{(item.IsRewindable ? suc : mut)}]{(item.IsRewindable ? "Yes" : "No")}[/]", string.Empty, $" [{acc}]Declaration (preview):[/]" }; - if (_declarations.TryGetValue(def.Identifier, out var declaration) && !string.IsNullOrEmpty(declaration)) + var declarations = data?.ProjectionDeclarations ?? new Dictionary(); + if (declarations.TryGetValue(item.Identifier, out var declaration) && !string.IsNullOrEmpty(declaration)) { foreach (var line in declaration.Split('\n').Take(40)) { @@ -198,6 +68,10 @@ void RefreshDetail() lines.Add($" [{mut}](no declaration available)[/]"); } - _detailPane.Text = string.Join('\n', lines); + return string.Join('\n', lines); } + + /// + protected override bool MatchesFilter(ProjectionDefinition item, string filter) => + item.Identifier.Contains(filter, StringComparison.OrdinalIgnoreCase); } diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/ReadModelsView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/ReadModelsView.cs index 03827f1..fdb2f00 100644 --- a/Source/Cli/Commands/Chronicle/Workbench/views/ReadModelsView.cs +++ b/Source/Cli/Commands/Chronicle/Workbench/views/ReadModelsView.cs @@ -2,9 +2,8 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using SharpConsoleUI; -using SharpConsoleUI.Builders; using SharpConsoleUI.Controls; -using UITableRow = SharpConsoleUI.Controls.TableRow; +using SharpConsoleUI.Layout; namespace Cratis.Cli.Commands.Chronicle.Workbench; @@ -12,20 +11,9 @@ namespace Cratis.Cli.Commands.Chronicle.Workbench; /// Read Models navigation item — filterable table of read model definitions with metadata in the detail pane. /// Pressing Enter on a selected row opens a detail overlay showing definition info and live instances. /// -public class ReadModelsView : IWorkbenchView +public class ReadModelsView : FilterableTableView { ConsoleWindowSystem? _windowSystem; - TableControl? _table; - MarkupControl? _detailPane; - PromptControl? _filterPrompt; - string _currentFilter = string.Empty; - List _allItems = []; - WorkbenchData? _pendingData; - - /// - /// Gets or sets the callback invoked when the filter input gains or loses focus. - /// - public Action? OnFilterFocusChanged { get; set; } /// /// Gets or sets the callback invoked when the user activates a read model row (Enter). @@ -35,108 +23,29 @@ public class ReadModelsView : IWorkbenchView public Func>? OnFetchInstances { get; set; } /// - public void Dispose() - { - _table?.Dispose(); - _detailPane?.Dispose(); - _filterPrompt?.Dispose(); - } - - /// - public IWindowControl BuildContent(ConsoleWindowSystem windowSystem) - { - _windowSystem = windowSystem; - - _table = Controls.Table() - .AddColumn("Container", SharpConsoleUI.Layout.TextJustification.Left, null) - .AddColumn("Owner", SharpConsoleUI.Layout.TextJustification.Left, 12) - .AddColumn("Source", SharpConsoleUI.Layout.TextJustification.Left, 14) - .Interactive() - .WithSorting() - .WithVerticalScrollbar(ScrollbarVisibility.Auto) - .OnSelectedRowChanged((_, _) => RefreshDetail()) - .WithName("ReadModelsTable") - .Build(); - - _filterPrompt = Controls.Prompt("Filter: ") - .WithHistory(true) - .WithTabCompleter((input, _) => GetCompletions()) - .OnInputChanged((_, text) => - { - _currentFilter = text ?? string.Empty; - RebuildFilteredRows(); - }) - .OnGotFocus((_, _) => OnFilterFocusChanged?.Invoke(true)) - .OnLostFocus((_, _) => OnFilterFocusChanged?.Invoke(false)) - .WithName("ReadModelsFilterPrompt") - .Build(); - - var leftPane = Controls.ScrollablePanel() - .AddControl(_filterPrompt) - .AddControl(_table) - .WithVerticalScroll(ScrollMode.None) - .WithName("ReadModelsLeftPane") - .Build(); - - _detailPane = new MarkupControl([$"[{WorkbenchColors.Muted.ToMarkup()}]Select a read model.[/]"]) - { - Name = "ReadModelDetail", - Wrap = true - }; - - var detailScroll = Controls.ScrollablePanel() - .AddControl(_detailPane) - .WithVerticalScroll(ScrollMode.Scroll) - .WithPadding(1, 0, 1, 0) - .Build(); - - var root = HorizontalGridControl.Create() - .Column(c => c.Add(leftPane)) - .WithSplitterAfter(0) - .Column(c => c.Width(50).Add(detailScroll)) - .Build(); - - // Apply any data that arrived before controls were ready (NavigationView lazy init). - if (_pendingData is not null) - UpdateData(_pendingData); - - return root; - } - - /// - public void ActivateFilter(Window window) - { - if (_filterPrompt is not null) - { - window.FocusControl(_filterPrompt); - } - } + protected override IReadOnlyList<(string Name, TextJustification Justify, int? Width)> Columns => + [ + ("Container", TextJustification.Left, null), + ("Owner", TextJustification.Left, 12), + ("Source", TextJustification.Left, 14) + ]; /// - public void ClearFilter() - { - _currentFilter = string.Empty; - _filterPrompt?.SetInput(string.Empty); - RebuildFilteredRows(); - } + protected override string DetailPanelHeader => "READ MODEL"; /// - public void UpdateData(WorkbenchData data) + public override IWindowControl BuildContent(ConsoleWindowSystem windowSystem) { - _pendingData = data; - if (_table is null) return; - - _allItems = [.. data.ReadModelDefinitions.OrderBy(d => d.ContainerName)]; - RebuildFilteredRows(); + _windowSystem = windowSystem; + return base.BuildContent(windowSystem); } /// /// Opens a detail overlay for the currently selected read model row, if any. - /// No-op when no row is selected. /// public void OpenSelectedDetailOverlay() { - if (_table?.SelectedRow?.Tag is WorkbenchReadModel rm) + if (SelectedItem is WorkbenchReadModel rm) { OpenDetailOverlay(rm); } @@ -148,7 +57,10 @@ public void OpenSelectedDetailOverlay() /// The read model to display. public void OpenDetailOverlay(WorkbenchReadModel rm) { - if (_windowSystem is null) return; + if (_windowSystem is null) + { + return; + } var acc = WorkbenchColors.Accent.ToMarkup(); var mut = WorkbenchColors.Muted.ToMarkup(); @@ -179,91 +91,66 @@ public void OpenDetailOverlay(WorkbenchReadModel rm) _windowSystem.AddWindow(window, activateWindow: true); } - static IEnumerable GetCompletions() => - [ - "owner:client", - "owner:server" - ]; - - bool MatchesFilter(WorkbenchReadModel rm) - { - if (string.IsNullOrEmpty(_currentFilter)) return true; - - var f = _currentFilter; - - if (f.StartsWith("owner:", StringComparison.OrdinalIgnoreCase)) - { - var owner = f[6..]; - return rm.Owner.Contains(owner, StringComparison.OrdinalIgnoreCase); - } - - return rm.ContainerName.Contains(f, StringComparison.OrdinalIgnoreCase) || - rm.DisplayName.Contains(f, StringComparison.OrdinalIgnoreCase); - } - - void RebuildFilteredRows() - { - if (_table is null) return; - - var selectedKey = (_table.SelectedRow?.Tag as WorkbenchReadModel)?.ContainerName; - - _table.ClearRows(); - foreach (var rm in _allItems.Where(MatchesFilter)) - { - _table.AddRow(new UITableRow([rm.ContainerName, rm.Owner, rm.Source]) { Tag = rm }); - } - - if (selectedKey is not null) - { - RestoreSelection(selectedKey); - } - - RefreshDetail(); - } + /// + protected override IEnumerable GetItems(WorkbenchData data) => + data.ReadModelDefinitions.OrderBy(d => d.ContainerName); - void RestoreSelection(string key) - { - if (_table is null) return; + /// + protected override string GetKey(WorkbenchReadModel item) => item.ContainerName; - for (var i = 0; i < _table.Rows.Count; i++) - { - if (_table.Rows[i].Tag is WorkbenchReadModel rm && rm.ContainerName == key) - { - _table.SelectedRowIndex = i; - return; - } - } - } + /// + protected override string[] BuildRow(WorkbenchReadModel item) => + [item.ContainerName, item.Owner, item.Source]; - void RefreshDetail() + /// + protected override string RenderDetail(WorkbenchReadModel? item, WorkbenchData? data) { - if (_table is null || _detailPane is null) return; - - if (_table.SelectedRow?.Tag is not WorkbenchReadModel rm) + if (item is null) { - _detailPane.Text = $"[{WorkbenchColors.Muted.ToMarkup()}]Select a read model.[/]"; - return; + return $"[{WorkbenchColors.Muted.ToMarkup()}]Select a read model.[/]"; } var acc = WorkbenchColors.Accent.ToMarkup(); var mut = WorkbenchColors.Muted.ToMarkup(); var suc = WorkbenchColors.Success.ToMarkup(); - var queryableColor = rm.IsQueryable ? suc : mut; + var queryableColor = item.IsQueryable ? suc : mut; - _detailPane.Text = string.Join( + return string.Join( "\n", $"[{acc}][bold]READ MODEL[/][/]", string.Empty, - $" [{mut}]Container[/] {rm.ContainerName}", - $" [{mut}]Display Name[/] {rm.DisplayName}", - $" [{mut}]Owner[/] {rm.Owner}", - $" [{mut}]Source[/] {rm.Source}", - $" [{mut}]Queryable[/] [{queryableColor}]{(rm.IsQueryable ? "Yes" : "No")}[/]", - $" [{mut}]Identifier[/] {rm.Identifier}", + $" [{mut}]Container[/] {item.ContainerName}", + $" [{mut}]Display Name[/] {item.DisplayName}", + $" [{mut}]Owner[/] {item.Owner}", + $" [{mut}]Source[/] {item.Source}", + $" [{mut}]Queryable[/] [{queryableColor}]{(item.IsQueryable ? "Yes" : "No")}[/]", + $" [{mut}]Identifier[/] {item.Identifier}", string.Empty, $"[{mut}]Press[/] [bold]Enter[/] [{mut}]to view instances[/]"); } + /// + protected override bool MatchesFilter(WorkbenchReadModel item, string filter) + { + if (filter.StartsWith("owner:", StringComparison.OrdinalIgnoreCase)) + { + return item.Owner.Contains(filter[6..], StringComparison.OrdinalIgnoreCase); + } + + return item.ContainerName.Contains(filter, StringComparison.OrdinalIgnoreCase) || + item.DisplayName.Contains(filter, StringComparison.OrdinalIgnoreCase); + } + + /// + protected override IEnumerable GetCompletions(string input) => + [ + "owner:client", + "owner:server" + ]; + + /// + protected override void OnRowActivated(WorkbenchReadModel item) => OpenDetailOverlay(item); + string BuildInstancesContent(WorkbenchReadModel rm) { var mut = WorkbenchColors.Muted.ToMarkup(); diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/RecommendationsView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/RecommendationsView.cs index b8447e6..8d19d62 100644 --- a/Source/Cli/Commands/Chronicle/Workbench/views/RecommendationsView.cs +++ b/Source/Cli/Commands/Chronicle/Workbench/views/RecommendationsView.cs @@ -1,21 +1,17 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -using SharpConsoleUI; -using SharpConsoleUI.Builders; -using SharpConsoleUI.Controls; -using UITableRow = SharpConsoleUI.Controls.TableRow; +using SharpConsoleUI.Layout; namespace Cratis.Cli.Commands.Chronicle.Workbench; /// -/// Recommendations tab — table of pending recommendations with apply/ignore actions in the bordered detail pane. +/// Recommendations tab — filterable table of pending recommendations with apply/ignore actions. /// -public class RecommendationsView : IWorkbenchView +public class RecommendationsView : FilterableTableView { - TableControl? _table; - PanelControl? _detailPanel; - WorkbenchData? _pendingData; + /// Gets the currently selected recommendation, or if none is selected. + public Recommendation? SelectedRecommendation => SelectedItem; /// /// Gets or sets the callback invoked when the user applies a recommendation. @@ -38,122 +34,101 @@ public class RecommendationsView : IWorkbenchView public Action>? OnIgnoreAll { get; set; } /// - /// Returns all recommendations that are currently checked (checkbox mode). + /// Gets all recommendations that are currently checked (checkbox mode). /// - /// A list of checked items. - public IReadOnlyList GetCheckedItems() => - [.. (_table?.GetCheckedRows() ?? []).Select(r => r.Tag).OfType()]; + public IReadOnlyList Checked => CheckedItems; /// - public void Dispose() - { - _table?.Dispose(); - _detailPanel?.Dispose(); - } + protected override IReadOnlyList<(string Name, TextJustification Justify, int? Width)> Columns => + [ + ("Name", TextJustification.Left, null), + ("Type", TextJustification.Left, 20) + ]; /// - public IWindowControl BuildContent(ConsoleWindowSystem windowSystem) - { - _table = Controls.Table() - .AddColumn("Name", SharpConsoleUI.Layout.TextJustification.Left, null) - .AddColumn("Type", SharpConsoleUI.Layout.TextJustification.Left, 20) - .Interactive() - .WithCheckboxMode() - .WithFiltering() - .WithSorting() - .WithVerticalScrollbar(ScrollbarVisibility.Auto) - .OnSelectedRowChanged((_, _) => RefreshDetail()) - .WithName("RecommendationsTable") - .Build(); - - _detailPanel = Controls.Panel() - .WithContent($"[{WorkbenchColors.Muted.ToMarkup()}]Select a recommendation.[/]") - .WithHeader(" RECOMMENDATION ") - .Rounded() - .WithBorderColor(WorkbenchColors.Warning) - .WithPadding(1, 0, 1, 0) - .FillVertical() - .WithName("RecommendationDetailPanel") - .Build(); - - var root = HorizontalGridControl.Create() - .Column(c => c.Add(_table)) - .WithSplitterAfter(0) - .Column(c => c.Width(50).Add(_detailPanel)) - .Build(); - - // Apply any data that arrived before controls were ready (NavigationView lazy init). - if (_pendingData is not null) - UpdateData(_pendingData); - - return root; - } + protected override string DetailPanelHeader => "RECOMMENDATION"; /// - public void UpdateData(WorkbenchData data) - { - _pendingData = data; - if (_table is null) return; + protected override SharpConsoleUI.Color DetailBorderColor => WorkbenchColors.Warning; - var selectedKey = (_table.SelectedRow?.Tag as Recommendation)?.Id.ToString(); + /// + protected override bool HasCheckboxMode => true; - _table.ClearRows(); - foreach (var rec in data.Recommendations) + /// + protected override IEnumerable<(string Label, string? Shortcut, Action Execute)> GetContextMenuActions(Recommendation item) + { + if (OnApply is not null) { - _table.AddRow(new UITableRow([rec.Name ?? rec.Id.ToString(), rec.Type ?? "—"]) { Tag = rec }); + yield return ("Apply recommendation", "A", () => OnApply(item)); } - if (selectedKey is not null) + if (OnIgnore is not null) { - RestoreSelection(selectedKey); + yield return ("Ignore recommendation", "I", () => OnIgnore(item)); } - RefreshDetail(); - } - - void RestoreSelection(string key) - { - if (_table is null) return; + var checkedCount = Checked.Count; + if (OnApplyAll is not null && checkedCount > 1) + { + yield return ($"Apply {checkedCount} checked", null, () => OnApplyAll(Checked)); + } - for (var i = 0; i < _table.Rows.Count; i++) + if (OnIgnoreAll is not null && checkedCount > 1) { - if (_table.Rows[i].Tag is Recommendation rec && rec.Id.ToString() == key) - { - _table.SelectedRowIndex = i; - return; - } + yield return ($"Ignore {checkedCount} checked", null, () => OnIgnoreAll(Checked)); } } - void RefreshDetail() - { - if (_table is null || _detailPanel is null) return; + /// + protected override IEnumerable GetItems(WorkbenchData data) => data.Recommendations; + + /// + protected override string GetKey(Recommendation item) => item.Id.ToString(); - if (_table.SelectedRow?.Tag is not Recommendation rec) + /// + protected override string[] BuildRow(Recommendation item) => + [item.Name ?? item.Id.ToString(), item.Type ?? "—"]; + + /// + protected override string RenderDetail(Recommendation? item, WorkbenchData? data) + { + if (item is null) { - _detailPanel.Content = $"[{WorkbenchColors.Muted.ToMarkup()}]Select a recommendation.[/]"; - return; + return $"[{WorkbenchColors.Muted.ToMarkup()}]Select a recommendation.[/]"; } var mut = WorkbenchColors.Muted.ToMarkup(); var lines = new List { - $"[{mut}]Name[/] {rec.Name ?? rec.Id.ToString()}", - $"[{mut}]Type[/] {rec.Type ?? "—"}", + $"[{mut}]Name[/] {item.Name ?? item.Id.ToString()}", + $"[{mut}]Type[/] {item.Type ?? "—"}" }; - if (!string.IsNullOrEmpty(rec.Description)) + if (!string.IsNullOrEmpty(item.Description)) { lines.Add(string.Empty); lines.Add($"[{mut}]Description:[/]"); - lines.Add($" {rec.Description}"); + lines.Add($" {item.Description}"); } lines.Add(string.Empty); - if (OnApply is not null) lines.Add($"[{mut}]Press[/] [bold]A[/] [{mut}]to apply[/]"); - if (OnIgnore is not null) lines.Add($"[{mut}]Press[/] [bold]I[/] [{mut}]to ignore[/]"); + if (OnApply is not null) + { + lines.Add($"[{mut}]Press[/] [bold]A[/] [{mut}]to apply[/]"); + } - _detailPanel.Content = string.Join('\n', lines); + if (OnIgnore is not null) + { + lines.Add($"[{mut}]Press[/] [bold]I[/] [{mut}]to ignore[/]"); + } + + return string.Join('\n', lines); } + + /// + protected override bool MatchesFilter(Recommendation item, string filter) => + (item.Name ?? string.Empty).Contains(filter, StringComparison.OrdinalIgnoreCase) || + (item.Type ?? string.Empty).Contains(filter, StringComparison.OrdinalIgnoreCase) || + item.Id.ToString().Contains(filter, StringComparison.OrdinalIgnoreCase); } diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/SubscriptionsView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/SubscriptionsView.cs new file mode 100644 index 0000000..b6ef277 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/views/SubscriptionsView.cs @@ -0,0 +1,71 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Chronicle.Contracts.Observation.EventStoreSubscriptions; +using SharpConsoleUI.Layout; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Subscriptions navigation item — filterable table of event store subscriptions with a detail pane. +/// +public class SubscriptionsView : FilterableTableView +{ + /// + protected override IReadOnlyList<(string Name, TextJustification Justify, int? Width)> Columns => + [ + ("Identifier", TextJustification.Left, null), + ("Source Store", TextJustification.Left, 30), + ("Event Types", TextJustification.Right, 12) + ]; + + /// + protected override string DetailPanelHeader => "SUBSCRIPTION"; + + /// + protected override IEnumerable GetItems(WorkbenchData data) => + data.EventStoreSubscriptions.OrderBy(s => s.Identifier); + + /// + protected override string GetKey(EventStoreSubscriptionDefinition item) => item.Identifier; + + /// + protected override string[] BuildRow(EventStoreSubscriptionDefinition item) => + [ + item.Identifier, + item.SourceEventStore, + (item.EventTypes?.Count ?? 0).ToString() + ]; + + /// + protected override string RenderDetail(EventStoreSubscriptionDefinition? item, WorkbenchData? data) + { + if (item is null) + { + return $"[{WorkbenchColors.Muted.ToMarkup()}]Select a subscription.[/]"; + } + + var mut = WorkbenchColors.Muted.ToMarkup(); + var acc = WorkbenchColors.Accent.ToMarkup(); + + var lines = new List + { + $"[{mut}]Identifier[/] {item.Identifier}", + $"[{mut}]Source Store[/] {item.SourceEventStore}", + string.Empty, + $"[{acc}]Event Types:[/]" + }; + + foreach (var et in item.EventTypes ?? []) + { + lines.Add($" • {et.Id}"); + } + + return string.Join('\n', lines); + } + + /// + protected override bool MatchesFilter(EventStoreSubscriptionDefinition item, string filter) => + item.Identifier.Contains(filter, StringComparison.OrdinalIgnoreCase) || + item.SourceEventStore.Contains(filter, StringComparison.OrdinalIgnoreCase); +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/UsersView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/UsersView.cs new file mode 100644 index 0000000..51c2936 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/views/UsersView.cs @@ -0,0 +1,70 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using SharpConsoleUI.Layout; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Users navigation item — filterable table of registered users with a detail pane. +/// +public class UsersView : FilterableTableView +{ + /// + protected override IReadOnlyList<(string Name, TextJustification Justify, int? Width)> Columns => + [ + ("Username", TextJustification.Left, null), + ("Email", TextJustification.Left, 30), + ("Active", TextJustification.Left, 8) + ]; + + /// + protected override string DetailPanelHeader => "USER"; + + /// + protected override IEnumerable GetItems(WorkbenchData data) => + data.Users.OrderBy(u => u.Username); + + /// + protected override string GetKey(User item) => item.Id.ToString(); + + /// + protected override string[] BuildRow(User item) + { + var activeColor = item.IsActive ? WorkbenchColors.Success.ToMarkup() : WorkbenchColors.Muted.ToMarkup(); + return + [ + item.Username, + item.Email ?? string.Empty, + $"[{activeColor}]{(item.IsActive ? "Yes" : "No")}[/]" + ]; + } + + /// + protected override string RenderDetail(User? item, WorkbenchData? data) + { + if (item is null) + { + return $"[{WorkbenchColors.Muted.ToMarkup()}]Select a user.[/]"; + } + + var mut = WorkbenchColors.Muted.ToMarkup(); + var suc = WorkbenchColors.Success.ToMarkup(); + var activeColor = item.IsActive ? suc : mut; + + return string.Join( + "\n", + $"[{mut}]Id[/] {item.Id}", + $"[{mut}]Username[/] {item.Username}", + $"[{mut}]Email[/] {item.Email ?? "—"}", + $"[{mut}]Active[/] [{activeColor}]{(item.IsActive ? "Yes" : "No")}[/]", + $"[{mut}]Has Logged In[/] {item.HasLoggedIn}", + $"[{mut}]Created[/] {item.CreatedAt}"); + } + + /// + protected override bool MatchesFilter(User item, string filter) => + item.Username.Contains(filter, StringComparison.OrdinalIgnoreCase) || + (item.Email ?? string.Empty).Contains(filter, StringComparison.OrdinalIgnoreCase) || + item.Id.ToString().Contains(filter, StringComparison.OrdinalIgnoreCase); +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/windows/DetailOverlayWindow.cs b/Source/Cli/Commands/Chronicle/Workbench/windows/DetailOverlayWindow.cs index 6a29364..f0c7610 100644 --- a/Source/Cli/Commands/Chronicle/Workbench/windows/DetailOverlayWindow.cs +++ b/Source/Cli/Commands/Chronicle/Workbench/windows/DetailOverlayWindow.cs @@ -36,14 +36,19 @@ public Window Build( foreach (var (tabName, content) in tabs) { - var markup = new MarkupControl([content]) { Wrap = true }; - var scrollPane = Controls.ScrollablePanel() - .AddControl(markup) - .WithVerticalScroll(ScrollMode.Scroll) - .WithPadding(1, 1, 1, 1) + // Use a read-only MultilineEdit so text can be selected with mouse/keyboard and copied. + // Markup is stripped to plain text — colors are not rendered in editable controls. + var plainText = Markup.Remove(content); + var editor = Controls.MultilineEdit(plainText) + .AsReadOnly(true) + .WrapWords() + .WithVerticalScrollbar(ScrollbarVisibility.Auto) + .WithSelectionColors(WorkbenchColors.Accent, WorkbenchColors.Background) + .WithColors(WorkbenchColors.Foreground, WorkbenchColors.Background) + .WithFocusedColors(WorkbenchColors.Foreground, WorkbenchColors.Background) .Build(); - tabBuilder.AddTab(tabName, scrollPane); + tabBuilder.AddTab(tabName, editor); } var tabControl = tabBuilder.Fill().Build(); diff --git a/Source/Cli/Commands/Chronicle/Workbench/windows/MainWindow.cs b/Source/Cli/Commands/Chronicle/Workbench/windows/MainWindow.cs index 70170d7..68bb1c0 100644 --- a/Source/Cli/Commands/Chronicle/Workbench/windows/MainWindow.cs +++ b/Source/Cli/Commands/Chronicle/Workbench/windows/MainWindow.cs @@ -7,113 +7,81 @@ using SharpConsoleUI; using SharpConsoleUI.Builders; using SharpConsoleUI.Controls; +using SharpConsoleUI.Helpers; using SColor = SharpConsoleUI.Color; +using UITableRow = SharpConsoleUI.Controls.TableRow; namespace Cratis.Cli.Commands.Chronicle.Workbench; /// -/// The main full-screen workbench window: navigation side pane, content area, live event log strip, and status bar. +/// The main full-screen workbench window: navigation side pane, content area, and status bar. +/// Serves as the composition root — delegates action confirmation to +/// and navigation building to . /// /// The SharpConsoleUI window system. /// The Chronicle data service. /// The workbench settings. /// The Chronicle gRPC service clients. /// Pre-fetched snapshot used to populate all views before the first frame is rendered. +/// Persisted workbench state from the previous session (last nav index, interval). public class MainWindow( ConsoleWindowSystem windowSystem, WorkbenchDataService dataService, WorkbenchSettings settings, IServices services, - WorkbenchData initialData) + WorkbenchData initialData, + WorkbenchState state) { - /// Navigation item index for Overview. - const int IndexOverview = 0; + /// Width of the help overlay window in columns. + const int HelpOverlayWidth = 72; - /// Navigation item index for Observers. - const int IndexObservers = 1; + /// Height of the help overlay window in rows. + const int HelpOverlayHeight = 40; - /// Navigation item index for Failures. - const int IndexFailures = 2; + /// Width of the command palette window in columns. + const int CommandPaletteWidth = 80; - /// Navigation item index for Jobs. - const int IndexJobs = 3; + /// Height of the command palette window in rows. + const int CommandPaletteHeight = 18; - /// Navigation item index for Recommendations. - const int IndexRecommendations = 4; - - /// Navigation item index for Event Types. - const int IndexEventTypes = 5; - - /// Navigation item index for Projections. - const int IndexProjections = 6; - - /// Navigation item index for Read Models. - const int IndexReadModels = 7; - - /// Navigation item index for Event Log. - const int IndexEventLog = 8; - - /// Navigation item index for Event Stores. - const int IndexEventStores = 9; - - /// Navigation item index for Namespaces. - const int IndexNamespaces = 10; - - /// Maximum number of raw event stream lines retained in the ring buffer for filter replay. - const int StreamBufferCapacity = 500; - - /// Color palette for coloring event type names in the live stream by a hash of their ID. - static readonly SColor[] _streamPalette = - [ - new(122, 162, 247, 255), // electric blue (Accent) - new(115, 218, 118, 255), // vivid green (Success) - new(224, 175, 104, 255), // amber (Warning) - new(187, 154, 247, 255), // mauve/purple - new(42, 195, 222, 255), // teal/cyan - new(247, 118, 142, 255), // coral-red (Danger) - new(255, 199, 119, 255), // gold - new(137, 220, 235, 255), // sky blue - new(166, 209, 137, 255), // sage green - new(238, 153, 160, 255), // rose - ]; + /// Maximum number of results shown in the command palette. + const int MaxCommandPaletteResults = 10; /// /// View instances — created once, reused across refreshes. - /// Order must match the nav index constants above. + /// Order must match the WorkbenchNavigation.IndexXxx constants. /// readonly IWorkbenchView[] _views = [ - new OverviewView(), - new ObserversView(), - new FailedPartitionsView(), - new JobsView(), - new RecommendationsView(), - new EventTypesView(), - new ProjectionsView(), - new ReadModelsView(), - new EventLogView(), - new EventStoresView(), - new NamespacesView() + new OverviewView(), // 0 Overview + new ObserversView(), // 1 Observers + new FailedPartitionsView(), // 2 Failures + new JobsView(), // 3 Jobs + new RecommendationsView(), // 4 Recommendations + new EventSequencesView(), // 5 Event Sequences + new EventTypesView(), // 6 Event Types + new ProjectionsView(), // 7 Projections + new ReadModelsView(), // 8 Read Models + new EventStoresView(), // 9 Event Stores + new NamespacesView(), // 10 Namespaces + new ApplicationsView(), // 11 Applications + new UsersView(), // 12 Users + new IdentitiesView(), // 13 Identities + new SubscriptionsView(), // 14 Subscriptions ]; readonly object _dataLock = new(); - readonly Queue _eventStreamBuffer = new(); string? _activeEventStore; string? _activeNamespace; WorkbenchData? _currentData; - bool _eventStreamVisible = true; - ulong _lastEventStreamSeq; bool _wasDisconnected; - string _streamFilter = string.Empty; - (string Description, Func Execute)? _pendingAction; - bool _textInputFocused; - NavigationView? _navView; + Window? _window; StatusBarControl? _statusBar; MarkupControl? _titleBar; - LogViewerControl? _eventStreamLog; - NavigationItem? _observersItem; - NavigationItem? _failuresItem; - NavigationItem? _recommendationsItem; + + WorkbenchActionHandler? _actionHandler; + WorkbenchNavigation? _navigation; + bool _sidebarExpanded = true; /// /// Builds the main window with all controls and the async update thread. @@ -121,28 +89,57 @@ public class MainWindow( /// The constructed . public Window Build() { + _actionHandler = new WorkbenchActionHandler(text => + { + if (string.IsNullOrEmpty(text)) + { + UpdateStatusBar(); + } + else + { + UpdateStatusRight(text); + } + }); + + _navigation = new WorkbenchNavigation( + windowSystem, + _views, + settings, + () => _activeEventStore, + () => _activeNamespace, + storeName => + { + _activeEventStore = storeName; + _activeNamespace = null; + SwitchToOverview(); + _ = Task.Run(() => FetchAndUpdate(CancellationToken.None)); + }, + nsName => + { + _activeNamespace = nsName; + _ = Task.Run(() => FetchAndUpdate(CancellationToken.None)); + }, + () => _ = Task.Run(() => FetchAndUpdate(CancellationToken.None)), + () => + { + lock (_dataLock) + { + return _currentData; + } + }); + WireViewCallbacks(); - var navView = BuildNavigationView(); - var logViewer = BuildEventStreamLogViewer(); - var streamFilterPrompt = BuildStreamFilterPrompt(); + var navView = _navigation.BuildNavigationView(); var statusBar = BuildStatusBar(); - var splitterBar = Controls.HorizontalSplitter() - .WithMinHeightAbove(6) - .WithMinHeightBelow(4) - .WithControls(navView, logViewer) - .Build(); - - // Populate all views with the pre-fetched snapshot before the first frame is rendered, - // so every navigation pane has real data from the moment the window opens. _currentData = initialData; PushDataToViews(initialData); UpdateStatusBar(initialData); - UpdateNavBadges(initialData); + _navigation.UpdateNavBadges(initialData); - return new WindowBuilder(windowSystem) - .WithTitle("Chronicle Workbench") + var builtWindow = new WindowBuilder(windowSystem) + .WithTitle(string.Empty) .Maximized() .WithColors(WorkbenchColors.Foreground, WorkbenchColors.Background) .Borderless() // cspell:ignore Borderless @@ -150,26 +147,24 @@ public Window Build() .HideCloseButton() .AddControl(BuildTitleBar()) .AddControl(navView) - .AddControl(splitterBar) - .AddControl(streamFilterPrompt) - .AddControl(logViewer) .AddControl(statusBar) .OnKeyPressed((_, e) => HandleKeyPress(e)) .WithAsyncWindowThread(RunDataRefreshLoop) .Build(); - } - static string TruncateId(string s) => s.Length <= 40 ? s : s[..37] + "…"; + _window = builtWindow; - static SColor EventTypeColor(string eventTypeId) - { - var hash = Math.Abs(eventTypeId.GetHashCode()); - return _streamPalette[hash % _streamPalette.Length]; + // Restore the last active navigation item from the previous session. + if (_navigation?.NavView is not null && state.LastNavIndex > 0 && state.LastNavIndex < _views.Length) + _navigation.NavView.SelectedIndex = state.LastNavIndex; + + return builtWindow; } + static string TruncateId(string s) => s.Length <= 40 ? s : s[..37] + "…"; + /// - /// Extracts the host:port portion from a Chronicle connection string, - /// stripping credentials and query parameters so the title bar shows only the endpoint. + /// Extracts the host:port portion from a Chronicle connection string. /// /// The raw connection string, possibly including credentials. /// A clean chronicle://host:port string, or the original if parsing fails. @@ -198,21 +193,11 @@ static string ExtractHostFromConnectionString(string connectionString) return $"chronicle://{afterScheme}"; } - static NavigationItem? FindItemByText(IReadOnlyList items, string text) - { - foreach (var item in items) - { - if (item.Text == text) return item; - } - - return null; - } - void WireViewCallbacks() { - if (_views[IndexObservers] is ObserversView ov) + if (_views[WorkbenchNavigation.IndexObservers] is ObserversView ov) { - ov.OnReplay = obs => ExecuteAction( + ov.OnReplay = obs => _actionHandler!.ExecuteAction( $"Replay observer '{TruncateId(obs.Id)}'", () => services.Observers.Replay(new Replay { @@ -222,7 +207,7 @@ void WireViewCallbacks() EventSequenceId = CliDefaults.DefaultEventSequenceId })); - ov.OnReplayAll = observers => ConfirmThenExecuteAll( + ov.OnReplayAll = observers => _actionHandler!.ConfirmThenExecuteAll( $"Replay {observers.Count} observer{(observers.Count == 1 ? string.Empty : "s")}", observers, obs => services.Observers.Replay(new Replay @@ -234,9 +219,9 @@ void WireViewCallbacks() })); } - if (_views[IndexFailures] is FailedPartitionsView fv) + if (_views[WorkbenchNavigation.IndexFailures] is FailedPartitionsView fv) { - fv.OnRetryPartition = fp => ExecuteAction( + fv.OnRetryPartition = fp => _actionHandler!.ExecuteAction( $"Retry partition '{fp.Partition}'", () => services.Observers.RetryPartition(new RetryPartition { @@ -247,7 +232,7 @@ void WireViewCallbacks() EventSequenceId = CliDefaults.DefaultEventSequenceId })); - fv.OnReplayPartition = fp => ExecuteAction( + fv.OnReplayPartition = fp => _actionHandler!.ExecuteAction( $"Replay partition '{fp.Partition}'", () => services.Observers.ReplayPartition(new ReplayPartition { @@ -258,7 +243,7 @@ void WireViewCallbacks() EventSequenceId = CliDefaults.DefaultEventSequenceId })); - fv.OnRetryAll = partitions => ConfirmThenExecuteAll( + fv.OnRetryAll = partitions => _actionHandler!.ConfirmThenExecuteAll( $"Retry {partitions.Count} partition{(partitions.Count == 1 ? string.Empty : "s")}", partitions, fp => services.Observers.RetryPartition(new RetryPartition @@ -270,7 +255,7 @@ void WireViewCallbacks() EventSequenceId = CliDefaults.DefaultEventSequenceId })); - fv.OnReplayAll = partitions => ConfirmThenExecuteAll( + fv.OnReplayAll = partitions => _actionHandler!.ConfirmThenExecuteAll( $"Replay {partitions.Count} partition{(partitions.Count == 1 ? string.Empty : "s")}", partitions, fp => services.Observers.ReplayPartition(new ReplayPartition @@ -283,9 +268,9 @@ void WireViewCallbacks() })); } - if (_views[IndexJobs] is JobsView jv) + if (_views[WorkbenchNavigation.IndexJobs] is JobsView jv) { - jv.OnStopJob = job => ExecuteAction( + jv.OnStopJob = job => _actionHandler!.ExecuteAction( $"Stop job '{TruncateId(job.Type ?? job.Id.ToString())}'", () => services.Jobs.Stop(new StopJob { @@ -294,7 +279,7 @@ void WireViewCallbacks() JobId = job.Id })); - jv.OnResumeJob = job => ExecuteAction( + jv.OnResumeJob = job => _actionHandler!.ExecuteAction( $"Resume job '{TruncateId(job.Type ?? job.Id.ToString())}'", () => services.Jobs.Resume(new ResumeJob { @@ -303,7 +288,7 @@ void WireViewCallbacks() JobId = job.Id })); - jv.OnStopAll = jobs => ConfirmThenExecuteAll( + jv.OnStopAll = jobs => _actionHandler!.ConfirmThenExecuteAll( $"Stop {jobs.Count} job{(jobs.Count == 1 ? string.Empty : "s")}", jobs, job => services.Jobs.Stop(new StopJob @@ -313,7 +298,7 @@ void WireViewCallbacks() JobId = job.Id })); - jv.OnResumeAll = jobs => ConfirmThenExecuteAll( + jv.OnResumeAll = jobs => _actionHandler!.ConfirmThenExecuteAll( $"Resume {jobs.Count} job{(jobs.Count == 1 ? string.Empty : "s")}", jobs, job => services.Jobs.Resume(new ResumeJob @@ -324,9 +309,9 @@ void WireViewCallbacks() })); } - if (_views[IndexRecommendations] is RecommendationsView rv) + if (_views[WorkbenchNavigation.IndexRecommendations] is RecommendationsView rv) { - rv.OnApply = rec => ExecuteAction( + rv.OnApply = rec => _actionHandler!.ExecuteAction( $"Apply recommendation '{TruncateId(rec.Name ?? rec.Id.ToString())}'", () => services.Recommendations.Perform(new Perform { @@ -335,7 +320,7 @@ void WireViewCallbacks() RecommendationId = rec.Id })); - rv.OnIgnore = rec => ExecuteAction( + rv.OnIgnore = rec => _actionHandler!.ExecuteAction( $"Ignore recommendation '{TruncateId(rec.Name ?? rec.Id.ToString())}'", () => services.Recommendations.Ignore(new Perform { @@ -344,7 +329,7 @@ void WireViewCallbacks() RecommendationId = rec.Id })); - rv.OnApplyAll = recs => ConfirmThenExecuteAll( + rv.OnApplyAll = recs => _actionHandler!.ConfirmThenExecuteAll( $"Apply {recs.Count} recommendation{(recs.Count == 1 ? string.Empty : "s")}", recs, rec => services.Recommendations.Perform(new Perform @@ -354,7 +339,7 @@ void WireViewCallbacks() RecommendationId = rec.Id })); - rv.OnIgnoreAll = recs => ConfirmThenExecuteAll( + rv.OnIgnoreAll = recs => _actionHandler!.ConfirmThenExecuteAll( $"Ignore {recs.Count} recommendation{(recs.Count == 1 ? string.Empty : "s")}", recs, rec => services.Recommendations.Ignore(new Perform @@ -365,7 +350,7 @@ void WireViewCallbacks() })); } - if (_views[IndexReadModels] is ReadModelsView rmv) + if (_views[WorkbenchNavigation.IndexReadModels] is ReadModelsView rmv) { rmv.OnFetchInstances = async (containerName, ct) => await dataService.FetchAsync( @@ -375,28 +360,63 @@ await dataService.FetchAsync( ct); } - if (_views[IndexEventStores] is EventStoresView esv) + if (_views[WorkbenchNavigation.IndexEventStores] is EventStoresView esv) { esv.OnSwitch = storeName => { _activeEventStore = storeName; _activeNamespace = null; SwitchToOverview(); + _ = Task.Run(() => FetchAndUpdate(CancellationToken.None)); }; } - if (_views[IndexNamespaces] is NamespacesView nsv) + if (_views[WorkbenchNavigation.IndexNamespaces] is NamespacesView nsv) { nsv.OnSwitch = nsName => { _activeNamespace = nsName; SwitchToOverview(); + _ = Task.Run(() => FetchAndUpdate(CancellationToken.None)); + }; + } + + if (_views[WorkbenchNavigation.IndexEventSequences] is EventSequencesView seqView) + { + seqView.OnViewEventTypeDefinition = evt => + { + _navigation?.NavigateTo(WorkbenchNavigation.IndexEventTypes); + if (_views[WorkbenchNavigation.IndexEventTypes] is EventTypesView etv) + { + etv.SetFilter(evt.Context.EventType.Id); + } + }; + + seqView.OnViewObserversForType = evt => + { + _navigation?.NavigateTo(WorkbenchNavigation.IndexObservers); + if (_views[WorkbenchNavigation.IndexObservers] is ObserversView ov) + { + ov.SetFilter($"event:{evt.Context.EventType.Id}"); + } + }; + } + + if (_views[WorkbenchNavigation.IndexEventTypes] is EventTypesView etView) + { + etView.OnViewObservers = reg => + { + _navigation?.NavigateTo(WorkbenchNavigation.IndexObservers); + if (_views[WorkbenchNavigation.IndexObservers] is ObserversView ov) + { + ov.SetFilter($"event:{reg.Type.Id}"); + } }; } foreach (var view in _views) { - view.OnFilterFocusChanged = focused => _textInputFocused = focused; + view.OnFilterFocusChanged = focused => _actionHandler!.TextInputFocused = focused; } } @@ -424,108 +444,13 @@ MarkupControl BuildTitleBar() return control; } - NavigationView BuildNavigationView() - { - var selectedBg = new SColor(49, 50, 68, 255); - var selectedFg = WorkbenchColors.Accent; - - var navView = Controls.NavigationView() - .WithNavWidth(22) - .WithPaneHeader($"[bold {WorkbenchColors.Accent.ToMarkup()}] CHRONICLE [/]") - .WithSelectedColors(selectedFg, selectedBg) - .WithPaneDisplayMode(NavigationViewDisplayMode.Expanded) - .WithName("MainNav") - .Fill() - .AddHeader("DASHBOARD", h => - h.AddItem("Overview", "◆", null, panel => - panel.AddControl(_views[IndexOverview].BuildContent(windowSystem)))) - .AddHeader("OBSERVATION", h => - h.AddItem("Observers", "o", null, panel => - panel.AddControl(_views[IndexObservers].BuildContent(windowSystem))) - .AddItem("Failures", "!", null, panel => - panel.AddControl(_views[IndexFailures].BuildContent(windowSystem)))) - .AddHeader("OPERATIONS", h => - h.AddItem("Jobs", "~", null, panel => - panel.AddControl(_views[IndexJobs].BuildContent(windowSystem))) - .AddItem("Recommendations", "*", null, panel => - panel.AddControl(_views[IndexRecommendations].BuildContent(windowSystem)))) - .AddHeader("SCHEMA", h => - h.AddItem("Event Types", "#", null, panel => - panel.AddControl(_views[IndexEventTypes].BuildContent(windowSystem))) - .AddItem("Projections", ">", null, panel => - panel.AddControl(_views[IndexProjections].BuildContent(windowSystem))) - .AddItem("Read Models", "=", null, panel => - panel.AddControl(_views[IndexReadModels].BuildContent(windowSystem)))) - .AddHeader("DATA", h => - h.AddItem("Event Log", "-", null, panel => - panel.AddControl(_views[IndexEventLog].BuildContent(windowSystem))) - .AddItem("Event Stores", "+", null, panel => - panel.AddControl(_views[IndexEventStores].BuildContent(windowSystem))) - .AddItem("Namespaces", "@", null, panel => - panel.AddControl(_views[IndexNamespaces].BuildContent(windowSystem)))) - .OnSelectedItemChanged((_, e) => - { - var idx = e.NewIndex; - if (idx < 0 || idx >= _views.Length) return; - - WorkbenchData? snapshot; - lock (_dataLock) - { - snapshot = _currentData; - } - - if (snapshot is not null) - { - _views[idx].UpdateData(snapshot); - } - else - { - _ = Task.Run(() => FetchAndUpdate(CancellationToken.None)); - } - }) - .Build(); - - var items = navView.Items; - _observersItem = FindItemByText(items, "Observers"); - _failuresItem = FindItemByText(items, "Failures"); - _recommendationsItem = FindItemByText(items, "Recommendations"); - - _navView = navView; - return navView; - } - - LogViewerControl BuildEventStreamLogViewer() - { - var logViewer = new LogViewerControl(windowSystem.LogService) - { - Title = "Live Event Stream", - AutoScroll = true, - Name = "EventStream" - }; - _eventStreamLog = logViewer; - return logViewer; - } - - PromptControl BuildStreamFilterPrompt() - { - var mut = WorkbenchColors.Muted.ToMarkup(); - return Controls.Prompt($"[{mut}]Stream: [/]") - .WithName("StreamFilter") - .OnInputChanged((_, text) => ApplyStreamFilter(text ?? string.Empty)) - .OnGotFocus((_, _) => _textInputFocused = true) - .OnLostFocus((_, _) => _textInputFocused = false) - .Build(); - } - StatusBarControl BuildStatusBar() { var statusBar = Controls.StatusBar() .WithName("StatusBar") .StickyBottom() - .AddLeft("1-0", "Jump", null) - .AddLeft("<-", "Sidebar", null) - .AddLeft("T", "Toggle Log", ToggleEventStream) - .AddLeft("C", "Clear Log", ClearEventStream) + .AddLeft("F", "Filter", null) + .AddLeft("?", "Help", () => OpenHelpOverlay()) .AddLeft("Q", "Quit", null) .AddRight(string.Empty, "Connecting...", null) .Build(); @@ -557,6 +482,8 @@ async Task FetchAndUpdate(CancellationToken ct) { try { + UpdateStatusRight($"[{WorkbenchColors.Muted.ToMarkup()}]↻ refreshing…[/]"); + var data = await dataService.FetchAsync( _activeEventStore, _activeNamespace, @@ -569,7 +496,6 @@ async Task FetchAndUpdate(CancellationToken ct) } PushDataToViews(data); - FetchNewEvents(data, ct); if (_wasDisconnected && data.IsConnected) { @@ -587,7 +513,7 @@ async Task FetchAndUpdate(CancellationToken ct) _wasDisconnected = !data.IsConnected; UpdateStatusBar(data); - UpdateNavBadges(data); + _navigation?.UpdateNavBadges(data); if (_titleBar is MarkupControl titleBar) { @@ -599,7 +525,7 @@ async Task FetchAndUpdate(CancellationToken ct) } catch { - // Swallow — connectivity errors shown in status bar via IsConnected + // Swallow — connectivity errors shown in status bar via IsConnected. } } @@ -611,98 +537,18 @@ void PushDataToViews(WorkbenchData data) } } - void FetchNewEvents(WorkbenchData data, CancellationToken ct) - { - if (data.TailSequenceNumber is null || _eventStreamLog is null) return; - - var afterSeq = _lastEventStreamSeq; - if (data.TailSequenceNumber.Value <= afterSeq) return; - - _ = Task.Run( - async () => - { - try - { - var newEvents = await dataService.FetchNewEventsAsync( - afterSeq, - _activeEventStore, - _activeNamespace, - ct); - - foreach (var evt in newEvents) - { - var typeColor = EventTypeColor(evt.Context.EventType.Id).ToMarkup(); - var line = - $"[{WorkbenchColors.Muted.ToMarkup()}]{evt.Context.Occurred}[/] [{typeColor}]{evt.Context.EventType.Id}[/] [{WorkbenchColors.Muted.ToMarkup()}]{evt.Context.EventSourceId ?? string.Empty}[/] [bold]#{evt.Context.SequenceNumber:N0}[/]"; - - AppendToStreamBuffer(line); - } - - if (newEvents.Count > 0) - { - _lastEventStreamSeq = newEvents[^1].Context.SequenceNumber; - } - - if (_views[IndexOverview] is OverviewView ov) - { - ov.UpdateEventDelta(newEvents.Count); - } - } - catch - { - // Best-effort display — ignore fetch errors for live stream - } - }, - ct); - } - - void AppendToStreamBuffer(string line) - { - lock (_eventStreamBuffer) - { - while (_eventStreamBuffer.Count >= StreamBufferCapacity) - { - _eventStreamBuffer.Dequeue(); - } - - _eventStreamBuffer.Enqueue(line); - } - - if (string.IsNullOrEmpty(_streamFilter) || - line.Contains(_streamFilter, StringComparison.OrdinalIgnoreCase)) - { - windowSystem.LogService.LogInfo(line, "events"); - } - } - - void ApplyStreamFilter(string filter) + void UpdateStatusBar(WorkbenchData? data = null) { - _streamFilter = filter; - - windowSystem.LogService.ClearLogs(); - - string[] buffered; - lock (_eventStreamBuffer) + if (_statusBar is null) { - buffered = [.. _eventStreamBuffer]; + return; } - foreach (var line in buffered) + data ??= _currentData; + if (data is null) { - if (string.IsNullOrEmpty(filter) || - line.Contains(filter, StringComparison.OrdinalIgnoreCase)) - { - windowSystem.LogService.LogInfo(line, "events"); - } + return; } - } - - void UpdateStatusBar(WorkbenchData? data = null) - { - if (_statusBar is null) return; - - data ??= _currentData; - if (data is null) return; _statusBar.ClearRight(); @@ -721,82 +567,28 @@ void UpdateStatusBar(WorkbenchData? data = null) var ns = _activeNamespace ?? settings.ResolveNamespace(); _statusBar.AddRightText( - $"{connDot}{seqText} [bold {acc}][E][/] [{mut}]{eventStore}[/] [bold {acc}][N][/] [{mut}]{ns}[/] ↻{settings.Interval}s", + $"{connDot}{seqText} [{acc}]{eventStore}[/] [{mut}]/[/] [{acc}]{ns}[/] [{mut}]↻{settings.Interval}s[/]", null); } - void UpdateNavBadges(WorkbenchData data) - { - var problemCount = data.DisconnectedObservers + data.ReplayingObservers; - - if (_observersItem is NavigationItem observersItem) - { - observersItem.Subtitle = problemCount > 0 ? $"⚠{problemCount}" : string.Empty; - } - - if (_failuresItem is NavigationItem failuresItem) - { - failuresItem.Subtitle = data.FailedPartitions.Count > 0 - ? data.FailedPartitions.Count.ToString() - : string.Empty; - } - - if (_recommendationsItem is NavigationItem recommendationsItem) - { - recommendationsItem.Subtitle = data.Recommendations.Count > 0 - ? data.Recommendations.Count.ToString() - : string.Empty; - } - - _navView?.Invalidate(); - } - - void ToggleEventStream() + void HandleKeyPress(KeyPressedEventArgs e) { - _eventStreamVisible = !_eventStreamVisible; - if (_eventStreamLog is LogViewerControl log) + if (_navigation?.NavView is null) { - log.Visible = _eventStreamVisible; + return; } - } - - void ClearEventStream() => windowSystem.LogService.ClearLogs(); - void HandleKeyPress(KeyPressedEventArgs e) - { - if (_navView is null) return; - - // When a destructive action is pending, intercept Y/N/Escape for confirmation. - if (_pendingAction is not null) + if (_actionHandler!.HandlePendingKeyPress(e.KeyInfo, () => UpdateStatusBar())) { - switch (e.KeyInfo.Key) - { - case ConsoleKey.Y: - var pending = _pendingAction.Value; - _pendingAction = null; - RunPendingAction(pending.Description, pending.Execute); - e.Handled = true; - return; - - case ConsoleKey.N: - case ConsoleKey.Escape: - _pendingAction = null; - UpdateStatusBar(); - e.Handled = true; - return; - } - - // Swallow all other keys while confirmation is shown. e.Handled = true; return; } - // When a text input has focus, suppress all global shortcuts except Escape (to clear filter). - if (_textInputFocused) + if (_actionHandler!.TextInputFocused) { if (e.KeyInfo.Key == ConsoleKey.Escape) { - var idx = _navView.SelectedIndex; + var idx = _navigation.CurrentViewIndex; if (idx >= 0 && idx < _views.Length) { _views[idx].ClearFilter(); @@ -808,40 +600,138 @@ void HandleKeyPress(KeyPressedEventArgs e) return; } + var navView = _navigation.NavView; switch (e.KeyInfo.Key) { - case ConsoleKey.D1: _navView.SelectedIndex = IndexOverview; e.Handled = true; break; - case ConsoleKey.D2: _navView.SelectedIndex = IndexObservers; e.Handled = true; break; - case ConsoleKey.D3: _navView.SelectedIndex = IndexFailures; e.Handled = true; break; - case ConsoleKey.D4: _navView.SelectedIndex = IndexJobs; e.Handled = true; break; - case ConsoleKey.D5: _navView.SelectedIndex = IndexRecommendations; e.Handled = true; break; - case ConsoleKey.D6: _navView.SelectedIndex = IndexEventTypes; e.Handled = true; break; - case ConsoleKey.D7: _navView.SelectedIndex = IndexProjections; e.Handled = true; break; - case ConsoleKey.D8: _navView.SelectedIndex = IndexReadModels; e.Handled = true; break; - case ConsoleKey.D9: _navView.SelectedIndex = IndexEventLog; e.Handled = true; break; - case ConsoleKey.D0: _navView.SelectedIndex = IndexEventStores; e.Handled = true; break; - case ConsoleKey.LeftArrow: FocusNavigation(); e.Handled = true; break; - case ConsoleKey.E: OpenEventStorePicker(); e.Handled = true; break; - case ConsoleKey.N: OpenNamespacePicker(); e.Handled = true; break; - case ConsoleKey.Enter when _navView.SelectedIndex == IndexReadModels: + case ConsoleKey.LeftArrow: + FocusNavigation(); + e.Handled = true; + break; + + case ConsoleKey.RightArrow: + FocusContent(); + e.Handled = true; + break; + + case ConsoleKey.B when e.KeyInfo.Modifiers.HasFlag(ConsoleModifiers.Control): + ToggleSidebar(); + e.Handled = true; + break; + + case ConsoleKey.Oem5 when e.KeyInfo.Modifiers.HasFlag(ConsoleModifiers.Control): + ToggleDetailPane(); + e.Handled = true; + break; + + case ConsoleKey.E when e.KeyInfo.Modifiers.HasFlag(ConsoleModifiers.Control): + _navigation.OpenEventStorePicker(); + e.Handled = true; + break; + + case ConsoleKey.N when e.KeyInfo.Modifiers.HasFlag(ConsoleModifiers.Control): + _navigation.OpenNamespacePicker(); + e.Handled = true; + break; + + case ConsoleKey.C when e.KeyInfo.Modifiers.HasFlag(ConsoleModifiers.Control): + CopyDetailToClipboard(); + e.Handled = true; + break; + + case ConsoleKey.Enter when _navigation.CurrentViewIndex == WorkbenchNavigation.IndexReadModels: OpenReadModelDetail(); e.Handled = true; break; + + case ConsoleKey.D when _navigation.CurrentViewIndex == WorkbenchNavigation.IndexEventSequences: + TriggerEventSequenceAction(seqView => seqView.OnViewEventTypeDefinition, seqView => seqView.SelectedEvent); + e.Handled = true; + break; + + case ConsoleKey.V when _navigation.CurrentViewIndex == WorkbenchNavigation.IndexEventSequences: + TriggerEventSequenceAction(seqView => seqView.OnViewObserversForType, seqView => seqView.SelectedEvent); + e.Handled = true; + break; + + case ConsoleKey.V when _navigation.CurrentViewIndex == WorkbenchNavigation.IndexEventTypes: + TriggerEventTypeAction(etv => etv.OnViewObservers, etv => etv.SelectedEventType); + e.Handled = true; + break; + case ConsoleKey.Oem2 when e.KeyInfo.Modifiers == ConsoleModifiers.Shift: OpenHelpOverlay(); e.Handled = true; break; - case ConsoleKey.T: ToggleEventStream(); e.Handled = true; break; - case ConsoleKey.C: ClearEventStream(); e.Handled = true; break; + case ConsoleKey.P when e.KeyInfo.Modifiers.HasFlag(ConsoleModifiers.Control): OpenCommandPalette(); e.Handled = true; break; + + case ConsoleKey.Oem6: // ] — next page (Mac-friendly alternative to PageDown) + case ConsoleKey.PageDown: + { + var idx = _navigation.CurrentViewIndex; + if (idx >= 0 && idx < _views.Length) _views[idx].NextPage(); + e.Handled = true; + break; + } + + case ConsoleKey.Oem4: // [ — previous page (Mac-friendly alternative to PageUp) + case ConsoleKey.PageUp: + { + var idx = _navigation.CurrentViewIndex; + if (idx >= 0 && idx < _views.Length) _views[idx].PreviousPage(); + e.Handled = true; + break; + } + + case ConsoleKey.R when _navigation.CurrentViewIndex == WorkbenchNavigation.IndexObservers: + TriggerSelectedObserverReplay(); + e.Handled = true; + break; + + case ConsoleKey.T when _navigation.CurrentViewIndex == WorkbenchNavigation.IndexFailures: + TriggerSelectedPartitionRetry(); + e.Handled = true; + break; + + case ConsoleKey.P when _navigation.CurrentViewIndex == WorkbenchNavigation.IndexFailures: + TriggerSelectedPartitionReplay(); + e.Handled = true; + break; + + case ConsoleKey.S when _navigation.CurrentViewIndex == WorkbenchNavigation.IndexJobs: + TriggerSelectedJobStop(); + e.Handled = true; + break; + + case ConsoleKey.U when _navigation.CurrentViewIndex == WorkbenchNavigation.IndexJobs: + TriggerSelectedJobResume(); + e.Handled = true; + break; + + case ConsoleKey.A when _navigation.CurrentViewIndex == WorkbenchNavigation.IndexRecommendations: + TriggerSelectedRecommendationApply(); + e.Handled = true; + break; + + case ConsoleKey.I when _navigation.CurrentViewIndex == WorkbenchNavigation.IndexRecommendations: + TriggerSelectedRecommendationIgnore(); + e.Handled = true; + break; + case ConsoleKey.F: ActivateCurrentFilter(); e.Handled = true; break; - case ConsoleKey.Q: Environment.Exit(0); break; + + case ConsoleKey.Q: + state.Interval = settings.Interval; + state.LastNavIndex = _navigation?.CurrentViewIndex ?? 0; + state.Save(); + Environment.Exit(0); + break; } } @@ -850,17 +740,33 @@ void OpenHelpOverlay() var mut = WorkbenchColors.Muted.ToMarkup(); var acc = WorkbenchColors.Accent.ToMarkup(); + var currentViewHelp = string.Empty; + var activeIdx = _navigation?.CurrentViewIndex ?? -1; + if (activeIdx >= 0 && activeIdx < _views.Length && !string.IsNullOrEmpty(_views[activeIdx].ViewHelp)) + { + currentViewHelp = + $"[bold {acc}]THIS VIEW[/]\n" + + string.Join('\n', _views[activeIdx].ViewHelp.Split('\n').Select(l => $" {l}")) + + "\n\n"; + } + var helpText = + currentViewHelp + $"[bold {acc}]NAVIGATION[/]\n" + - $" [{mut}]1–9/0[/] Jump to section\n" + $" [{mut}]↑ ↓[/] Move selection\n" + $" [{mut}]← / →[/] Sidebar ↔ Content\n" + + $" [{mut}]Ctrl+B[/] Toggle sidebar expand / compact\n" + + $" [{mut}]Ctrl+\\[/] Toggle detail pane\n" + $" [{mut}]Enter[/] Open detail overlay\n" + $" [{mut}]Esc[/] Close overlay\n" + "\n" + + $"[bold {acc}]PAGING[/]\n" + + $" [{mut}][ / ][/] Previous / next page\n" + + $" [{mut}]◄ ► buttons[/] Click to change page\n" + + "\n" + $"[bold {acc}]QUICK SWITCH[/]\n" + - $" [{mut}]E[/] Switch event store\n" + - $" [{mut}]N[/] Switch namespace\n" + + $" [{mut}]Ctrl+E[/] Switch event store\n" + + $" [{mut}]Ctrl+N[/] Switch namespace\n" + "\n" + $"[bold {acc}]FILTER[/]\n" + $" [{mut}]F[/] Focus filter prompt for current view\n" + @@ -872,11 +778,12 @@ void OpenHelpOverlay() $" [{mut}]P[/] Replay partition\n" + $" [{mut}]S / U[/] Stop / Resume job\n" + $" [{mut}]A / I[/] Apply / Ignore recommendation\n" + + $" [{mut}]D[/] View event type definition (Event Sequences)\n" + + $" [{mut}]V[/] View observers for event type\n" + $" [{mut}]Y / N[/] Confirm / Cancel action\n" + "\n" + - $"[bold {acc}]LIVE STREAM[/]\n" + - $" [{mut}]T[/] Toggle event stream\n" + - $" [{mut}]C[/] Clear event stream\n" + + $"[bold {acc}]CLIPBOARD[/]\n" + + $" [{mut}]Ctrl+C[/] Copy detail pane content to clipboard\n" + "\n" + $"[bold {acc}]GENERAL[/]\n" + $" [{mut}]+ / -[/] Increase / decrease refresh interval\n" + @@ -895,7 +802,7 @@ void OpenHelpOverlay() helpWindow = new WindowBuilder(windowSystem) .WithTitle(" Keyboard Shortcuts ") .WithColors(WorkbenchColors.Foreground, WorkbenchColors.Background) - .WithSize(70, 35) + .WithSize(HelpOverlayWidth, HelpOverlayHeight) .Centered() .AddControl(content) .OnKeyPressed((_, e) => @@ -913,31 +820,140 @@ void OpenHelpOverlay() windowSystem.AddWindow(helpWindow, activateWindow: true); } - void NavigateObservers() + void TriggerEventSequenceAction(Func?> getCallback, Func getItem) + { + if (_views[WorkbenchNavigation.IndexEventSequences] is not EventSequencesView esv) + { + return; + } + + var item = getItem(esv); + if (item is null) + { + ShowNoSelectionHint(); + return; + } + + getCallback(esv)?.Invoke(item); + } + + void TriggerEventTypeAction(Func?> getCallback, Func getItem) + { + if (_views[WorkbenchNavigation.IndexEventTypes] is not EventTypesView etv) + { + return; + } + + var item = getItem(etv); + if (item is null) + { + ShowNoSelectionHint(); + return; + } + + getCallback(etv)?.Invoke(item); + } + + void ShowNoSelectionHint() + { + UpdateStatusRight($"[{WorkbenchColors.Muted.ToMarkup()}]Select a row first[/]"); + _ = Task.Delay(2000).ContinueWith(_ => UpdateStatusBar(_currentData), TaskScheduler.Default); + } + + void TriggerSelectedObserverReplay() { - if (_navView is NavigationView nav) nav.SelectedIndex = IndexObservers; + if (_views[WorkbenchNavigation.IndexObservers] is not ObserversView ov) return; + var selected = ov.SelectedObserver; + if (selected is null) + { + ShowNoSelectionHint(); + return; + } + ov.OnReplay?.Invoke(selected); } - void NavigateEventTypes() + void TriggerSelectedPartitionRetry() { - if (_navView is NavigationView nav) nav.SelectedIndex = IndexEventTypes; + if (_views[WorkbenchNavigation.IndexFailures] is not FailedPartitionsView fv) return; + var selected = fv.SelectedPartition; + if (selected is null) + { + ShowNoSelectionHint(); + return; + } + fv.OnRetryPartition?.Invoke(selected); } - void NavigateProjections() + void TriggerSelectedPartitionReplay() { - if (_navView is NavigationView nav) nav.SelectedIndex = IndexProjections; + if (_views[WorkbenchNavigation.IndexFailures] is not FailedPartitionsView fv) return; + var selected = fv.SelectedPartition; + if (selected is null) + { + ShowNoSelectionHint(); + return; + } + fv.OnReplayPartition?.Invoke(selected); } - void NavigateReadModels() + void TriggerSelectedJobStop() { - if (_navView is NavigationView nav) nav.SelectedIndex = IndexReadModels; + if (_views[WorkbenchNavigation.IndexJobs] is not JobsView jv) return; + var selected = jv.SelectedJob; + if (selected is null) + { + ShowNoSelectionHint(); + return; + } + jv.OnStopJob?.Invoke(selected); } - void NavigateFailures() + void TriggerSelectedJobResume() { - if (_navView is NavigationView nav) nav.SelectedIndex = IndexFailures; + if (_views[WorkbenchNavigation.IndexJobs] is not JobsView jv) return; + var selected = jv.SelectedJob; + if (selected is null) + { + ShowNoSelectionHint(); + return; + } + jv.OnResumeJob?.Invoke(selected); } + void TriggerSelectedRecommendationApply() + { + if (_views[WorkbenchNavigation.IndexRecommendations] is not RecommendationsView rv) return; + var selected = rv.SelectedRecommendation; + if (selected is null) + { + ShowNoSelectionHint(); + return; + } + rv.OnApply?.Invoke(selected); + } + + void TriggerSelectedRecommendationIgnore() + { + if (_views[WorkbenchNavigation.IndexRecommendations] is not RecommendationsView rv) return; + var selected = rv.SelectedRecommendation; + if (selected is null) + { + ShowNoSelectionHint(); + return; + } + rv.OnIgnore?.Invoke(selected); + } + + void NavigateObservers() => _navigation?.NavigateTo(WorkbenchNavigation.IndexObservers); + + void NavigateEventTypes() => _navigation?.NavigateTo(WorkbenchNavigation.IndexEventTypes); + + void NavigateProjections() => _navigation?.NavigateTo(WorkbenchNavigation.IndexProjections); + + void NavigateReadModels() => _navigation?.NavigateTo(WorkbenchNavigation.IndexReadModels); + + void NavigateFailures() => _navigation?.NavigateTo(WorkbenchNavigation.IndexFailures); + void OpenCommandPalette() { WorkbenchData? snapshot; @@ -946,7 +962,10 @@ void OpenCommandPalette() snapshot = _currentData; } - if (snapshot is null) return; + if (snapshot is null) + { + return; + } var mut = WorkbenchColors.Muted.ToMarkup(); var acc = WorkbenchColors.Accent.ToMarkup(); @@ -961,15 +980,18 @@ void OpenCommandPalette() var searchPrompt = Controls.Prompt($"[{acc}]>[/] ") .WithName("CommandPaletteSearch") - .OnGotFocus((_, _) => _textInputFocused = true) - .OnLostFocus((_, _) => _textInputFocused = false) + .OnGotFocus((_, _) => _actionHandler!.TextInputFocused = true) + .OnLostFocus((_, _) => _actionHandler!.TextInputFocused = false) .Build(); void PopulateResults(string query) { resultsTable.ClearRows(); - if (string.IsNullOrWhiteSpace(query)) return; + if (string.IsNullOrWhiteSpace(query)) + { + return; + } var matches = new List<(string Kind, string Label, Action Navigate)>(); @@ -1015,9 +1037,9 @@ void PopulateResults(string query) } } - foreach (var (kind, label, navigate) in matches.Take(10)) + foreach (var (kind, label, navigate) in matches.Take(MaxCommandPaletteResults)) { - resultsTable.AddRow(new SharpConsoleUI.Controls.TableRow([kind, label]) { Tag = navigate }); + resultsTable.AddRow(new UITableRow([kind, label]) { Tag = navigate }); } } @@ -1027,7 +1049,7 @@ void PopulateResults(string query) paletteWindow = new WindowBuilder(windowSystem) .WithTitle(" Command Palette ") .WithColors(WorkbenchColors.Foreground, WorkbenchColors.Background) - .WithSize(80, 16) + .WithSize(CommandPaletteWidth, CommandPaletteHeight) .Centered() .AddControl(searchPrompt) .AddControl(resultsTable) @@ -1059,200 +1081,107 @@ void PopulateResults(string query) void FocusNavigation() { - if (_navView is null) return; + if (_window is null || _navigation?.NavView is null) + { + return; + } - var window = windowSystem.GetWindowAtPoint(new System.Drawing.Point(0, 0)); - window?.FocusControl(_navView); + _window.FocusControl(_navigation.NavView); } - void ActivateCurrentFilter() + void FocusContent() { - var idx = _navView?.SelectedIndex ?? -1; - if (idx < 0 || idx >= _views.Length) return; - - var window = windowSystem.GetWindowAtPoint(new System.Drawing.Point(0, 0)); - if (window is null) return; + if (_window is null) return; - _views[idx].ActivateFilter(window); + var idx = _navigation?.CurrentViewIndex ?? -1; + if (idx >= 0 && idx < _views.Length && _views[idx].PrimaryFocusTarget is IInteractiveControl ic) + { + _window.FocusControl(ic); + } + else + { + // No specific focus target for this view — do nothing. + } } - void OpenEventStorePicker() + void ToggleSidebar() { - WorkbenchData? snapshot; - lock (_dataLock) + if (_navigation?.NavView is null) { - snapshot = _currentData; + return; } - if (snapshot is null) return; - - var stores = snapshot.EventStoreNames.Order().ToList(); - var active = _activeEventStore ?? settings.ResolveEventStore(); - var acc = WorkbenchColors.Accent.ToMarkup(); - - var pickerTable = Controls.Table() - .AddColumn("Event Store", SharpConsoleUI.Layout.TextJustification.Left, null) - .Interactive() - .WithVerticalScrollbar(ScrollbarVisibility.Auto) - .WithName("EventStorePickerTable") - .Build(); + _sidebarExpanded = !_sidebarExpanded; + _navigation.NavView.PaneDisplayMode = _sidebarExpanded + ? NavigationViewDisplayMode.Expanded + : NavigationViewDisplayMode.Compact; + } - foreach (var name in stores) + void ToggleDetailPane() + { + var idx = _navigation?.CurrentViewIndex ?? -1; + if (idx >= 0 && idx < _views.Length) { - var label = name == active ? $"[{acc}]► {name}[/]" : name; - pickerTable.AddRow(new SharpConsoleUI.Controls.TableRow([label]) { Tag = name }); + _views[idx].ToggleDetailPane(); } - - Window? picker = null; - var height = Math.Min(stores.Count + 4, 20); - picker = new WindowBuilder(windowSystem) - .WithTitle(" Switch Event Store ") - .WithColors(WorkbenchColors.Foreground, WorkbenchColors.Background) - .WithSize(50, height) - .Centered() - .AddControl(pickerTable) - .OnKeyPressed((_, ke) => - { - if (ke.KeyInfo.Key == ConsoleKey.Escape) - { - windowSystem.CloseWindow(picker, activateParent: true, force: false); - ke.Handled = true; - } - }) - .Build(); - - pickerTable.RowActivated += (_, _) => - { - if (pickerTable.SelectedRow?.Tag is string storeName) - { - _activeEventStore = storeName; - _activeNamespace = null; - windowSystem.CloseWindow(picker, activateParent: true, force: false); - _ = Task.Run(() => FetchAndUpdate(CancellationToken.None)); - } - }; - - windowSystem.AddWindow(picker, activateWindow: true); } - void OpenNamespacePicker() + void ActivateCurrentFilter() { - WorkbenchData? snapshot; - lock (_dataLock) + var idx = _navigation?.CurrentViewIndex ?? -1; + if (idx < 0 || idx >= _views.Length) { - snapshot = _currentData; + return; } - if (snapshot is null) return; - - var namespaces = snapshot.NamespaceNames.Order().ToList(); - var active = _activeNamespace ?? settings.ResolveNamespace(); - var acc = WorkbenchColors.Accent.ToMarkup(); + var window = windowSystem.GetWindowAtPoint(new System.Drawing.Point(0, 0)); + if (window is null) + { + return; + } - var pickerTable = Controls.Table() - .AddColumn("Namespace", SharpConsoleUI.Layout.TextJustification.Left, null) - .Interactive() - .WithVerticalScrollbar(ScrollbarVisibility.Auto) - .WithName("NamespacePickerTable") - .Build(); + _views[idx].ActivateFilter(window); + } - foreach (var name in namespaces) + void CopyDetailToClipboard() + { + var idx = _navigation?.CurrentViewIndex ?? -1; + if (idx < 0 || idx >= _views.Length) { - var label = name == active ? $"[{acc}]► {name}[/]" : name; - pickerTable.AddRow(new SharpConsoleUI.Controls.TableRow([label]) { Tag = name }); + return; } - Window? picker = null; - var height = Math.Min(namespaces.Count + 4, 20); - picker = new WindowBuilder(windowSystem) - .WithTitle(" Switch Namespace ") - .WithColors(WorkbenchColors.Foreground, WorkbenchColors.Background) - .WithSize(50, height) - .Centered() - .AddControl(pickerTable) - .OnKeyPressed((_, ke) => - { - if (ke.KeyInfo.Key == ConsoleKey.Escape) - { - windowSystem.CloseWindow(picker, activateParent: true, force: false); - ke.Handled = true; - } - }) - .Build(); - - pickerTable.RowActivated += (_, _) => + var content = _views[idx].DetailContent; + if (string.IsNullOrEmpty(content)) { - if (pickerTable.SelectedRow?.Tag is string nsName) - { - _activeNamespace = nsName; - windowSystem.CloseWindow(picker, activateParent: true, force: false); - _ = Task.Run(() => FetchAndUpdate(CancellationToken.None)); - } - }; + return; + } - windowSystem.AddWindow(picker, activateWindow: true); + var plain = Markup.Remove(content); + ClipboardHelper.SetText(plain); + UpdateStatusRight($"[{WorkbenchColors.Success.ToMarkup()}]✓ Copied[/]"); + _ = Task.Delay(2000).ContinueWith(_ => UpdateStatusBar(_currentData), TaskScheduler.Default); } - void SwitchToOverview() - { - if (_navView is null) return; - _navView.SelectedIndex = IndexOverview; - } + void SwitchToOverview() => _navigation?.NavigateTo(WorkbenchNavigation.IndexOverview); void OpenReadModelDetail() { - if (_views[IndexReadModels] is not ReadModelsView rmv) return; + if (_views[WorkbenchNavigation.IndexReadModels] is not ReadModelsView rmv) + { + return; + } - // Retrieve the selected read model from the view's table via the public overlay method. - // The view owns the table reference, so we delegate entirely to it. rmv.OpenSelectedDetailOverlay(); } - void ExecuteAction(string description, Func action) - { - _pendingAction = (description, action); - var war = WorkbenchColors.Warning.ToMarkup(); - var acc = WorkbenchColors.Accent.ToMarkup(); - var mut = WorkbenchColors.Muted.ToMarkup(); - UpdateStatusRight( - $"[{war}]⚡ {description}?[/] [bold {acc}][Y][/] [{mut}]Confirm[/] [bold {acc}][N][/] [{mut}]Cancel[/]"); - } - - void ConfirmThenExecuteAll(string description, IReadOnlyList items, Func perItem) - { - ExecuteAction(description, async () => - { - foreach (var item in items) - { - await perItem(item); - } - }); - } - - void RunPendingAction(string description, Func action) + void UpdateStatusRight(string text) { - _ = Task.Run(async () => + if (_statusBar is null) { - try - { - UpdateStatusRight($"[{WorkbenchColors.Warning.ToMarkup()}]⟳ {description}...[/]"); - await action(); - UpdateStatusRight($"[{WorkbenchColors.Success.ToMarkup()}]✓ Done[/]"); - - await Task.Delay(3000); - UpdateStatusBar(); - } - catch (Exception ex) - { - var msg = ex.Message.Length > 60 ? ex.Message[..60] : ex.Message; - UpdateStatusRight($"[{WorkbenchColors.Danger.ToMarkup()}]✗ {msg}[/]"); - } - }); - } + return; + } - void UpdateStatusRight(string text) - { - if (_statusBar is null) return; _statusBar.ClearRight(); _statusBar.AddRightText(text, null); } diff --git a/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchActionHandler.cs b/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchActionHandler.cs new file mode 100644 index 0000000..a89db9f --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchActionHandler.cs @@ -0,0 +1,116 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using SharpConsoleUI; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Manages destructive-action confirmation for the workbench: queuing a pending action, waiting for +/// Y/N confirmation, and executing the action while streaming status messages back to the caller. +/// +/// Callback invoked to display a status message in the workbench status bar right segment. +public class WorkbenchActionHandler(Action updateStatus) +{ + (string Description, Func Execute)? _pendingAction; + + /// + /// Gets a value indicating whether there is a destructive action waiting for Y/N confirmation. + /// + public bool IsPendingAction => _pendingAction is not null; + + /// + /// Gets or sets a value indicating whether a text input control currently has keyboard focus. + /// When , most shortcut keys are suppressed so the user can type freely. + /// + public bool TextInputFocused { get; set; } + + /// + /// Queues a destructive action for confirmation and shows a Y/N prompt in the status bar. + /// + /// Short human-readable description of the action. + /// Async delegate that performs the action when confirmed. + public void ExecuteAction(string description, Func action) + { + _pendingAction = (description, action); + var war = WorkbenchColors.Warning.ToMarkup(); + var acc = WorkbenchColors.Accent.ToMarkup(); + var mut = WorkbenchColors.Muted.ToMarkup(); + updateStatus( + $"[{war}]⚡ {description}?[/] [bold {acc}][Y][/] [{mut}]Confirm[/] [bold {acc}][N][/] [{mut}]Cancel[/]"); + } + + /// + /// Queues a bulk action that iterates over and calls for each. + /// The action is queued with a Y/N confirmation prompt before anything is executed. + /// + /// The element type. + /// Short human-readable description of the bulk action. + /// The collection of items to act on. + /// Async delegate called once per item. + public void ConfirmThenExecuteAll(string description, IReadOnlyList items, Func perItem) + { + ExecuteAction(description, async () => + { + foreach (var item in items) + { + await perItem(item); + } + }); + } + + /// + /// Handles a key press while a pending action is queued. + /// Returns if the key was consumed (whether confirmed, cancelled, or a no-op + /// because a confirmation is already in progress). + /// + /// The key that was pressed. + /// Invoked after cancellation so the caller can refresh the status bar. + /// if the key was consumed by the pending-action handler. + public bool HandlePendingKeyPress(ConsoleKeyInfo keyInfo, Action onCancelled) + { + if (_pendingAction is null) + { + return false; + } + + switch (keyInfo.Key) + { + case ConsoleKey.Y: + var pending = _pendingAction.Value; + _pendingAction = null; + RunPendingAction(pending.Description, pending.Execute); + return true; + + case ConsoleKey.N: + case ConsoleKey.Escape: + _pendingAction = null; + onCancelled(); + return true; + } + + // Any other key while a confirmation is pending — consume it. + return true; + } + + void RunPendingAction(string description, Func action) + { + _ = Task.Run(async () => + { + try + { + updateStatus($"[{WorkbenchColors.Warning.ToMarkup()}]⟳ {description}...[/]"); + await action(); + updateStatus($"[{WorkbenchColors.Success.ToMarkup()}]✓ Done[/]"); + + await Task.Delay(3000); + updateStatus(string.Empty); + } + catch (Exception ex) + { + var msg = ex.Message.Length > 60 ? ex.Message[..60] : ex.Message; + updateStatus($"[{WorkbenchColors.Danger.ToMarkup()}]✗ {msg}[/]"); + } + }); + } +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchNavigation.cs b/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchNavigation.cs new file mode 100644 index 0000000..aec8905 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchNavigation.cs @@ -0,0 +1,353 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using SharpConsoleUI; +using SharpConsoleUI.Builders; +using SharpConsoleUI.Controls; +using UITableRow = SharpConsoleUI.Controls.TableRow; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Builds and manages the navigation side pane, badge counts, and the event store / namespace picker overlays. +/// +/// The SharpConsoleUI window system. +/// The ordered array of instances, one per navigation item. +/// Workbench settings — used to resolve the active event store and namespace. +/// Returns the currently active event store name, or for the default. +/// Returns the currently active namespace name, or for the default. +/// Invoked with the newly selected event store name when the user picks one from the picker overlay. +/// Invoked with the newly selected namespace name when the user picks one from the picker overlay. +/// Invoked when navigation changes require a data refresh. +/// Returns the latest cached snapshot, or if none available yet. +public class WorkbenchNavigation( + ConsoleWindowSystem windowSystem, + IWorkbenchView[] views, + WorkbenchSettings settings, + Func getActiveEventStore, + Func getActiveNamespace, + Action onStoreSwitch, + Action onNamespaceSwitch, + Action onDataNeeded, + Func getLatestData) +{ + /// Navigation item index for Overview. + public const int IndexOverview = 0; + + /// Navigation item index for Observers. + public const int IndexObservers = 1; + + /// Navigation item index for Failures. + public const int IndexFailures = 2; + + /// Navigation item index for Jobs. + public const int IndexJobs = 3; + + /// Navigation item index for Recommendations. + public const int IndexRecommendations = 4; + + /// Navigation item index for Event Sequences. + public const int IndexEventSequences = 5; + + /// Navigation item index for Event Types. + public const int IndexEventTypes = 6; + + /// Navigation item index for Projections. + public const int IndexProjections = 7; + + /// Navigation item index for Read Models. + public const int IndexReadModels = 8; + + /// Navigation item index for Event Stores. + public const int IndexEventStores = 9; + + /// Navigation item index for Namespaces. + public const int IndexNamespaces = 10; + + /// Width of the picker overlay window in columns. + const int PickerOverlayWidth = 54; + + /// Maximum height of the picker overlay window in rows. + const int MaxPickerOverlayHeight = 24; + + /// Extra rows added to item count to account for picker window chrome (borders, title, padding). + const int PickerOverlayHeightPadding = 6; + + NavigationItem? _observersItem; + NavigationItem? _failuresItem; + NavigationItem? _recommendationsItem; + NavigationView? _navView; + int _currentViewIndex; + + /// + /// Gets the built control. + /// Only available after has been called. + /// + public NavigationView? NavView => _navView; + + /// + /// Gets the zero-based item index of the currently active view, sourced from + /// OnSelectedItemChanged's NewIndex — the same index used to activate views + /// and guaranteed to match the IndexXxx constants regardless of how + /// counts headers internally. + /// + public int CurrentViewIndex => _currentViewIndex; + + /// + /// Gets the Observers navigation item (used to set badge counts). + /// Only available after has been called. + /// + public NavigationItem? ObserversItem => _observersItem; + + /// + /// Gets the Failures navigation item (used to set badge counts). + /// Only available after has been called. + /// + public NavigationItem? FailuresItem => _failuresItem; + + /// + /// Gets the Recommendations navigation item (used to set badge counts). + /// Only available after has been called. + /// + public NavigationItem? RecommendationsItem => _recommendationsItem; + + /// + /// Builds the navigation view with all headers and items, wires the selection-changed callback, + /// and captures the badge item references. + /// + /// The fully configured . + public NavigationView BuildNavigationView() + { + var selectedBg = new SharpConsoleUI.Color(49, 50, 68, 255); + var selectedFg = WorkbenchColors.Accent; + + var navView = Controls.NavigationView() + .WithNavWidth(26) + .WithPaneHeader($"[bold {WorkbenchColors.Accent.ToMarkup()}] CHRONICLE [/]") + .WithSelectedColors(selectedFg, selectedBg) + .WithPaneDisplayMode(NavigationViewDisplayMode.Expanded) + .WithName("MainNav") + .Fill() + .AddHeader("OVERVIEW", h => + h.AddItem("Overview", "◆", null, panel => panel.AddControl(views[0].BuildContent(windowSystem)))) + .AddHeader("OBSERVATION", h => + h.AddItem("Observers", "o", null, panel => panel.AddControl(views[1].BuildContent(windowSystem))) + .AddItem("Failures", "!", null, panel => panel.AddControl(views[2].BuildContent(windowSystem))) + .AddItem("Jobs", "~", null, panel => panel.AddControl(views[3].BuildContent(windowSystem))) + .AddItem("Recommendations", "*", null, panel => panel.AddControl(views[4].BuildContent(windowSystem)))) + .AddHeader("EVENTS", h => + h.AddItem("Event Sequences", "-", null, panel => panel.AddControl(views[5].BuildContent(windowSystem))) + .AddItem("Event Types", "#", null, panel => panel.AddControl(views[6].BuildContent(windowSystem)))) + .AddHeader("PROJECTIONS", h => + h.AddItem("Projections", ">", null, panel => panel.AddControl(views[7].BuildContent(windowSystem))) + .AddItem("Read Models", "=", null, panel => panel.AddControl(views[8].BuildContent(windowSystem)))) + .AddHeader("SERVER", h => + h.AddItem("Event Stores", "+", null, panel => panel.AddControl(views[9].BuildContent(windowSystem))) + .AddItem("Namespaces", "@", null, panel => panel.AddControl(views[10].BuildContent(windowSystem))) + .AddItem("Applications", "A", null, panel => panel.AddControl(views[11].BuildContent(windowSystem))) + .AddItem("Users", "U", null, panel => panel.AddControl(views[12].BuildContent(windowSystem))) + .AddItem("Identities", "I", null, panel => panel.AddControl(views[13].BuildContent(windowSystem))) + .AddItem("Subscriptions", "S", null, panel => panel.AddControl(views[14].BuildContent(windowSystem)))) + .OnSelectedItemChanged((_, e) => + { + // Deactivate the previous view so background refreshes resume rebuilding it. + if (e.OldIndex >= 0 && e.OldIndex < views.Length) + views[e.OldIndex].IsActive = false; + + var idx = e.NewIndex; + _currentViewIndex = idx; + + if (idx < 0 || idx >= views.Length) + { + return; + } + + // Push latest data to the newly selected view (IsActive still false → will rebuild). + var snapshot = getLatestData(); + if (snapshot is not null) + { + views[idx].UpdateData(snapshot); + } + else + { + onDataNeeded(); + } + + // Mark as active AFTER the rebuild so future interval refreshes preserve state. + views[idx].IsActive = true; + }) + .Build(); + + var items = navView.Items; + _observersItem = FindItemByText(items, "Observers"); + _failuresItem = FindItemByText(items, "Failures"); + _recommendationsItem = FindItemByText(items, "Recommendations"); + + _navView = navView; + return navView; + } + + /// + /// Navigates to the specified view by index. No-op when the index is out of range. + /// + /// Zero-based index of the target view (use IndexXxx constants). + public void NavigateTo(int viewIndex) + { + if (_navView is null || viewIndex < 0 || viewIndex >= views.Length) + { + return; + } + + _navView.SelectedIndex = viewIndex; + } + + /// + /// Updates the badge subtitles on the Observers, Failures, and Recommendations navigation items + /// to reflect the latest counts from . + /// + /// The latest workbench data snapshot. + public void UpdateNavBadges(WorkbenchData data) + { + var problemCount = data.DisconnectedObservers + data.ReplayingObservers; + + if (_observersItem is NavigationItem observersItem) + { + observersItem.Subtitle = problemCount > 0 ? $"⚠{problemCount}" : string.Empty; + } + + if (_failuresItem is NavigationItem failuresItem) + { + failuresItem.Subtitle = data.FailedPartitions.Count > 0 + ? data.FailedPartitions.Count.ToString() + : string.Empty; + } + + if (_recommendationsItem is NavigationItem recommendationsItem) + { + recommendationsItem.Subtitle = data.Recommendations.Count > 0 + ? data.Recommendations.Count.ToString() + : string.Empty; + } + + _navView?.Invalidate(); + } + + /// + /// Opens a modal picker overlay that lets the user select a different event store. + /// Calls the store-switch callback when a selection is confirmed. + /// + public void OpenEventStorePicker() + { + var snapshot = getLatestData(); + if (snapshot is null) + { + return; + } + + ShowStringPickerOverlay( + " Switch Event Store ", + "Event Store", + "EventStorePickerTable", + [.. snapshot.EventStoreNames.Order()], + getActiveEventStore() ?? settings.ResolveEventStore(), + onStoreSwitch); + } + + /// + /// Opens a modal picker overlay that lets the user select a different namespace. + /// Calls the namespace-switch callback when a selection is confirmed. + /// + public void OpenNamespacePicker() + { + var snapshot = getLatestData(); + if (snapshot is null) + { + return; + } + + ShowStringPickerOverlay( + " Switch Namespace ", + "Namespace", + "NamespacePickerTable", + [.. snapshot.NamespaceNames.Order()], + getActiveNamespace() ?? settings.ResolveNamespace(), + onNamespaceSwitch); + } + + static NavigationItem? FindItemByText(IReadOnlyList items, string text) + { + foreach (var item in items) + { + if (item.Text == text) + { + return item; + } + } + + return null; + } + + /// + /// Opens a modal, keyboard-navigable picker overlay presenting a list of strings. + /// Highlights the currently active item with an arrow indicator; calls + /// with the chosen string when a row is activated or Enter is pressed. + /// + /// The window title shown in the overlay border. + /// The header text for the single picker column. + /// The SharpConsoleUI control name for the picker table (used for test/automation). + /// The ordered list of choices to display. + /// The item that is currently selected; shown with a ► prefix. + /// Invoked with the chosen item name when the user confirms a selection. + void ShowStringPickerOverlay( + string title, + string columnHeader, + string tableName, + List items, + string activeItem, + Action onSelected) + { + var acc = WorkbenchColors.Accent.ToMarkup(); + + var pickerTable = Controls.Table() + .AddColumn(columnHeader, SharpConsoleUI.Layout.TextJustification.Left, null) + .Interactive() + .WithVerticalScrollbar(ScrollbarVisibility.Auto) + .WithName(tableName) + .Build(); + + foreach (var name in items) + { + var label = name == activeItem ? $"[{acc}]► {name}[/]" : name; + pickerTable.AddRow(new UITableRow([label]) { Tag = name }); + } + + Window? picker = null; + var height = Math.Min(items.Count + PickerOverlayHeightPadding, MaxPickerOverlayHeight); + picker = new WindowBuilder(windowSystem) + .WithTitle(title) + .WithColors(WorkbenchColors.Foreground, WorkbenchColors.Background) + .WithSize(PickerOverlayWidth, height) + .Centered() + .AddControl(pickerTable) + .OnKeyPressed((_, ke) => + { + if (ke.KeyInfo.Key == ConsoleKey.Escape) + { + windowSystem.CloseWindow(picker, activateParent: true, force: false); + ke.Handled = true; + } + }) + .Build(); + + pickerTable.RowActivated += (_, _) => + { + if (pickerTable.SelectedRow?.Tag is string selected) + { + windowSystem.CloseWindow(picker, activateParent: true, force: false); + onSelected(selected); + } + }; + + windowSystem.AddWindow(picker, activateWindow: true); + } +} From 72fa36e006160919c532ba4b9cf71f1c1287385a Mon Sep 17 00:00:00 2001 From: woksin Date: Fri, 22 May 2026 23:46:04 +0200 Subject: [PATCH 4/5] Refactor Workbench Navigation and Introduce Overlays and Refresh Loop - Updated WorkbenchNavigation to derive view indices from WorkbenchViewRegistry, ensuring consistency and reducing hardcoded values. - Enhanced navigation item descriptions for clarity. - Introduced WorkbenchOverlays class to manage modal popups for help, command palette, and read model details. - Implemented WorkbenchRefreshLoop to handle periodic data fetching and updating views, including connection status management. - Added methods for opening help overlays, command palettes, and observer/event type details. - Improved navigation and filtering capabilities within the command palette. --- .ai/rules/feature-completeness.md | 80 ++ .claude/rules/feature-completeness.md | 1 + .../feature-completeness.instructions.md | 1 + .../Chronicle/Workbench/WorkbenchApp.cs | 10 +- .../Chronicle/Workbench/WorkbenchColors.cs | 89 +- .../Chronicle/Workbench/WorkbenchHints.cs | 14 + .../Chronicle/Workbench/WorkbenchSection.cs | 15 + .../Workbench/WorkbenchViewDefinition.cs | 24 + .../Workbench/WorkbenchViewRegistry.cs | 103 ++ .../Workbench/views/EventSequencesView.cs | 26 +- .../Workbench/views/EventTypesView.cs | 10 +- .../Workbench/views/FailedPartitionsView.cs | 27 +- .../Workbench/views/FilterableTableView.cs | 490 ++++----- .../Workbench/views/IWorkbenchView.cs | 47 +- .../Chronicle/Workbench/views/JobsView.cs | 27 +- .../Workbench/views/ObserversView.cs | 30 +- .../Workbench/views/ProjectionsView.cs | 3 + .../Workbench/views/ReadModelsView.cs | 3 + .../Workbench/views/RecommendationsView.cs | 27 +- .../Chronicle/Workbench/windows/MainWindow.cs | 980 ++---------------- .../Chronicle/Workbench/windows/ViewAction.cs | 21 + .../windows/WorkbenchActionHandler.cs | 114 +- .../windows/WorkbenchKeyDispatcher.cs | 318 ++++++ .../Workbench/windows/WorkbenchMenuBar.cs | 70 ++ .../Workbench/windows/WorkbenchNavigation.cs | 293 +++--- .../Workbench/windows/WorkbenchOverlays.cs | 535 ++++++++++ .../Workbench/windows/WorkbenchRefreshLoop.cs | 205 ++++ 27 files changed, 2134 insertions(+), 1429 deletions(-) create mode 100644 .ai/rules/feature-completeness.md create mode 120000 .claude/rules/feature-completeness.md create mode 120000 .github/instructions/feature-completeness.instructions.md create mode 100644 Source/Cli/Commands/Chronicle/Workbench/WorkbenchHints.cs create mode 100644 Source/Cli/Commands/Chronicle/Workbench/WorkbenchSection.cs create mode 100644 Source/Cli/Commands/Chronicle/Workbench/WorkbenchViewDefinition.cs create mode 100644 Source/Cli/Commands/Chronicle/Workbench/WorkbenchViewRegistry.cs create mode 100644 Source/Cli/Commands/Chronicle/Workbench/windows/ViewAction.cs create mode 100644 Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchKeyDispatcher.cs create mode 100644 Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchMenuBar.cs create mode 100644 Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchOverlays.cs create mode 100644 Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchRefreshLoop.cs diff --git a/.ai/rules/feature-completeness.md b/.ai/rules/feature-completeness.md new file mode 100644 index 0000000..b29d25e --- /dev/null +++ b/.ai/rules/feature-completeness.md @@ -0,0 +1,80 @@ +--- +applyTo: "**/*" +--- + +# Feature Completeness + +Build success proves compilation. It proves nothing about behaviour. These standards exist because code that compiles cleanly can still be silently broken — callbacks wired but never triggered, UI elements that advertise features that do not exist, actions that return without doing anything or telling anyone why. + +## Build ≠ Behaviour + +> **"Build succeeded" is never a signal that work is complete.** + +Compilation verifies types and syntax. It does not verify that a button click actually fires, that a registered handler is ever called, that a feature works as documented, or that a user-facing interaction produces the expected result. + +When work cannot be verified at runtime — because there is no test harness, the environment is unavailable, or the UI cannot be exercised programmatically — say so explicitly. Do not present unverified behaviour as confirmed. + +## Complete Implementation Chains + +Every user-facing feature is a chain: a trigger connects to a handler connects to an action. All links in the chain must exist before the feature is considered done. + +**Common failure modes:** + +- A button, keyboard shortcut, or menu item is documented and visible — but no handler exists for it +- A handler is registered (event, callback, delegate) — but nothing ever calls it +- An action is implemented — but no trigger connects it to user input +- An interface or abstract method is declared — but left as a no-op or stub and never noticed + +**The rule:** When you add any one part of a chain, locate and verify all the other parts. If they do not exist, either create them in the same change or explicitly flag them as missing. + +Chains to audit in this codebase: +- UI label / key hint → key handler case → trigger method → registered callback → executing action +- Interface method / abstract member → all implementations → all call sites +- Event subscription → event firing → observable side effect + +## Document–Code Consistency + +Everything the user can read — UI hints, help text, comments, XML docs, error messages — is a promise about what the code does. Broken promises are bugs. + +**Rule:** Any text visible to a user that describes a behaviour, shortcut, or feature must have a corresponding implementation. If the implementation does not exist, remove the text. If the text is wrong, fix it. Never leave documentation and code in disagreement. + +Examples: +- A tooltip says "Press R to replay" → `ConsoleKey.R` must have a handler +- An XML doc says "Throws X when Y" → the code must throw X when Y +- A README says "Supports Z format" → Z must be supported + +## No Silent Failures + +When a user-triggered operation cannot proceed because a precondition is not met, always surface feedback. A method that silently returns accomplishes nothing except making the user believe the feature is broken. + +**Rule:** Every early-return guard in a user-facing operation must explain why it returned. Use status messages, error indicators, or log output — whatever is appropriate for the context. Never return silently. + +```csharp +// ❌ User sees nothing, concludes the feature is broken +if (selected is null) return; + +// ✅ User sees "Select a row first" +if (selected is null) { ShowHint("Select a row first"); return; } +``` + +This applies equally to: +- User input handlers that silently drop input +- API endpoints that return empty 200s instead of 404s +- Background jobs that swallow exceptions +- Validation that fails without reporting what was invalid + +## Carry-Forward Integrity + +When extending existing code, do not assume it is complete. Scaffold, boilerplate, and copy-pasted patterns frequently have missing links — stubs, unimplemented handlers, wired-but-never-called callbacks. The fact that existing code was not flagged as broken does not mean it works. + +**Rule:** When touching an existing feature, trace the full implementation chain — trigger → handler → action — and verify each link. Any gap you find is a pre-existing bug. Either fix it in the same change or leave an explicit note. + +## Verification Checklist + +Before marking any feature complete, confirm: + +- [ ] Every user-visible description of a behaviour has a corresponding implementation +- [ ] Every registered handler/callback/listener is called by something +- [ ] Every trigger (button, shortcut, event) connects to a handler +- [ ] Every early return in a user-facing path provides user feedback +- [ ] If runtime behaviour could not be verified, that is stated explicitly diff --git a/.claude/rules/feature-completeness.md b/.claude/rules/feature-completeness.md new file mode 120000 index 0000000..c79c159 --- /dev/null +++ b/.claude/rules/feature-completeness.md @@ -0,0 +1 @@ +../../.ai/rules/feature-completeness.md \ No newline at end of file diff --git a/.github/instructions/feature-completeness.instructions.md b/.github/instructions/feature-completeness.instructions.md new file mode 120000 index 0000000..c79c159 --- /dev/null +++ b/.github/instructions/feature-completeness.instructions.md @@ -0,0 +1 @@ +../../.ai/rules/feature-completeness.md \ No newline at end of file diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchApp.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchApp.cs index fe37ac0..0b195b3 100644 --- a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchApp.cs +++ b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchApp.cs @@ -2,7 +2,9 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using SharpConsoleUI; +using SharpConsoleUI.Configuration; using SharpConsoleUI.Drivers; +using SharpConsoleUI.Panel; namespace Cratis.Cli.Commands.Chronicle.Workbench; @@ -22,7 +24,13 @@ public class WorkbenchApp(WorkbenchDataService dataService, WorkbenchSettings se /// The exit code. public int Run() { - var windowSystem = new ConsoleWindowSystem(new NetConsoleDriver(RenderMode.Buffer)); + var windowSystem = new ConsoleWindowSystem( + new NetConsoleDriver(RenderMode.Buffer), + options: new ConsoleWindowSystemOptions( + TopPanelConfig: panel => panel.Left(Elements.StatusText(string.Empty)), + BottomPanelConfig: panel => panel.Left(Elements.StatusText(WorkbenchHints.BottomBar)))); + + windowSystem.PanelStateService.TopStatus = "◆ CHRONICLE WORKBENCH"; var mainWindow = new MainWindow(windowSystem, dataService, settings, services, initialData, state); windowSystem.AddWindow(mainWindow.Build(), activateWindow: true); diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchColors.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchColors.cs index 28541da..aaa926d 100644 --- a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchColors.cs +++ b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchColors.cs @@ -6,57 +6,46 @@ namespace Cratis.Cli.Commands.Chronicle.Workbench; /// -/// Provides SharpConsoleUI-compatible color constants using a vivid Tokyo Night Storm inspired palette. +/// SharpConsoleUI-compatible color constants for the workbench — midnight-blue palette with vibrant accents. /// public static class WorkbenchColors { - /// - /// The primary accent color — electric blue (Tokyo Night: Blue). - /// - public static readonly SColor Accent = new(122, 162, 247, 255); - - /// - /// A muted blue-grey for secondary text. - /// - public static readonly SColor Muted = new(86, 95, 137, 255); - - /// - /// The success color — vivid neon green (Tokyo Night: Green). - /// - public static readonly SColor Success = new(115, 218, 118, 255); - - /// - /// The warning color — vivid amber (Tokyo Night: Warning). - /// - public static readonly SColor Warning = new(224, 175, 104, 255); - - /// - /// The danger/error color — vivid coral-red (Tokyo Night: Red/Pink). - /// - public static readonly SColor Danger = new(247, 118, 142, 255); - - /// - /// A very dark navy-black background color (GitHub dark background). - /// - public static readonly SColor Background = new(13, 17, 23, 255); - - /// - /// A dark blue-grey surface color for panels. - /// - public static readonly SColor Surface = new(22, 27, 39, 255); - - /// - /// The primary foreground text color — cold white-blue (Tokyo Night: Foreground). - /// - public static readonly SColor Foreground = new(192, 202, 245, 255); - - /// - /// Mauve/purple accent for variety in stream palettes and indicators. - /// - public static readonly SColor Mauve = new(187, 154, 247, 255); - - /// - /// Teal/cyan accent for variety in stream palettes and indicators. - /// - public static readonly SColor Teal = new(42, 195, 222, 255); + /// Very deep midnight-navy background — the base window color. + public static readonly SColor Background = new(8, 12, 28, 255); + + /// Deep navy surface — content pane background, slightly lighter than the window. + public static readonly SColor Surface = new(16, 22, 46, 255); + + /// Primary foreground — blue-tinted white, easy on the eyes on dark backgrounds. + public static readonly SColor Foreground = new(220, 225, 250, 255); + + /// Primary accent — electric blue, used for highlights, selected items, and borders. + public static readonly SColor Accent = new(100, 180, 255, 255); + + /// Navigation pane selection background — rich royal blue. + public static readonly SColor SelectedBg = new(35, 70, 150, 255); + + /// Muted secondary text — perceptible but not distracting. + public static readonly SColor Muted = new(90, 110, 160, 255); + + /// Success / healthy state — vibrant mint green. + public static readonly SColor Success = new(100, 220, 130, 255); + + /// Warning state — warm amber, used for the OBSERVATION nav section. + public static readonly SColor Warning = new(240, 190, 80, 255); + + /// Danger / error state — bright coral-pink for failed partitions and errors. + public static readonly SColor Danger = new(255, 100, 130, 255); + + /// Teal / cyan accent — used for the EVENTS nav section and event-type borders. + public static readonly SColor Teal = new(60, 220, 200, 255); + + /// Mauve / violet accent — used for the PROJECTIONS nav section. + public static readonly SColor Mauve = new(200, 155, 255, 255); + + /// Content pane border — subtle blue-violet, visible but not distracting. + public static readonly SColor ContentBorder = new(45, 65, 115, 255); + + /// Dim table/panel border color — very subtle dark-blue chrome. + public static readonly SColor ChromeBorder = new(30, 40, 80, 255); } diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchHints.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchHints.cs new file mode 100644 index 0000000..d2a9f86 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchHints.cs @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// User-facing hint strings displayed in the system status panels and help overlays. +/// Centralised here so there are no magic strings scattered across the workbench. +/// +static class WorkbenchHints +{ + /// Hint text shown in the system bottom panel at all times. + public const string BottomBar = "F Filter | ? Help | Ctrl+P Palette | Q Quit"; +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchSection.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchSection.cs new file mode 100644 index 0000000..b1f95cc --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchSection.cs @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using SColor = SharpConsoleUI.Color; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Identifies a navigation pane section header and its display color. +/// Instances are held as static readonly fields in +/// and compared by reference to detect section boundaries. +/// +/// The all-caps section header text shown in the nav pane. +/// The color used for the section header and its items. +public sealed record WorkbenchSection(string Title, SColor Color); diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchViewDefinition.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchViewDefinition.cs new file mode 100644 index 0000000..f342f9d --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchViewDefinition.cs @@ -0,0 +1,24 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Bundles everything that defines a single workbench navigation item in one place: +/// the view type, factory, nav item text/icon/subtitle, and the section it belongs to. +/// The position of this entry in is its view index. +/// +/// The concrete view type — used for O(n) index lookup by type without creating instances. +/// Creates a fresh instance of the view. Called once during startup. +/// The nav item label shown in the sidebar. +/// The single-character icon shown in compact sidebar mode. +/// The short subtitle shown below the label in expanded mode. +/// The section header this item lives under. Use the static readonly +/// instances from — reference equality is used to detect section boundaries. +public sealed record WorkbenchViewDefinition( + Type ViewType, + Func Factory, + string NavText, + string NavIcon, + string NavSubtitle, + WorkbenchSection Section); diff --git a/Source/Cli/Commands/Chronicle/Workbench/WorkbenchViewRegistry.cs b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchViewRegistry.cs new file mode 100644 index 0000000..fde8b57 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/WorkbenchViewRegistry.cs @@ -0,0 +1,103 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Single source of truth for all workbench views. +/// +/// Every view, its navigation sidebar entry, and its position index are defined here. +/// Nothing else in the codebase hardcodes view positions, nav labels, or icons. +/// +/// +/// To add a new view: create the view class, then add ONE to the +/// master list below in the desired position. Navigation and all index constants update automatically. +/// +/// +/// asserts at startup when nav item count and view count drift apart. +/// +/// +public static class WorkbenchViewRegistry +{ + // ── Section definitions ──────────────────────────────────────────────────── + // Static reference objects — WorkbenchNavigation uses ReferenceEquals to detect + // section boundaries when building the nav tree. + + /// Overview section header. + public static readonly WorkbenchSection SectionOverview = new("OVERVIEW", WorkbenchColors.Accent); + + /// Observation (observers, failures, jobs, recommendations) section header. + public static readonly WorkbenchSection SectionObservation = new("OBSERVATION", WorkbenchColors.Warning); + + /// Events (sequences, types) section header. + public static readonly WorkbenchSection SectionEvents = new("EVENTS", WorkbenchColors.Teal); + + /// Projections (projections, read models) section header. + public static readonly WorkbenchSection SectionProjections = new("PROJECTIONS", WorkbenchColors.Mauve); + + /// Server (event stores, namespaces, applications, users, identities, subscriptions) section header. + public static readonly WorkbenchSection SectionServer = new("SERVER", WorkbenchColors.Muted); + + static readonly WorkbenchViewDefinition[] _all = + [ + Entry(static () => new OverviewView(), "Overview", "◈", "Health & status", SectionOverview), + Entry(static () => new ObserversView(), "Observers", "◉", "Monitor & replay", SectionObservation), + Entry(static () => new FailedPartitionsView(), "Failures", "✕", "Retry failed partitions", SectionObservation), + Entry(static () => new JobsView(), "Jobs", "≋", "Background operations", SectionObservation), + Entry(static () => new RecommendationsView(), "Recommendations", "★", "Suggested actions", SectionObservation), + Entry(static () => new EventSequencesView(), "Event Sequences", "≡", "Appended events", SectionEvents), + Entry(static () => new EventTypesView(), "Event Types", "◇", "Registered schemas", SectionEvents), + Entry(static () => new ProjectionsView(), "Projections", "▷", "Running projections", SectionProjections), + Entry(static () => new ReadModelsView(), "Read Models", "▦", "Derived state", SectionProjections), + Entry(static () => new EventStoresView(), "Event Stores", "⊞", "Store management", SectionServer), + Entry(static () => new NamespacesView(), "Namespaces", "⊙", "Namespace context", SectionServer), + Entry(static () => new ApplicationsView(), "Applications", "⊕", "App registrations", SectionServer), + Entry(static () => new UsersView(), "Users", "♟", "User management", SectionServer), + Entry(static () => new IdentitiesView(), "Identities", "◎", "Identity records", SectionServer), + Entry(static () => new SubscriptionsView(), "Subscriptions", "⊗", "Active subscriptions", SectionServer), + ]; + + /// The ordered list of all registered view definitions. Position = view index. + public static IReadOnlyList All => _all; + + /// + /// Returns the zero-based view index for . + /// Throws if the type is not registered — this is a programming error. + /// + /// The view type to look up. + /// The view index matching the corresponding IndexXxx constant. + /// Thrown when is not in the registry. + public static int IndexOf() + where TView : IWorkbenchView + { + var target = typeof(TView); + for (var i = 0; i < _all.Length; i++) + { + if (_all[i].ViewType == target) + { + return i; + } + } + + throw new InvalidOperationException( + $"View type {typeof(TView).Name} is not registered in {nameof(WorkbenchViewRegistry)}. " + + $"Add an Entry<{typeof(TView).Name}>(...) to the master list."); + } + + /// + /// Creates one fresh instance of every registered view, in registry order. + /// The returned array is the shared _views[] for MainWindow and WorkbenchNavigation. + /// + /// New view instances in registry order. + public static IWorkbenchView[] CreateViews() => + [.. _all.Select(d => d.Factory())]; + + static WorkbenchViewDefinition Entry( + Func factory, + string navText, + string navIcon, + string navSubtitle, + WorkbenchSection section) + where TView : IWorkbenchView => + new(typeof(TView), factory, navText, navIcon, navSubtitle, section); +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/EventSequencesView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/EventSequencesView.cs index a639f25..2b433af 100644 --- a/Source/Cli/Commands/Chronicle/Workbench/views/EventSequencesView.cs +++ b/Source/Cli/Commands/Chronicle/Workbench/views/EventSequencesView.cs @@ -41,6 +41,9 @@ public class EventSequencesView : FilterableTableView /// protected override string DetailPanelHeader => "EVENT"; + /// Uses teal to match the EVENTS section color. + protected override SharpConsoleUI.Color DetailBorderColor => WorkbenchColors.Teal; + /// protected override IEnumerable GetItems(WorkbenchData data) => data.RecentEvents; @@ -57,29 +60,30 @@ protected override string[] BuildRow(AppendedEvent item) => ]; /// - protected override IEnumerable<(string Label, string? Shortcut, Action Execute)> GetContextMenuActions(AppendedEvent item) + protected override IReadOnlyList GetAvailableActions(AppendedEvent item) { + List actions = []; if (OnViewEventTypeDefinition is not null) { - yield return ("View event type definition", "D", () => OnViewEventTypeDefinition(item)); + actions.Add(new ViewAction("View event type definition", "D", ConsoleKey.D, default, () => OnViewEventTypeDefinition(item))); } if (OnViewObserversForType is not null) { - yield return ("View observers for this type", "V", () => OnViewObserversForType(item)); + actions.Add(new ViewAction("View observers for this type", "V", ConsoleKey.V, default, () => OnViewObserversForType(item))); } + + return actions; } /// - protected override string GetSortValue(AppendedEvent item, int columnIndex) => columnIndex switch + protected override IComparer GetColumnComparer(int columnIndex) => columnIndex switch { - // Sort numerically (not by display string) by padding to a fixed width. - 0 => item.Context.SequenceNumber.ToString("D20"), - - // Sort chronologically using UTC ticks, not the relative-time display string. - 1 => ((DateTimeOffset)item.Context.Occurred).UtcDateTime.Ticks.ToString("D20"), - - _ => base.GetSortValue(item, columnIndex) + 0 => Comparer.Create((a, b) => + a.Context.SequenceNumber.CompareTo(b.Context.SequenceNumber)), + 1 => Comparer.Create((a, b) => + ((DateTimeOffset)a.Context.Occurred).CompareTo((DateTimeOffset)b.Context.Occurred)), + _ => base.GetColumnComparer(columnIndex) }; /// diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/EventTypesView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/EventTypesView.cs index 69d01ac..1e53332 100644 --- a/Source/Cli/Commands/Chronicle/Workbench/views/EventTypesView.cs +++ b/Source/Cli/Commands/Chronicle/Workbench/views/EventTypesView.cs @@ -35,6 +35,9 @@ public class EventTypesView : FilterableTableView /// protected override string DetailPanelHeader => "EVENT TYPE"; + /// Uses teal to match the EVENTS section color. + protected override SharpConsoleUI.Color DetailBorderColor => WorkbenchColors.Teal; + /// protected override int DefaultSortColumn => 0; @@ -45,12 +48,15 @@ public class EventTypesView : FilterableTableView protected override bool IsSortableColumn(int columnIndex) => columnIndex == 0; /// - protected override IEnumerable<(string Label, string? Shortcut, Action Execute)> GetContextMenuActions(EventTypeRegistration item) + protected override IReadOnlyList GetAvailableActions(EventTypeRegistration item) { + List actions = []; if (OnViewObservers is not null) { - yield return ("View observers for this type", "V", () => OnViewObservers(item)); + actions.Add(new ViewAction("View observers for this type", "V", ConsoleKey.V, default, () => OnViewObservers(item))); } + + return actions; } /// diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/FailedPartitionsView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/FailedPartitionsView.cs index 2ad2648..7f62b73 100644 --- a/Source/Cli/Commands/Chronicle/Workbench/views/FailedPartitionsView.cs +++ b/Source/Cli/Commands/Chronicle/Workbench/views/FailedPartitionsView.cs @@ -13,6 +13,14 @@ public class FailedPartitionsView : FilterableTableView /// Gets the currently selected failed partition, or if none is selected. public FailedPartition? SelectedPartition => SelectedItem; + /// + public override string ViewHelp => + "Lists partitions that have failed during event processing.\n" + + " [T] Retry the selected partition (re-process from last failure)\n" + + " [P] Replay the selected partition from the beginning\n" + + " [Space] Check / uncheck row for bulk operations\n" + + " [T] / [P] (with 2+ checked) Bulk retry / replay all checked partitions"; + /// /// Gets or sets the callback invoked when the user requests a partition retry. /// @@ -56,28 +64,31 @@ public class FailedPartitionsView : FilterableTableView protected override bool HasCheckboxMode => true; /// - protected override IEnumerable<(string Label, string? Shortcut, Action Execute)> GetContextMenuActions(FailedPartition item) + protected override IReadOnlyList GetAvailableActions(FailedPartition item) { + List actions = []; if (OnRetryPartition is not null) { - yield return ("Retry partition", "T", () => OnRetryPartition(item)); + actions.Add(new ViewAction("Retry partition", "T", ConsoleKey.T, default, () => OnRetryPartition(item))); } if (OnReplayPartition is not null) { - yield return ("Replay partition", "P", () => OnReplayPartition(item)); + actions.Add(new ViewAction("Replay partition", "P", ConsoleKey.P, default, () => OnReplayPartition(item))); } - var checkedCount = Checked.Count; - if (OnRetryAll is not null && checkedCount > 1) + var checkedItems = Checked; + if (OnRetryAll is not null && checkedItems.Count > 1) { - yield return ($"Retry {checkedCount} checked", null, () => OnRetryAll(Checked)); + actions.Add(new ViewAction($"Retry {checkedItems.Count} checked", null, null, default, () => OnRetryAll(checkedItems))); } - if (OnReplayAll is not null && checkedCount > 1) + if (OnReplayAll is not null && checkedItems.Count > 1) { - yield return ($"Replay {checkedCount} checked", null, () => OnReplayAll(Checked)); + actions.Add(new ViewAction($"Replay {checkedItems.Count} checked", null, null, default, () => OnReplayAll(checkedItems))); } + + return actions; } /// diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/FilterableTableView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/FilterableTableView.cs index 24e9f01..42ce0ab 100644 --- a/Source/Cli/Commands/Chronicle/Workbench/views/FilterableTableView.cs +++ b/Source/Cli/Commands/Chronicle/Workbench/views/FilterableTableView.cs @@ -1,7 +1,6 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -using System.ComponentModel; using SharpConsoleUI; using SharpConsoleUI.Animation; using SharpConsoleUI.Builders; @@ -14,21 +13,18 @@ namespace Cratis.Cli.Commands.Chronicle.Workbench; /// -/// Abstract base for workbench views that display a filterable, sortable table with a detail panel. -/// Subclasses only implement domain-specific concerns — all table/filter/selection/pagination boilerplate lives here. +/// Abstract base for workbench views that display a filterable, sortable, paginated table with a detail panel. +/// Sorting is applied to the full filtered dataset before pagination — sort order is always consistent +/// across all pages. Per-column CustomRowComparer delegates ensure typed (not string) comparison. +/// Subclasses only implement domain-specific concerns. /// /// The domain item type displayed in each row. public abstract class FilterableTableView : IWorkbenchView { - /// - /// UI rows consumed by non-table chrome: title bar, filter prompt, page nav, status bar, and borders. - /// Subtracted from terminal height to compute the usable table row count. - /// - const int NonTableRowOverhead = 16; + /// Rows consumed by non-table chrome. Subtracted from terminal height to compute page size. + const int NonTableRowOverhead = 14; - /// - /// Minimum number of table rows to show regardless of terminal height. - /// + /// Minimum number of table rows to show regardless of terminal height. const int MinPageSize = 5; ConsoleWindowSystem? _windowSystem; @@ -44,11 +40,9 @@ public abstract class FilterableTableView : IWorkbenchView string _currentFilter = string.Empty; List _allItems = []; int _pageIndex; - int _sortColumnIndex = -1; - SortDirection _sortDirection = SortDirection.None; - int _lastSetSortColumn = -1; - SortDirection _lastSetSortDirection = SortDirection.None; - bool _suppressSortSync; + + int _lastAppliedSortColumn = -1; + SortDirection _lastAppliedSortDirection = SortDirection.None; /// public Action? OnFilterFocusChanged { get; set; } @@ -56,72 +50,48 @@ public abstract class FilterableTableView : IWorkbenchView /// public bool IsActive { get; set; } - /// - /// Gets the primary focus target for this view (the table itself; use F to reach the filter bar). - /// + /// Gets the primary focus target for this view (the main table). public IWindowControl? PrimaryFocusTarget => _table; /// public string? DetailContent => _detailPanel?.Content; - /// - /// Gets the per-view help text shown in the help overlay. - /// Override to provide a brief description and a list of view-specific shortcuts. - /// + /// Gets the per-view help text shown in the help overlay. public virtual string ViewHelp => string.Empty; - /// - /// Gets column definitions: (name, justification, fixed width or null for flex). - /// + /// + public IReadOnlyList ViewActions => + SelectedItem is TItem item ? GetAvailableActions(item) : []; + + /// Gets column definitions: (name, justification, fixed width or null for flex). protected abstract IReadOnlyList<(string Name, TextJustification Justify, int? Width)> Columns { get; } - /// - /// Gets the header label shown on the right detail panel. - /// + /// Gets the header label shown on the right detail panel. protected virtual string DetailPanelHeader => "DETAIL"; - /// - /// Gets the border color for the right detail panel. - /// + /// Gets the border color for the right detail panel. protected virtual SharpConsoleUI.Color DetailBorderColor => WorkbenchColors.Accent; - /// - /// Gets a value indicating whether to enable checkbox multi-select mode on the table. - /// + /// Gets a value indicating whether to enable checkbox multi-select mode on the table. protected virtual bool HasCheckboxMode => false; - /// - /// Gets the zero-based column index to sort by when the view is first displayed. - /// Return -1 (default) for no initial sort. - /// + /// Gets the zero-based column index to use for the initial sort. -1 = no initial sort. protected virtual int DefaultSortColumn => -1; - /// - /// Gets the sort direction applied alongside on first display. - /// + /// Gets the sort direction applied alongside on first display. protected virtual SortDirection DefaultSortDirection => SortDirection.None; - /// - /// Gets the width of the right-hand detail pane in character columns. - /// Defaults to one-third of the terminal width, with a minimum of 30. - /// Subclasses may override to fix or adjust the width for their content. - /// + /// Gets the width of the right-hand detail pane in character columns. protected virtual int DetailPaneWidth => Math.Max(30, Console.WindowWidth / 3); - /// - /// Gets the pending data snapshot (populated during and after ). - /// + /// Gets the pending data snapshot. protected WorkbenchData? PendingData => _pendingData; - /// - /// Gets the currently selected item, or if no row is selected. - /// + /// Gets the currently selected item, or if no row is selected. protected TItem? SelectedItem => _table?.SelectedRow?.Tag is TItem item ? item : default; - /// - /// Gets all items that are currently checked (checkbox mode only). - /// + /// Gets all items that are currently checked (checkbox mode only). protected IReadOnlyList CheckedItems { get @@ -135,9 +105,6 @@ protected IReadOnlyList CheckedItems } } - /// - /// Number of table rows visible per page, computed from the current terminal height. - /// int PageSize => Math.Max(MinPageSize, Console.WindowHeight - NonTableRowOverhead); /// @@ -166,14 +133,25 @@ public virtual IWindowControl BuildContent(ConsoleWindowSystem windowSystem) _table = tableBuilder.Build(); - _sortColumnIndex = DefaultSortColumn; - _sortDirection = DefaultSortDirection; - _lastSetSortColumn = _sortColumnIndex; - _lastSetSortDirection = _sortDirection; - - _table.PropertyChanged += OnTablePropertyChanged; + // SortByColumn (called on header click) does NOT fire PropertyChanged for SortColumnIndex + // or CurrentSortDirection — those properties have no setters. MouseClick fires after + // SortByColumn completes, giving us the correct new sort state to detect header clicks. + _table.MouseClick += OnTableMouseClick; _table.MouseRightClick += OnTableRightClick; + // Wire per-column typed comparers so SortByColumn uses our comparers, not string comparison. + // This ensures the sort map produced by SortByColumn matches our pre-sorted page data. + var columns = _table.Columns; + for (var i = 0; i < columns.Count; i++) + { + var colIndex = i; + var col = columns[i]; + col.IsSortable = IsSortableColumn(colIndex); + var comparer = GetColumnComparer(colIndex); + col.CustomRowComparer = (a, b) => + a.Tag is TItem ta && b.Tag is TItem tb ? comparer.Compare(ta, tb) : 0; + } + _pageIndicator = new MarkupControl([string.Empty]) { Name = $"{GetType().Name}Page" }; _prevPageButton = Controls.Button(" ◄ ") @@ -186,7 +164,7 @@ public virtual IWindowControl BuildContent(ConsoleWindowSystem windowSystem) .WithName($"{GetType().Name}NextPage") .Build(); - _filterPrompt = Controls.Prompt("🔍 Filter: ") + _filterPrompt = Controls.Prompt("/ filter: ") .WithHistory(true) .WithTabCompleter((input, _) => GetCompletions(input)) .OnInputChanged((_, text) => @@ -231,8 +209,6 @@ public virtual IWindowControl BuildContent(ConsoleWindowSystem windowSystem) if (_pendingData is not null) { - // Force-rebuild even if IsActive is already true — BuildContent runs during NavigationView - // lazy init, which may happen after IsActive is set. var wasActive = IsActive; IsActive = false; UpdateData(_pendingData); @@ -292,28 +268,47 @@ public void ToggleDetailPane() } /// - public void Dispose() + public void MoveSelectionDown() { - UnsubscribeTableEvents(); - _root?.Dispose(); - _table?.Dispose(); - _detailPanel?.Dispose(); - _filterPrompt?.Dispose(); - _pageIndicator?.Dispose(); - _prevPageButton?.Dispose(); - _nextPageButton?.Dispose(); + if (_table is null || _table.SelectedRowIndex >= _table.Rows.Count - 1) + { + return; + } + + _table.SelectedRowIndex++; } - /// - /// Sets the filter text and rebuilds the table rows. Can be called externally to pre-filter the view. - /// - /// The filter string to apply. - public void SetFilter(string key) + /// + public void MoveSelectionUp() { - _currentFilter = key; - _pageIndex = 0; - SetFilterInput(key); - RebuildRows(); + if (_table is null || _table.SelectedRowIndex <= 0) + { + return; + } + + _table.SelectedRowIndex--; + } + + /// + public void JumpToFirstRow() + { + if (_table is null || _table.Rows.Count == 0) + { + return; + } + + _table.SelectedRowIndex = 0; + } + + /// + public void JumpToLastRow() + { + if (_table is null || _table.Rows.Count == 0) + { + return; + } + + _table.SelectedRowIndex = _table.Rows.Count - 1; } /// @@ -344,95 +339,107 @@ public void PreviousPage() RebuildRows(); } - /// - /// Extracts the relevant items from the snapshot. - /// + /// + public void Dispose() + { + if (_table is not null) + { + _table.MouseClick -= OnTableMouseClick; + _table.MouseRightClick -= OnTableRightClick; + } + + _root?.Dispose(); + _table?.Dispose(); + _detailPanel?.Dispose(); + _filterPrompt?.Dispose(); + _pageIndicator?.Dispose(); + _prevPageButton?.Dispose(); + _nextPageButton?.Dispose(); + } + + /// Sets the filter text and rebuilds the table rows. + /// The filter string to apply. + public void SetFilter(string filter) + { + _currentFilter = filter; + _pageIndex = 0; + SetFilterInput(filter); + RebuildRows(); + } + + /// Extracts the relevant items from the snapshot. /// The current workbench data snapshot. /// The items to display in the table. protected abstract IEnumerable GetItems(WorkbenchData data); - /// - /// Returns a stable string key that uniquely identifies this item (used for selection restore). - /// + /// Returns a stable string key that uniquely identifies this item. /// The item. /// A unique string key. protected abstract string GetKey(TItem item); - /// - /// Returns cell values for the table row (must match count). - /// + /// Returns cell values for the table row (must match count). /// The item to render. - /// Cell values for each column, may contain SharpConsoleUI markup. + /// Cell values for each column, may contain markup. protected abstract string[] BuildRow(TItem item); - /// - /// Returns the markup string shown in the right detail panel for the selected item. - /// + /// Returns the markup string shown in the right detail panel for the selected item. /// The selected item, or if nothing is selected. /// The current workbench data snapshot. /// Markup content for the detail panel. protected abstract string RenderDetail(TItem? item, WorkbenchData? data); - /// - /// Returns true if the item matches the given filter text. - /// + /// Returns true if the item matches the given filter text. /// The item to test. /// The current filter string. /// if the item passes the filter. protected abstract bool MatchesFilter(TItem item, string filter); /// - /// Returns the plain-text value used for sorting column . - /// Default: strips markup from the corresponding cell. - /// Subclasses may override to provide numeric-aware or custom sort keys. + /// Returns the comparer used to sort the given column. + /// Default: OrdinalIgnoreCase string comparison on the rendered cell text. + /// Override for numeric, date, or enum columns to get correct cross-page sort order. /// - /// The item to sort. /// The zero-based column index being sorted. - /// A plain-text sort key. - protected virtual string GetSortValue(TItem item, int columnIndex) - { - var cells = BuildRow(item); - return columnIndex < cells.Length ? Markup.Remove(cells[columnIndex]) : string.Empty; - } - - /// - /// Tab-completion tokens for the filter prompt. Default: none. - /// + /// A comparer for . + protected virtual IComparer GetColumnComparer(int columnIndex) => + Comparer.Create((a, b) => string.Compare( + Markup.Remove(BuildRow(a).ElementAtOrDefault(columnIndex) ?? string.Empty), + Markup.Remove(BuildRow(b).ElementAtOrDefault(columnIndex) ?? string.Empty), + StringComparison.OrdinalIgnoreCase)); + + /// Tab-completion tokens for the filter prompt. Default: none. /// The current user input. /// Completion suggestions. protected virtual IEnumerable GetCompletions(string input) => []; - /// - /// Called when Enter is pressed on a row (or row double-clicked). Default: no-op. - /// + /// Called when Enter is pressed on a row. Default: no-op. /// The activated item. protected virtual void OnRowActivated(TItem item) { } /// - /// Returns the context-menu actions available when the user right-clicks the given item. - /// The base implementation returns an empty sequence — override to provide view-specific actions. + /// Returns the actions available when is selected. + /// Override in action views to expose view-specific actions to the keyboard dispatcher + /// and right-click context menu. /// - /// The row item that was right-clicked. - /// - /// A sequence of (Label, Shortcut, Execute) tuples. Shortcut may be . - /// - protected virtual IEnumerable<(string Label, string? Shortcut, Action Execute)> GetContextMenuActions(TItem item) => []; + /// The currently selected item. + /// The list of actions the user can invoke on this item. + protected virtual IReadOnlyList GetAvailableActions(TItem item) => []; /// - /// Returns when the user may sort by the given column index. + /// Returns when the user may sort by the given column. /// Override to restrict sorting to specific columns only. /// /// The zero-based column index to test. - /// if the column is sortable; otherwise. + /// if the column is sortable. protected virtual bool IsSortableColumn(int columnIndex) => true; - static int ContextMenuWidth(List<(string Label, string? Shortcut, Action Execute)> actions) + static int ContextMenuWidth(List actions) { var maxLabel = actions.Max(a => a.Label.Length); - var maxShortcut = actions.Max(a => a.Shortcut?.Length ?? 0); - return maxLabel + (maxShortcut > 0 ? maxShortcut + 4 : 0) + 4; + var maxHint = actions.Max(a => a.KeyHint?.Length ?? 0); + return maxLabel + (maxHint > 0 ? maxHint + 4 : 0) + 4; } static void SetButtonEnabled(ButtonControl? button, bool enabled) @@ -456,6 +463,22 @@ List GetFiltered() => ? _allItems : [.. _allItems.Where(item => MatchesFilter(item, _currentFilter))]; + List ApplySort(List items, int sortCol, SortDirection sortDir) + { + var col = sortCol >= 0 ? sortCol : DefaultSortColumn; + var dir = sortDir != SortDirection.None ? sortDir : DefaultSortDirection; + + if (col < 0 || dir == SortDirection.None) + { + return items; + } + + var comparer = GetColumnComparer(col); + return dir == SortDirection.Ascending + ? [.. items.Order(comparer)] + : [.. items.OrderDescending(comparer)]; + } + void SetFilterInput(string value) { if (_filterPrompt is null) @@ -466,6 +489,30 @@ void SetFilterInput(string value) _filterPrompt.Input = value; } + /// + /// Fires after every left-click on the table, including header clicks. + /// SortByColumn runs before MouseClick fires, so we can compare the table's new + /// sort state against what we applied last time. When it differs, a header click changed the + /// sort and we rebuild the full dataset with the new sort applied across all pages. + /// + /// The event source. + /// Mouse event args. + void OnTableMouseClick(object? sender, MouseEventArgs e) + { + if (_table is null) + { + return; + } + + var newCol = _table.SortColumnIndex; + var newDir = _table.CurrentSortDirection; + + if (newCol != _lastAppliedSortColumn || newDir != _lastAppliedSortDirection) + { + RebuildRows(); + } + } + void OnTableRightClick(object? sender, MouseEventArgs e) { if (_windowSystem is null || SelectedItem is not TItem item) @@ -473,7 +520,7 @@ void OnTableRightClick(object? sender, MouseEventArgs e) return; } - var actions = GetContextMenuActions(item).ToList(); + var actions = GetAvailableActions(item).ToList(); if (actions.Count == 0) { return; @@ -482,7 +529,7 @@ void OnTableRightClick(object? sender, MouseEventArgs e) ShowContextMenu(e.AbsolutePosition.X, e.AbsolutePosition.Y, actions); } - void ShowContextMenu(int x, int y, List<(string Label, string? Shortcut, Action Execute)> actions) + void ShowContextMenu(int x, int y, List actions) { if (_windowSystem is null) { @@ -493,9 +540,9 @@ void ShowContextMenu(int x, int y, List<(string Label, string? Shortcut, Action .WithMenuBarColors(WorkbenchColors.Background, WorkbenchColors.Foreground, WorkbenchColors.Accent, WorkbenchColors.Background) .WithDropdownColors(WorkbenchColors.Background, WorkbenchColors.Foreground, WorkbenchColors.Accent, WorkbenchColors.Background); - foreach (var (label, shortcut, execute) in actions) + foreach (var action in actions) { - menuBuilder.AddItem(label, shortcut ?? string.Empty, execute); + menuBuilder.AddItem(action.Label, action.KeyHint ?? string.Empty, action.Execute); } var menu = menuBuilder.Build(); @@ -530,75 +577,6 @@ void ShowContextMenu(int x, int y, List<(string Label, string? Shortcut, Action _windowSystem.AddWindow(contextWindow, activateWindow: true); } - void UnsubscribeTableEvents() - { - if (_table is null) - { - return; - } - - _table.PropertyChanged -= OnTablePropertyChanged; - _table.MouseRightClick -= OnTableRightClick; - } - - /// - /// Responds to PropertyChanged events on the table — specifically SortColumnIndex and - /// CurrentSortDirection which fire when the user clicks a column header. Captures the new - /// sort intent, rejects sorts on columns where returns , - /// and immediately triggers a full-dataset rebuild so the correct order is shown - /// without waiting for the next data refresh cycle. - /// - /// The event source. - /// The property changed event args containing the changed property name. - void OnTablePropertyChanged(object? sender, PropertyChangedEventArgs e) - { - if (_suppressSortSync) - { - return; - } - - if (e.PropertyName is not (nameof(TableControl.SortColumnIndex) or nameof(TableControl.CurrentSortDirection))) - { - return; - } - - var newCol = _table!.SortColumnIndex; - var newDir = _table.CurrentSortDirection; - - if (newCol >= 0 && !IsSortableColumn(newCol)) - { - RestoreSortIndicator(); - return; - } - - _sortColumnIndex = newCol; - _sortDirection = newDir; - _lastSetSortColumn = _sortColumnIndex; - _lastSetSortDirection = _sortDirection; - RebuildRows(); - } - - /// - /// Sorts by the active sort column, falling back to - /// and when no user-initiated sort is active. - /// Sorting is applied to the full dataset before pagination so cross-page order is consistent. - /// - /// The filtered item list to sort. - List ApplySort(List items) - { - var col = _sortColumnIndex >= 0 ? _sortColumnIndex : DefaultSortColumn; - var dir = _sortDirection != SortDirection.None ? _sortDirection : DefaultSortDirection; - - if (col < 0 || dir == SortDirection.None) - { - return items; - } - - return dir == SortDirection.Ascending - ? [.. items.OrderBy(i => GetSortValue(i, col), StringComparer.OrdinalIgnoreCase)] - : [.. items.OrderByDescending(i => GetSortValue(i, col), StringComparer.OrdinalIgnoreCase)]; - } - void RebuildRows() { if (_table is null) @@ -606,17 +584,22 @@ void RebuildRows() return; } - SyncSortFromTable(); + // Capture sort state before clearing rows. + var sortCol = _table.SortColumnIndex; + var sortDir = _table.CurrentSortDirection; var selectedKey = SelectedItem is TItem sel ? GetKey(sel) : null; var pageSize = PageSize; + + // Sort the FULL filtered dataset first, then paginate. + // This guarantees consistent cross-page order regardless of column type. var filtered = GetFiltered(); - var sorted = ApplySort(filtered); + var sorted = ApplySort(filtered, sortCol, sortDir); var totalPages = ComputeTotalPages(sorted.Count); if (_pageIndex >= totalPages) { - _pageIndex = totalPages - 1; + _pageIndex = Math.Max(0, totalPages - 1); } _table.ClearRows(); @@ -626,65 +609,45 @@ void RebuildRows() _table.AddRow(new UITableRow(BuildRow(item)) { Tag = item }); } - RestoreSortIndicator(); - - if (selectedKey is not null) + // Restore sort indicator on the column header. Rows are already in sorted order from + // ApplySort above; CustomRowComparer matches that comparer so SortByColumn produces + // an identity map — the visual indicator is set without re-ordering rows. + // ClearSort and SortByColumn do NOT fire PropertyChanged, so no recursion risk. + _table.ClearSort(); + if (sortCol >= 0 && sortDir != SortDirection.None) { - RestoreSelection(selectedKey); - } - - UpdatePageIndicator(sorted.Count, totalPages, pageSize); - RefreshDetail(); - } - - /// - /// Detects sort changes made by clicking column headers (SharpConsoleUI updates SortColumnIndex - /// immediately on click) and persists the new intent into our own fields so it survives across rebuilds. - /// - void SyncSortFromTable() - { - var tableCol = _table!.SortColumnIndex; - var tableDir = _table.CurrentSortDirection; - - // A difference from what we last set means the user changed the sort via column header. - if (tableCol != _lastSetSortColumn || tableDir != _lastSetSortDirection) - { - _sortColumnIndex = tableCol; - _sortDirection = tableDir; + _table.SortByColumn(sortCol); + if (sortDir == SortDirection.Descending) + { + _table.SortByColumn(sortCol); + } } - } - - /// - /// Restores the sort direction indicator on the column header. - /// The rows are already in sorted order from ; SharpConsoleUI's - /// in-table re-sort on the current page is a stable no-op. - /// Uses to prevent the PropertyChanged callbacks - /// fired by ClearSort and SortByColumn from triggering a recursive rebuild. - /// - void RestoreSortIndicator() - { - _suppressSortSync = true; - try + else if (DefaultSortColumn >= 0 && DefaultSortDirection != SortDirection.None && sortCol < 0) { - _table!.ClearSort(); - _lastSetSortColumn = _sortColumnIndex; - _lastSetSortDirection = _sortDirection; - - if (_sortColumnIndex < 0 || _sortDirection == SortDirection.None) + _table.SortByColumn(DefaultSortColumn); + if (DefaultSortDirection == SortDirection.Descending) { - return; + _table.SortByColumn(DefaultSortColumn); } + } - _table.SortByColumn(_sortColumnIndex); - if (_sortDirection == SortDirection.Descending) - { - _table.SortByColumn(_sortColumnIndex); - } + // Record the sort state we applied so OnTableMouseClick can detect real header-click changes. + _lastAppliedSortColumn = _table.SortColumnIndex; + _lastAppliedSortDirection = _table.CurrentSortDirection; + + if (selectedKey is not null) + { + RestoreSelection(selectedKey); } - finally + else if (_table.Rows.Count > 0) { - _suppressSortSync = false; + // ClearRows() resets SelectedRowIndex to -1. Pre-select the first row so + // SelectedItem is always non-null and ViewActions always returns actions. + _table.SelectedRowIndex = 0; } + + UpdatePageIndicator(sorted.Count, totalPages, pageSize); + RefreshDetail(); } void UpdatePageIndicator(int totalItems, int totalPages, int pageSize) @@ -708,14 +671,21 @@ void RestoreSelection(string key) return; } - for (var i = 0; i < _table.Rows.Count; i++) + // Iterate display positions to account for the active sort map. + var count = _table.Rows.Count; + for (var dispIdx = 0; dispIdx < count; dispIdx++) { - if (_table.Rows[i].Tag is TItem item && GetKey(item) == key) + _table.SelectedRowIndex = dispIdx; + if (_table.SelectedRow?.Tag is TItem item && GetKey(item) == key) { - _table.SelectedRowIndex = i; return; } } + + if (count > 0) + { + _table.SelectedRowIndex = 0; + } } void RefreshDetail() diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/IWorkbenchView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/IWorkbenchView.cs index 20ffe73..c85e021 100644 --- a/Source/Cli/Commands/Chronicle/Workbench/views/IWorkbenchView.cs +++ b/Source/Cli/Commands/Chronicle/Workbench/views/IWorkbenchView.cs @@ -48,6 +48,41 @@ public interface IWorkbenchView : IDisposable /// string ViewHelp => string.Empty; + /// + /// Gets the actions available for the currently selected row. + /// Consumed by the keyboard dispatcher, right-click context menu, and the Actions menu bar item. + /// Returns an empty list when no item is selected or the view has no actions. + /// + IReadOnlyList ViewActions => []; + + /// + /// Moves the selection one row toward the end of the table. No-op for views without a table. + /// + void MoveSelectionDown() + { + } + + /// + /// Moves the selection one row toward the top of the table. No-op for views without a table. + /// + void MoveSelectionUp() + { + } + + /// + /// Jumps the selection to the first row in the table. No-op for views without a table. + /// + void JumpToFirstRow() + { + } + + /// + /// Jumps the selection to the last row in the table. No-op for views without a table. + /// + void JumpToLastRow() + { + } + /// /// Toggles the detail pane open or closed with an animation. /// Views without a detail pane may leave this as a no-op. @@ -87,15 +122,19 @@ void ClearFilter() } /// - /// Advances to the next page of results. No-op for views without pagination. + /// Applies the given filter string and rebuilds the table rows. No-op for views without a filter. /// + /// The filter text to apply. + void SetFilter(string filter) + { + } + + /// Advances to the next page of results. No-op for views without pagination. void NextPage() { } - /// - /// Goes back to the previous page of results. No-op for views without pagination. - /// + /// Goes back to the previous page of results. No-op for views without pagination. void PreviousPage() { } diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/JobsView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/JobsView.cs index a7406e9..69e1817 100644 --- a/Source/Cli/Commands/Chronicle/Workbench/views/JobsView.cs +++ b/Source/Cli/Commands/Chronicle/Workbench/views/JobsView.cs @@ -14,6 +14,14 @@ public class JobsView : FilterableTableView /// Gets the currently selected job, or if none is selected. public Job? SelectedJob => SelectedItem; + /// + public override string ViewHelp => + "Lists all background jobs and their current status.\n" + + " [S] Stop the selected job\n" + + " [U] Resume a stopped job\n" + + " [Space] Check / uncheck row for bulk operations\n" + + " [S] / [U] (with 2+ checked) Bulk stop / resume all checked jobs"; + /// /// Gets or sets the callback invoked when the user requests to stop a job. /// @@ -54,28 +62,31 @@ public class JobsView : FilterableTableView protected override bool HasCheckboxMode => true; /// - protected override IEnumerable<(string Label, string? Shortcut, Action Execute)> GetContextMenuActions(Job item) + protected override IReadOnlyList GetAvailableActions(Job item) { + List actions = []; if (OnStopJob is not null) { - yield return ("Stop job", "S", () => OnStopJob(item)); + actions.Add(new ViewAction("Stop job", "S", ConsoleKey.S, default, () => OnStopJob(item))); } if (OnResumeJob is not null) { - yield return ("Resume job", "U", () => OnResumeJob(item)); + actions.Add(new ViewAction("Resume job", "U", ConsoleKey.U, default, () => OnResumeJob(item))); } - var checkedCount = Checked.Count; - if (OnStopAll is not null && checkedCount > 1) + var checkedItems = Checked; + if (OnStopAll is not null && checkedItems.Count > 1) { - yield return ($"Stop {checkedCount} checked", null, () => OnStopAll(Checked)); + actions.Add(new ViewAction($"Stop {checkedItems.Count} checked", null, null, default, () => OnStopAll(checkedItems))); } - if (OnResumeAll is not null && checkedCount > 1) + if (OnResumeAll is not null && checkedItems.Count > 1) { - yield return ($"Resume {checkedCount} checked", null, () => OnResumeAll(Checked)); + actions.Add(new ViewAction($"Resume {checkedItems.Count} checked", null, null, default, () => OnResumeAll(checkedItems))); } + + return actions; } /// diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/ObserversView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/ObserversView.cs index bc7d34d..9cf3725 100644 --- a/Source/Cli/Commands/Chronicle/Workbench/views/ObserversView.cs +++ b/Source/Cli/Commands/Chronicle/Workbench/views/ObserversView.cs @@ -13,6 +13,13 @@ public class ObserversView : FilterableTableView /// Gets the currently selected observer, or if none is selected. public ObserverInformation? SelectedObserver => SelectedItem; + /// + public override string ViewHelp => + "Lists all registered observers and their current running state.\n" + + " [R] Replay the selected observer from the beginning\n" + + " [Space] Check / uncheck row for bulk operations\n" + + " [R] (with 2+ checked) Replay all checked observers"; + /// /// Gets or sets the callback invoked when the user requests a replay of the selected observer. /// @@ -39,24 +46,37 @@ public class ObserversView : FilterableTableView /// protected override string DetailPanelHeader => "OBSERVER"; + /// Uses the warning amber to match the OBSERVATION section color. + protected override SharpConsoleUI.Color DetailBorderColor => WorkbenchColors.Warning; + /// protected override bool HasCheckboxMode => true; /// - protected override IEnumerable<(string Label, string? Shortcut, Action Execute)> GetContextMenuActions(ObserverInformation item) + protected override IReadOnlyList GetAvailableActions(ObserverInformation item) { + List actions = []; if (OnReplay is not null) { - yield return ("Replay observer", "R", () => OnReplay(item)); + actions.Add(new ViewAction("Replay observer", "R", ConsoleKey.R, default, () => OnReplay(item))); } - var checkedCount = Checked.Count; - if (OnReplayAll is not null && checkedCount > 1) + var checkedItems = Checked; + if (OnReplayAll is not null && checkedItems.Count > 1) { - yield return ($"Replay {checkedCount} checked", null, () => OnReplayAll(Checked)); + actions.Add(new ViewAction($"Replay {checkedItems.Count} checked", null, null, default, () => OnReplayAll(checkedItems))); } + + return actions; } + /// + protected override IComparer GetColumnComparer(int columnIndex) => columnIndex switch + { + 0 => Comparer.Create((a, b) => ObserverSortOrder(a).CompareTo(ObserverSortOrder(b))), + _ => base.GetColumnComparer(columnIndex) + }; + /// protected override IEnumerable GetItems(WorkbenchData data) => data.Observers.OrderBy(ObserverSortOrder).ThenBy(o => o.Id); diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/ProjectionsView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/ProjectionsView.cs index 68be210..dc07e7b 100644 --- a/Source/Cli/Commands/Chronicle/Workbench/views/ProjectionsView.cs +++ b/Source/Cli/Commands/Chronicle/Workbench/views/ProjectionsView.cs @@ -20,6 +20,9 @@ public class ProjectionsView : FilterableTableView /// protected override string DetailPanelHeader => "PROJECTION"; + /// + protected override SharpConsoleUI.Color DetailBorderColor => WorkbenchColors.Mauve; + /// protected override IEnumerable GetItems(WorkbenchData data) => data.ProjectionDefinitions.OrderBy(d => d.Identifier); diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/ReadModelsView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/ReadModelsView.cs index fdb2f00..b304829 100644 --- a/Source/Cli/Commands/Chronicle/Workbench/views/ReadModelsView.cs +++ b/Source/Cli/Commands/Chronicle/Workbench/views/ReadModelsView.cs @@ -33,6 +33,9 @@ public class ReadModelsView : FilterableTableView /// protected override string DetailPanelHeader => "READ MODEL"; + /// + protected override SharpConsoleUI.Color DetailBorderColor => WorkbenchColors.Mauve; + /// public override IWindowControl BuildContent(ConsoleWindowSystem windowSystem) { diff --git a/Source/Cli/Commands/Chronicle/Workbench/views/RecommendationsView.cs b/Source/Cli/Commands/Chronicle/Workbench/views/RecommendationsView.cs index 8d19d62..67a17a6 100644 --- a/Source/Cli/Commands/Chronicle/Workbench/views/RecommendationsView.cs +++ b/Source/Cli/Commands/Chronicle/Workbench/views/RecommendationsView.cs @@ -13,6 +13,14 @@ public class RecommendationsView : FilterableTableView /// Gets the currently selected recommendation, or if none is selected. public Recommendation? SelectedRecommendation => SelectedItem; + /// + public override string ViewHelp => + "Lists pending recommendations suggested by Chronicle.\n" + + " [A] Apply the selected recommendation\n" + + " [I] Ignore the selected recommendation\n" + + " [Space] Check / uncheck row for bulk operations\n" + + " [A] / [I] (with 2+ checked) Bulk apply / ignore all checked"; + /// /// Gets or sets the callback invoked when the user applies a recommendation. /// @@ -55,28 +63,31 @@ public class RecommendationsView : FilterableTableView protected override bool HasCheckboxMode => true; /// - protected override IEnumerable<(string Label, string? Shortcut, Action Execute)> GetContextMenuActions(Recommendation item) + protected override IReadOnlyList GetAvailableActions(Recommendation item) { + List actions = []; if (OnApply is not null) { - yield return ("Apply recommendation", "A", () => OnApply(item)); + actions.Add(new ViewAction("Apply recommendation", "A", ConsoleKey.A, default, () => OnApply(item))); } if (OnIgnore is not null) { - yield return ("Ignore recommendation", "I", () => OnIgnore(item)); + actions.Add(new ViewAction("Ignore recommendation", "I", ConsoleKey.I, default, () => OnIgnore(item))); } - var checkedCount = Checked.Count; - if (OnApplyAll is not null && checkedCount > 1) + var checkedItems = Checked; + if (OnApplyAll is not null && checkedItems.Count > 1) { - yield return ($"Apply {checkedCount} checked", null, () => OnApplyAll(Checked)); + actions.Add(new ViewAction($"Apply {checkedItems.Count} checked", null, null, default, () => OnApplyAll(checkedItems))); } - if (OnIgnoreAll is not null && checkedCount > 1) + if (OnIgnoreAll is not null && checkedItems.Count > 1) { - yield return ($"Ignore {checkedCount} checked", null, () => OnIgnoreAll(Checked)); + actions.Add(new ViewAction($"Ignore {checkedItems.Count} checked", null, null, default, () => OnIgnoreAll(checkedItems))); } + + return actions; } /// diff --git a/Source/Cli/Commands/Chronicle/Workbench/windows/MainWindow.cs b/Source/Cli/Commands/Chronicle/Workbench/windows/MainWindow.cs index 68bb1c0..e6da66b 100644 --- a/Source/Cli/Commands/Chronicle/Workbench/windows/MainWindow.cs +++ b/Source/Cli/Commands/Chronicle/Workbench/windows/MainWindow.cs @@ -6,17 +6,13 @@ using Cratis.Chronicle.Contracts.Recommendations; using SharpConsoleUI; using SharpConsoleUI.Builders; -using SharpConsoleUI.Controls; using SharpConsoleUI.Helpers; -using SColor = SharpConsoleUI.Color; -using UITableRow = SharpConsoleUI.Controls.TableRow; +using SharpConsoleUI.Rendering; namespace Cratis.Cli.Commands.Chronicle.Workbench; /// -/// The main full-screen workbench window: navigation side pane, content area, and status bar. -/// Serves as the composition root — delegates action confirmation to -/// and navigation building to . +/// Composition root for the Chronicle Workbench TUI — creates and wires all subsystems, then builds the main window. /// /// The SharpConsoleUI window system. /// The Chronicle data service. @@ -32,74 +28,32 @@ public class MainWindow( WorkbenchData initialData, WorkbenchState state) { - /// Width of the help overlay window in columns. - const int HelpOverlayWidth = 72; + readonly IWorkbenchView[] _views = WorkbenchViewRegistry.CreateViews(); - /// Height of the help overlay window in rows. - const int HelpOverlayHeight = 40; - - /// Width of the command palette window in columns. - const int CommandPaletteWidth = 80; - - /// Height of the command palette window in rows. - const int CommandPaletteHeight = 18; - - /// Maximum number of results shown in the command palette. - const int MaxCommandPaletteResults = 10; - - /// - /// View instances — created once, reused across refreshes. - /// Order must match the WorkbenchNavigation.IndexXxx constants. - /// - readonly IWorkbenchView[] _views = - [ - new OverviewView(), // 0 Overview - new ObserversView(), // 1 Observers - new FailedPartitionsView(), // 2 Failures - new JobsView(), // 3 Jobs - new RecommendationsView(), // 4 Recommendations - new EventSequencesView(), // 5 Event Sequences - new EventTypesView(), // 6 Event Types - new ProjectionsView(), // 7 Projections - new ReadModelsView(), // 8 Read Models - new EventStoresView(), // 9 Event Stores - new NamespacesView(), // 10 Namespaces - new ApplicationsView(), // 11 Applications - new UsersView(), // 12 Users - new IdentitiesView(), // 13 Identities - new SubscriptionsView(), // 14 Subscriptions - ]; - - readonly object _dataLock = new(); string? _activeEventStore; string? _activeNamespace; - WorkbenchData? _currentData; - bool _wasDisconnected; Window? _window; - StatusBarControl? _statusBar; - MarkupControl? _titleBar; WorkbenchActionHandler? _actionHandler; WorkbenchNavigation? _navigation; - bool _sidebarExpanded = true; + WorkbenchRefreshLoop? _refreshLoop; + WorkbenchOverlays? _overlays; /// - /// Builds the main window with all controls and the async update thread. + /// Builds the main window, composes all workbench subsystems, and returns the ready-to-show window. /// - /// The constructed . + /// The fully configured . public Window Build() { - _actionHandler = new WorkbenchActionHandler(text => - { - if (string.IsNullOrEmpty(text)) - { - UpdateStatusBar(); - } - else + _actionHandler = new WorkbenchActionHandler( + windowSystem, + text => { - UpdateStatusRight(text); - } - }); + if (string.IsNullOrEmpty(text)) + _refreshLoop?.UpdateTopPanel(); + else + windowSystem.PanelStateService.TopStatus = text; + }); _navigation = new WorkbenchNavigation( windowSystem, @@ -111,86 +65,98 @@ public Window Build() { _activeEventStore = storeName; _activeNamespace = null; - SwitchToOverview(); - _ = Task.Run(() => FetchAndUpdate(CancellationToken.None)); + _navigation!.NavigateTo(WorkbenchNavigation.IndexOverview); + _ = Task.Run(() => _refreshLoop!.FetchAndUpdate(CancellationToken.None)); }, nsName => { _activeNamespace = nsName; - _ = Task.Run(() => FetchAndUpdate(CancellationToken.None)); + _navigation!.NavigateTo(WorkbenchNavigation.IndexOverview); + _ = Task.Run(() => _refreshLoop!.FetchAndUpdate(CancellationToken.None)); }, - () => _ = Task.Run(() => FetchAndUpdate(CancellationToken.None)), - () => - { - lock (_dataLock) - { - return _currentData; - } - }); + () => _ = Task.Run(() => _refreshLoop!.FetchAndUpdate(CancellationToken.None)), + () => _refreshLoop?.CurrentData); + + _refreshLoop = new WorkbenchRefreshLoop( + dataService, + settings, + _views, + _navigation, + windowSystem, + () => _activeEventStore, + () => _activeNamespace); WireViewCallbacks(); + _overlays = new WorkbenchOverlays(windowSystem, _views, _navigation, _actionHandler, _refreshLoop); + var overlays = _overlays; + var keyDispatcher = new WorkbenchKeyDispatcher( + _navigation, _views, _actionHandler, windowSystem, overlays, settings, state, () => _window); + var menuBar = new WorkbenchMenuBar(_navigation, overlays, windowSystem, settings, state).Build(); var navView = _navigation.BuildNavigationView(); - var statusBar = BuildStatusBar(); - _currentData = initialData; - PushDataToViews(initialData); - UpdateStatusBar(initialData); - _navigation.UpdateNavBadges(initialData); + _refreshLoop.Initialize(initialData); var builtWindow = new WindowBuilder(windowSystem) .WithTitle(string.Empty) .Maximized() .WithColors(WorkbenchColors.Foreground, WorkbenchColors.Background) + .WithBackgroundGradient( + ColorGradient.FromColors([ + WorkbenchColors.Background, + WorkbenchColors.Surface, + WorkbenchColors.Background + ]), + GradientDirection.DiagonalDown) .Borderless() // cspell:ignore Borderless .HideTitle() .HideCloseButton() - .AddControl(BuildTitleBar()) + .AddControl(menuBar) .AddControl(navView) - .AddControl(statusBar) - .OnKeyPressed((_, e) => HandleKeyPress(e)) - .WithAsyncWindowThread(RunDataRefreshLoop) + .OnKeyPressed((_, e) => keyDispatcher.Dispatch(e)) + .WithAsyncWindowThread(_refreshLoop.RunAsync) .Build(); _window = builtWindow; - // Restore the last active navigation item from the previous session. - if (_navigation?.NavView is not null && state.LastNavIndex > 0 && state.LastNavIndex < _views.Length) - _navigation.NavView.SelectedIndex = state.LastNavIndex; + // The menu bar is the first added control so the window system gives it initial focus. + // Move focus to the nav view so arrow keys, action keys, and shortcuts work immediately. + _window.FocusControl(navView); + + if (state.LastNavIndex > 0 && state.LastNavIndex < _views.Length) + { + _navigation.NavigateTo(state.LastNavIndex); + } return builtWindow; } static string TruncateId(string s) => s.Length <= 40 ? s : s[..37] + "…"; - /// - /// Extracts the host:port portion from a Chronicle connection string. - /// - /// The raw connection string, possibly including credentials. - /// A clean chronicle://host:port string, or the original if parsing fails. - static string ExtractHostFromConnectionString(string connectionString) + void OpenObserversForEventTypeOverlay(string eventTypeId) { - const string scheme = "chronicle://"; - if (!connectionString.StartsWith(scheme, StringComparison.OrdinalIgnoreCase)) - { - return connectionString; - } - - var afterScheme = connectionString[scheme.Length..]; - - var queryStart = afterScheme.IndexOf('?'); - if (queryStart >= 0) + var snapshot = _refreshLoop?.CurrentData; + if (snapshot is null || _overlays is null) { - afterScheme = afterScheme[..queryStart]; + return; } - var atSign = afterScheme.IndexOf('@'); - if (atSign >= 0) - { - afterScheme = afterScheme[(atSign + 1)..]; - } + var matching = snapshot.Observers + .Where(o => (o.EventTypes ?? []).Any(et => + string.Equals(et.Id, eventTypeId, StringComparison.OrdinalIgnoreCase))) + .ToList(); - return $"chronicle://{afterScheme}"; + _overlays.OpenObserversForEventType( + eventTypeId, + matching, + obs => + { + _navigation!.NavigateTo(WorkbenchNavigation.IndexObservers); + if (_views[WorkbenchNavigation.IndexObservers] is ObserversView ov) + { + ov.SetFilter(obs.Id); + } + }); } void WireViewCallbacks() @@ -366,8 +332,8 @@ await dataService.FetchAsync( { _activeEventStore = storeName; _activeNamespace = null; - SwitchToOverview(); - _ = Task.Run(() => FetchAndUpdate(CancellationToken.None)); + _navigation!.NavigateTo(WorkbenchNavigation.IndexOverview); + _ = Task.Run(() => _refreshLoop!.FetchAndUpdate(CancellationToken.None)); }; } @@ -376,42 +342,26 @@ await dataService.FetchAsync( nsv.OnSwitch = nsName => { _activeNamespace = nsName; - SwitchToOverview(); - _ = Task.Run(() => FetchAndUpdate(CancellationToken.None)); + _navigation!.NavigateTo(WorkbenchNavigation.IndexOverview); + _ = Task.Run(() => _refreshLoop!.FetchAndUpdate(CancellationToken.None)); }; } if (_views[WorkbenchNavigation.IndexEventSequences] is EventSequencesView seqView) { seqView.OnViewEventTypeDefinition = evt => - { - _navigation?.NavigateTo(WorkbenchNavigation.IndexEventTypes); - if (_views[WorkbenchNavigation.IndexEventTypes] is EventTypesView etv) - { - etv.SetFilter(evt.Context.EventType.Id); - } - }; + _overlays?.OpenEventTypeDefinition( + evt.Context.EventType.Id, + _refreshLoop?.CurrentData); seqView.OnViewObserversForType = evt => - { - _navigation?.NavigateTo(WorkbenchNavigation.IndexObservers); - if (_views[WorkbenchNavigation.IndexObservers] is ObserversView ov) - { - ov.SetFilter($"event:{evt.Context.EventType.Id}"); - } - }; + OpenObserversForEventTypeOverlay(evt.Context.EventType.Id); } if (_views[WorkbenchNavigation.IndexEventTypes] is EventTypesView etView) { etView.OnViewObservers = reg => - { - _navigation?.NavigateTo(WorkbenchNavigation.IndexObservers); - if (_views[WorkbenchNavigation.IndexObservers] is ObserversView ov) - { - ov.SetFilter($"event:{reg.Type.Id}"); - } - }; + OpenObserversForEventTypeOverlay(reg.Type.Id); } foreach (var view in _views) @@ -419,770 +369,4 @@ await dataService.FetchAsync( view.OnFilterFocusChanged = focused => _actionHandler!.TextInputFocused = focused; } } - - string BuildTitleContent() - { - var acc = WorkbenchColors.Accent.ToMarkup(); - var mut = WorkbenchColors.Muted.ToMarkup(); - var suc = WorkbenchColors.Success.ToMarkup(); - var host = ExtractHostFromConnectionString(settings.ResolveConnectionString()); - var eventStore = _activeEventStore ?? settings.ResolveEventStore(); - var ns = _activeNamespace ?? settings.ResolveNamespace(); - return $" [bold {acc}]◆ CHRONICLE WORKBENCH[/]" + - $" [{mut}]{host}[/]" + - $" [{suc}]●[/] [{mut}]{eventStore} / {ns}[/]" + - $" [{mut}]↻ {settings.Interval}s[/]"; - } - - MarkupControl BuildTitleBar() - { - var control = new MarkupControl([BuildTitleContent()]) - { - Name = "TitleBar" - }; - _titleBar = control; - return control; - } - - StatusBarControl BuildStatusBar() - { - var statusBar = Controls.StatusBar() - .WithName("StatusBar") - .StickyBottom() - .AddLeft("F", "Filter", null) - .AddLeft("?", "Help", () => OpenHelpOverlay()) - .AddLeft("Q", "Quit", null) - .AddRight(string.Empty, "Connecting...", null) - .Build(); - - _statusBar = statusBar; - return statusBar; - } - - async Task RunDataRefreshLoop(Window window, CancellationToken ct) - { - await FetchAndUpdate(ct); - - while (!ct.IsCancellationRequested) - { - try - { - await Task.Delay(TimeSpan.FromSeconds(settings.Interval), ct); - } - catch (OperationCanceledException) - { - break; - } - - await FetchAndUpdate(ct); - } - } - - async Task FetchAndUpdate(CancellationToken ct) - { - try - { - UpdateStatusRight($"[{WorkbenchColors.Muted.ToMarkup()}]↻ refreshing…[/]"); - - var data = await dataService.FetchAsync( - _activeEventStore, - _activeNamespace, - readModelContainerName: null, - ct); - - lock (_dataLock) - { - _currentData = data; - } - - PushDataToViews(data); - - if (_wasDisconnected && data.IsConnected) - { - var suc = WorkbenchColors.Success.ToMarkup(); - _ = Task.Run( - async () => - { - UpdateStatusRight($"[{suc}]✓ Reconnected[/]"); - await Task.Delay(3000, ct); - UpdateStatusBar(_currentData); - }, - ct); - } - - _wasDisconnected = !data.IsConnected; - - UpdateStatusBar(data); - _navigation?.UpdateNavBadges(data); - - if (_titleBar is MarkupControl titleBar) - { - titleBar.Text = BuildTitleContent(); - } - } - catch (OperationCanceledException) - { - } - catch - { - // Swallow — connectivity errors shown in status bar via IsConnected. - } - } - - void PushDataToViews(WorkbenchData data) - { - foreach (var view in _views) - { - view.UpdateData(data); - } - } - - void UpdateStatusBar(WorkbenchData? data = null) - { - if (_statusBar is null) - { - return; - } - - data ??= _currentData; - if (data is null) - { - return; - } - - _statusBar.ClearRight(); - - var connDot = data.IsConnected - ? $"[{WorkbenchColors.Success.ToMarkup()}]●[/] connected" - : $"[{WorkbenchColors.Danger.ToMarkup()}]●[/] disconnected"; - - var seqText = data.TailSequenceNumber.HasValue - ? $" seq# {data.TailSequenceNumber.Value:N0}" - : string.Empty; - - var mut = WorkbenchColors.Muted.ToMarkup(); - var acc = WorkbenchColors.Accent.ToMarkup(); - - var eventStore = _activeEventStore ?? settings.ResolveEventStore(); - var ns = _activeNamespace ?? settings.ResolveNamespace(); - - _statusBar.AddRightText( - $"{connDot}{seqText} [{acc}]{eventStore}[/] [{mut}]/[/] [{acc}]{ns}[/] [{mut}]↻{settings.Interval}s[/]", - null); - } - - void HandleKeyPress(KeyPressedEventArgs e) - { - if (_navigation?.NavView is null) - { - return; - } - - if (_actionHandler!.HandlePendingKeyPress(e.KeyInfo, () => UpdateStatusBar())) - { - e.Handled = true; - return; - } - - if (_actionHandler!.TextInputFocused) - { - if (e.KeyInfo.Key == ConsoleKey.Escape) - { - var idx = _navigation.CurrentViewIndex; - if (idx >= 0 && idx < _views.Length) - { - _views[idx].ClearFilter(); - } - - e.Handled = true; - } - - return; - } - - var navView = _navigation.NavView; - switch (e.KeyInfo.Key) - { - case ConsoleKey.LeftArrow: - FocusNavigation(); - e.Handled = true; - break; - - case ConsoleKey.RightArrow: - FocusContent(); - e.Handled = true; - break; - - case ConsoleKey.B when e.KeyInfo.Modifiers.HasFlag(ConsoleModifiers.Control): - ToggleSidebar(); - e.Handled = true; - break; - - case ConsoleKey.Oem5 when e.KeyInfo.Modifiers.HasFlag(ConsoleModifiers.Control): - ToggleDetailPane(); - e.Handled = true; - break; - - case ConsoleKey.E when e.KeyInfo.Modifiers.HasFlag(ConsoleModifiers.Control): - _navigation.OpenEventStorePicker(); - e.Handled = true; - break; - - case ConsoleKey.N when e.KeyInfo.Modifiers.HasFlag(ConsoleModifiers.Control): - _navigation.OpenNamespacePicker(); - e.Handled = true; - break; - - case ConsoleKey.C when e.KeyInfo.Modifiers.HasFlag(ConsoleModifiers.Control): - CopyDetailToClipboard(); - e.Handled = true; - break; - - case ConsoleKey.Enter when _navigation.CurrentViewIndex == WorkbenchNavigation.IndexReadModels: - OpenReadModelDetail(); - e.Handled = true; - break; - - case ConsoleKey.D when _navigation.CurrentViewIndex == WorkbenchNavigation.IndexEventSequences: - TriggerEventSequenceAction(seqView => seqView.OnViewEventTypeDefinition, seqView => seqView.SelectedEvent); - e.Handled = true; - break; - - case ConsoleKey.V when _navigation.CurrentViewIndex == WorkbenchNavigation.IndexEventSequences: - TriggerEventSequenceAction(seqView => seqView.OnViewObserversForType, seqView => seqView.SelectedEvent); - e.Handled = true; - break; - - case ConsoleKey.V when _navigation.CurrentViewIndex == WorkbenchNavigation.IndexEventTypes: - TriggerEventTypeAction(etv => etv.OnViewObservers, etv => etv.SelectedEventType); - e.Handled = true; - break; - - case ConsoleKey.Oem2 when e.KeyInfo.Modifiers == ConsoleModifiers.Shift: - OpenHelpOverlay(); - e.Handled = true; - break; - - case ConsoleKey.P when e.KeyInfo.Modifiers.HasFlag(ConsoleModifiers.Control): - OpenCommandPalette(); - e.Handled = true; - break; - - case ConsoleKey.Oem6: // ] — next page (Mac-friendly alternative to PageDown) - case ConsoleKey.PageDown: - { - var idx = _navigation.CurrentViewIndex; - if (idx >= 0 && idx < _views.Length) _views[idx].NextPage(); - e.Handled = true; - break; - } - - case ConsoleKey.Oem4: // [ — previous page (Mac-friendly alternative to PageUp) - case ConsoleKey.PageUp: - { - var idx = _navigation.CurrentViewIndex; - if (idx >= 0 && idx < _views.Length) _views[idx].PreviousPage(); - e.Handled = true; - break; - } - - case ConsoleKey.R when _navigation.CurrentViewIndex == WorkbenchNavigation.IndexObservers: - TriggerSelectedObserverReplay(); - e.Handled = true; - break; - - case ConsoleKey.T when _navigation.CurrentViewIndex == WorkbenchNavigation.IndexFailures: - TriggerSelectedPartitionRetry(); - e.Handled = true; - break; - - case ConsoleKey.P when _navigation.CurrentViewIndex == WorkbenchNavigation.IndexFailures: - TriggerSelectedPartitionReplay(); - e.Handled = true; - break; - - case ConsoleKey.S when _navigation.CurrentViewIndex == WorkbenchNavigation.IndexJobs: - TriggerSelectedJobStop(); - e.Handled = true; - break; - - case ConsoleKey.U when _navigation.CurrentViewIndex == WorkbenchNavigation.IndexJobs: - TriggerSelectedJobResume(); - e.Handled = true; - break; - - case ConsoleKey.A when _navigation.CurrentViewIndex == WorkbenchNavigation.IndexRecommendations: - TriggerSelectedRecommendationApply(); - e.Handled = true; - break; - - case ConsoleKey.I when _navigation.CurrentViewIndex == WorkbenchNavigation.IndexRecommendations: - TriggerSelectedRecommendationIgnore(); - e.Handled = true; - break; - - case ConsoleKey.F: - ActivateCurrentFilter(); - e.Handled = true; - break; - - case ConsoleKey.Q: - state.Interval = settings.Interval; - state.LastNavIndex = _navigation?.CurrentViewIndex ?? 0; - state.Save(); - Environment.Exit(0); - break; - } - } - - void OpenHelpOverlay() - { - var mut = WorkbenchColors.Muted.ToMarkup(); - var acc = WorkbenchColors.Accent.ToMarkup(); - - var currentViewHelp = string.Empty; - var activeIdx = _navigation?.CurrentViewIndex ?? -1; - if (activeIdx >= 0 && activeIdx < _views.Length && !string.IsNullOrEmpty(_views[activeIdx].ViewHelp)) - { - currentViewHelp = - $"[bold {acc}]THIS VIEW[/]\n" + - string.Join('\n', _views[activeIdx].ViewHelp.Split('\n').Select(l => $" {l}")) + - "\n\n"; - } - - var helpText = - currentViewHelp + - $"[bold {acc}]NAVIGATION[/]\n" + - $" [{mut}]↑ ↓[/] Move selection\n" + - $" [{mut}]← / →[/] Sidebar ↔ Content\n" + - $" [{mut}]Ctrl+B[/] Toggle sidebar expand / compact\n" + - $" [{mut}]Ctrl+\\[/] Toggle detail pane\n" + - $" [{mut}]Enter[/] Open detail overlay\n" + - $" [{mut}]Esc[/] Close overlay\n" + - "\n" + - $"[bold {acc}]PAGING[/]\n" + - $" [{mut}][ / ][/] Previous / next page\n" + - $" [{mut}]◄ ► buttons[/] Click to change page\n" + - "\n" + - $"[bold {acc}]QUICK SWITCH[/]\n" + - $" [{mut}]Ctrl+E[/] Switch event store\n" + - $" [{mut}]Ctrl+N[/] Switch namespace\n" + - "\n" + - $"[bold {acc}]FILTER[/]\n" + - $" [{mut}]F[/] Focus filter prompt for current view\n" + - $" [{mut}]Escape[/] Clear filter and return focus to table\n" + - "\n" + - $"[bold {acc}]ACTIONS (when row selected)[/]\n" + - $" [{mut}]R[/] Replay observer\n" + - $" [{mut}]T[/] Retry partition\n" + - $" [{mut}]P[/] Replay partition\n" + - $" [{mut}]S / U[/] Stop / Resume job\n" + - $" [{mut}]A / I[/] Apply / Ignore recommendation\n" + - $" [{mut}]D[/] View event type definition (Event Sequences)\n" + - $" [{mut}]V[/] View observers for event type\n" + - $" [{mut}]Y / N[/] Confirm / Cancel action\n" + - "\n" + - $"[bold {acc}]CLIPBOARD[/]\n" + - $" [{mut}]Ctrl+C[/] Copy detail pane content to clipboard\n" + - "\n" + - $"[bold {acc}]GENERAL[/]\n" + - $" [{mut}]+ / -[/] Increase / decrease refresh interval\n" + - $" [{mut}]Ctrl+P[/] Command palette\n" + - $" [{mut}]?[/] This help screen\n" + - $" [{mut}]Q[/] Quit"; - - var markup = new MarkupControl([helpText]) { Wrap = true }; - var content = Controls.ScrollablePanel() - .AddControl(markup) - .WithVerticalScroll(ScrollMode.Scroll) - .WithPadding(2, 1, 2, 1) - .Build(); - - Window? helpWindow = null; - helpWindow = new WindowBuilder(windowSystem) - .WithTitle(" Keyboard Shortcuts ") - .WithColors(WorkbenchColors.Foreground, WorkbenchColors.Background) - .WithSize(HelpOverlayWidth, HelpOverlayHeight) - .Centered() - .AddControl(content) - .OnKeyPressed((_, e) => - { - if (e.KeyInfo.Key == ConsoleKey.Escape || - (e.KeyInfo.Key == ConsoleKey.Oem2 && e.KeyInfo.Modifiers == ConsoleModifiers.Shift)) - { - windowSystem.CloseWindow(helpWindow, activateParent: true, force: false); - } - - e.Handled = true; - }) - .Build(); - - windowSystem.AddWindow(helpWindow, activateWindow: true); - } - - void TriggerEventSequenceAction(Func?> getCallback, Func getItem) - { - if (_views[WorkbenchNavigation.IndexEventSequences] is not EventSequencesView esv) - { - return; - } - - var item = getItem(esv); - if (item is null) - { - ShowNoSelectionHint(); - return; - } - - getCallback(esv)?.Invoke(item); - } - - void TriggerEventTypeAction(Func?> getCallback, Func getItem) - { - if (_views[WorkbenchNavigation.IndexEventTypes] is not EventTypesView etv) - { - return; - } - - var item = getItem(etv); - if (item is null) - { - ShowNoSelectionHint(); - return; - } - - getCallback(etv)?.Invoke(item); - } - - void ShowNoSelectionHint() - { - UpdateStatusRight($"[{WorkbenchColors.Muted.ToMarkup()}]Select a row first[/]"); - _ = Task.Delay(2000).ContinueWith(_ => UpdateStatusBar(_currentData), TaskScheduler.Default); - } - - void TriggerSelectedObserverReplay() - { - if (_views[WorkbenchNavigation.IndexObservers] is not ObserversView ov) return; - var selected = ov.SelectedObserver; - if (selected is null) - { - ShowNoSelectionHint(); - return; - } - ov.OnReplay?.Invoke(selected); - } - - void TriggerSelectedPartitionRetry() - { - if (_views[WorkbenchNavigation.IndexFailures] is not FailedPartitionsView fv) return; - var selected = fv.SelectedPartition; - if (selected is null) - { - ShowNoSelectionHint(); - return; - } - fv.OnRetryPartition?.Invoke(selected); - } - - void TriggerSelectedPartitionReplay() - { - if (_views[WorkbenchNavigation.IndexFailures] is not FailedPartitionsView fv) return; - var selected = fv.SelectedPartition; - if (selected is null) - { - ShowNoSelectionHint(); - return; - } - fv.OnReplayPartition?.Invoke(selected); - } - - void TriggerSelectedJobStop() - { - if (_views[WorkbenchNavigation.IndexJobs] is not JobsView jv) return; - var selected = jv.SelectedJob; - if (selected is null) - { - ShowNoSelectionHint(); - return; - } - jv.OnStopJob?.Invoke(selected); - } - - void TriggerSelectedJobResume() - { - if (_views[WorkbenchNavigation.IndexJobs] is not JobsView jv) return; - var selected = jv.SelectedJob; - if (selected is null) - { - ShowNoSelectionHint(); - return; - } - jv.OnResumeJob?.Invoke(selected); - } - - void TriggerSelectedRecommendationApply() - { - if (_views[WorkbenchNavigation.IndexRecommendations] is not RecommendationsView rv) return; - var selected = rv.SelectedRecommendation; - if (selected is null) - { - ShowNoSelectionHint(); - return; - } - rv.OnApply?.Invoke(selected); - } - - void TriggerSelectedRecommendationIgnore() - { - if (_views[WorkbenchNavigation.IndexRecommendations] is not RecommendationsView rv) return; - var selected = rv.SelectedRecommendation; - if (selected is null) - { - ShowNoSelectionHint(); - return; - } - rv.OnIgnore?.Invoke(selected); - } - - void NavigateObservers() => _navigation?.NavigateTo(WorkbenchNavigation.IndexObservers); - - void NavigateEventTypes() => _navigation?.NavigateTo(WorkbenchNavigation.IndexEventTypes); - - void NavigateProjections() => _navigation?.NavigateTo(WorkbenchNavigation.IndexProjections); - - void NavigateReadModels() => _navigation?.NavigateTo(WorkbenchNavigation.IndexReadModels); - - void NavigateFailures() => _navigation?.NavigateTo(WorkbenchNavigation.IndexFailures); - - void OpenCommandPalette() - { - WorkbenchData? snapshot; - lock (_dataLock) - { - snapshot = _currentData; - } - - if (snapshot is null) - { - return; - } - - var mut = WorkbenchColors.Muted.ToMarkup(); - var acc = WorkbenchColors.Accent.ToMarkup(); - - var resultsTable = Controls.Table() - .AddColumn("Kind", SharpConsoleUI.Layout.TextJustification.Left, 20) - .AddColumn("Name", SharpConsoleUI.Layout.TextJustification.Left, null) - .Interactive() - .WithVerticalScrollbar(ScrollbarVisibility.Auto) - .WithName("CommandPaletteResults") - .Build(); - - var searchPrompt = Controls.Prompt($"[{acc}]>[/] ") - .WithName("CommandPaletteSearch") - .OnGotFocus((_, _) => _actionHandler!.TextInputFocused = true) - .OnLostFocus((_, _) => _actionHandler!.TextInputFocused = false) - .Build(); - - void PopulateResults(string query) - { - resultsTable.ClearRows(); - - if (string.IsNullOrWhiteSpace(query)) - { - return; - } - - var matches = new List<(string Kind, string Label, Action Navigate)>(); - - foreach (var obs in snapshot.Observers) - { - if (obs.Id.Contains(query, StringComparison.OrdinalIgnoreCase)) - { - matches.Add(("Observer", $"{obs.Id} [{obs.RunningState}]", NavigateObservers)); - } - } - - foreach (var et in snapshot.EventTypeRegistrations) - { - if (et.Type.Id.Contains(query, StringComparison.OrdinalIgnoreCase)) - { - matches.Add(("Event Type", $"{et.Type.Id} gen {et.Type.Generation}", NavigateEventTypes)); - } - } - - foreach (var pd in snapshot.ProjectionDefinitions) - { - if (pd.Identifier.Contains(query, StringComparison.OrdinalIgnoreCase)) - { - matches.Add(("Projection", pd.Identifier, NavigateProjections)); - } - } - - foreach (var rm in snapshot.ReadModelDefinitions) - { - if (rm.ContainerName.Contains(query, StringComparison.OrdinalIgnoreCase) || - rm.DisplayName.Contains(query, StringComparison.OrdinalIgnoreCase)) - { - matches.Add(("Read Model", rm.DisplayName.Length > 0 ? rm.DisplayName : rm.ContainerName, NavigateReadModels)); - } - } - - foreach (var fp in snapshot.FailedPartitions) - { - if (fp.ObserverId.Contains(query, StringComparison.OrdinalIgnoreCase) || - fp.Partition.Contains(query, StringComparison.OrdinalIgnoreCase)) - { - matches.Add(("Failure", $"{fp.ObserverId}/{fp.Partition}", NavigateFailures)); - } - } - - foreach (var (kind, label, navigate) in matches.Take(MaxCommandPaletteResults)) - { - resultsTable.AddRow(new UITableRow([kind, label]) { Tag = navigate }); - } - } - - searchPrompt.InputChanged += (_, text) => PopulateResults(text ?? string.Empty); - - Window? paletteWindow = null; - paletteWindow = new WindowBuilder(windowSystem) - .WithTitle(" Command Palette ") - .WithColors(WorkbenchColors.Foreground, WorkbenchColors.Background) - .WithSize(CommandPaletteWidth, CommandPaletteHeight) - .Centered() - .AddControl(searchPrompt) - .AddControl(resultsTable) - .OnKeyPressed((_, e) => - { - if (e.KeyInfo.Key == ConsoleKey.Escape) - { - windowSystem.CloseWindow(paletteWindow, activateParent: true, force: false); - e.Handled = true; - return; - } - - if (e.KeyInfo.Key == ConsoleKey.Enter) - { - var selectedRow = resultsTable.SelectedRow; - if (selectedRow?.Tag is Action navigate) - { - windowSystem.CloseWindow(paletteWindow, activateParent: true, force: false); - navigate(); - } - - e.Handled = true; - } - }) - .Build(); - - windowSystem.AddWindow(paletteWindow, activateWindow: true); - } - - void FocusNavigation() - { - if (_window is null || _navigation?.NavView is null) - { - return; - } - - _window.FocusControl(_navigation.NavView); - } - - void FocusContent() - { - if (_window is null) return; - - var idx = _navigation?.CurrentViewIndex ?? -1; - if (idx >= 0 && idx < _views.Length && _views[idx].PrimaryFocusTarget is IInteractiveControl ic) - { - _window.FocusControl(ic); - } - else - { - // No specific focus target for this view — do nothing. - } - } - - void ToggleSidebar() - { - if (_navigation?.NavView is null) - { - return; - } - - _sidebarExpanded = !_sidebarExpanded; - _navigation.NavView.PaneDisplayMode = _sidebarExpanded - ? NavigationViewDisplayMode.Expanded - : NavigationViewDisplayMode.Compact; - } - - void ToggleDetailPane() - { - var idx = _navigation?.CurrentViewIndex ?? -1; - if (idx >= 0 && idx < _views.Length) - { - _views[idx].ToggleDetailPane(); - } - } - - void ActivateCurrentFilter() - { - var idx = _navigation?.CurrentViewIndex ?? -1; - if (idx < 0 || idx >= _views.Length) - { - return; - } - - var window = windowSystem.GetWindowAtPoint(new System.Drawing.Point(0, 0)); - if (window is null) - { - return; - } - - _views[idx].ActivateFilter(window); - } - - void CopyDetailToClipboard() - { - var idx = _navigation?.CurrentViewIndex ?? -1; - if (idx < 0 || idx >= _views.Length) - { - return; - } - - var content = _views[idx].DetailContent; - if (string.IsNullOrEmpty(content)) - { - return; - } - - var plain = Markup.Remove(content); - ClipboardHelper.SetText(plain); - UpdateStatusRight($"[{WorkbenchColors.Success.ToMarkup()}]✓ Copied[/]"); - _ = Task.Delay(2000).ContinueWith(_ => UpdateStatusBar(_currentData), TaskScheduler.Default); - } - - void SwitchToOverview() => _navigation?.NavigateTo(WorkbenchNavigation.IndexOverview); - - void OpenReadModelDetail() - { - if (_views[WorkbenchNavigation.IndexReadModels] is not ReadModelsView rmv) - { - return; - } - - rmv.OpenSelectedDetailOverlay(); - } - - void UpdateStatusRight(string text) - { - if (_statusBar is null) - { - return; - } - - _statusBar.ClearRight(); - _statusBar.AddRightText(text, null); - } } diff --git a/Source/Cli/Commands/Chronicle/Workbench/windows/ViewAction.cs b/Source/Cli/Commands/Chronicle/Workbench/windows/ViewAction.cs new file mode 100644 index 0000000..e1543f1 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/windows/ViewAction.cs @@ -0,0 +1,21 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Describes a single user-triggerable action that a view exposes. +/// The same record is consumed by the keyboard dispatcher, the right-click context menu, +/// and the Actions menu bar item — defining the action once eliminates duplication across all three. +/// +/// Display text shown in menus and the context menu. +/// Short key hint shown alongside the label (e.g. "R"). Display only — not handled here. +/// The that activates this action, or for menu-only actions. +/// Required modifier keys. Use for no modifiers. +/// The delegate invoked when the action is activated. +public record ViewAction( + string Label, + string? KeyHint, + ConsoleKey? TriggerKey, + ConsoleModifiers TriggerModifiers, + Action Execute); diff --git a/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchActionHandler.cs b/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchActionHandler.cs index a89db9f..2e11337 100644 --- a/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchActionHandler.cs +++ b/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchActionHandler.cs @@ -2,23 +2,18 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using SharpConsoleUI; +using SharpConsoleUI.Builders; +using SharpConsoleUI.Controls; namespace Cratis.Cli.Commands.Chronicle.Workbench; /// -/// Manages destructive-action confirmation for the workbench: queuing a pending action, waiting for -/// Y/N confirmation, and executing the action while streaming status messages back to the caller. +/// Shows centered confirmation dialogs for destructive workbench actions and executes them on confirmation. /// -/// Callback invoked to display a status message in the workbench status bar right segment. -public class WorkbenchActionHandler(Action updateStatus) +/// The SharpConsoleUI window system — used to open the confirmation modal. +/// Callback invoked to display progress / success / error messages in the top panel. +public class WorkbenchActionHandler(ConsoleWindowSystem windowSystem, Action updateStatus) { - (string Description, Func Execute)? _pendingAction; - - /// - /// Gets a value indicating whether there is a destructive action waiting for Y/N confirmation. - /// - public bool IsPendingAction => _pendingAction is not null; - /// /// Gets or sets a value indicating whether a text input control currently has keyboard focus. /// When , most shortcut keys are suppressed so the user can type freely. @@ -26,23 +21,17 @@ public class WorkbenchActionHandler(Action updateStatus) public bool TextInputFocused { get; set; } /// - /// Queues a destructive action for confirmation and shows a Y/N prompt in the status bar. + /// Opens a centered confirmation dialog for . + /// Executes when the user confirms; dismisses on cancel. /// /// Short human-readable description of the action. /// Async delegate that performs the action when confirmed. - public void ExecuteAction(string description, Func action) - { - _pendingAction = (description, action); - var war = WorkbenchColors.Warning.ToMarkup(); - var acc = WorkbenchColors.Accent.ToMarkup(); - var mut = WorkbenchColors.Muted.ToMarkup(); - updateStatus( - $"[{war}]⚡ {description}?[/] [bold {acc}][Y][/] [{mut}]Confirm[/] [bold {acc}][N][/] [{mut}]Cancel[/]"); - } + public void ExecuteAction(string description, Func action) => + ShowConfirmationDialog(description, action); /// - /// Queues a bulk action that iterates over and calls for each. - /// The action is queued with a Y/N confirmation prompt before anything is executed. + /// Opens a confirmation dialog for a bulk operation over . + /// Calls for each item in sequence when confirmed. /// /// The element type. /// Short human-readable description of the bulk action. @@ -59,57 +48,68 @@ public void ConfirmThenExecuteAll(string description, IReadOnlyList items, }); } - /// - /// Handles a key press while a pending action is queued. - /// Returns if the key was consumed (whether confirmed, cancelled, or a no-op - /// because a confirmation is already in progress). - /// - /// The key that was pressed. - /// Invoked after cancellation so the caller can refresh the status bar. - /// if the key was consumed by the pending-action handler. - public bool HandlePendingKeyPress(ConsoleKeyInfo keyInfo, Action onCancelled) + void ShowConfirmationDialog(string description, Func action) { - if (_pendingAction is null) - { - return false; - } + var mut = WorkbenchColors.Muted.ToMarkup(); + var warn = WorkbenchColors.Warning.ToMarkup(); + var acc = WorkbenchColors.Accent.ToMarkup(); - switch (keyInfo.Key) - { - case ConsoleKey.Y: - var pending = _pendingAction.Value; - _pendingAction = null; - RunPendingAction(pending.Description, pending.Execute); - return true; + var body = Controls.Markup() + .AddEmptyLine() + .AddLine($" [{warn}]⚡[/] {description}") + .AddEmptyLine() + .AddLine($" [{mut}]This action cannot be undone.[/]") + .AddEmptyLine() + .AddLine($" [{mut}]Press[/] [bold {acc}]Enter[/] [{mut}]or[/] [bold {acc}]Y[/] [{mut}]to confirm, or[/] [bold {acc}]Escape[/] [{mut}]/ [bold {acc}]N[/] [{mut}]to cancel.[/]") + .AddEmptyLine() + .Build(); + + Window? dialog = null; + dialog = new WindowBuilder(windowSystem) + .WithTitle(" Confirm Action ") + .WithColors(WorkbenchColors.Foreground, WorkbenchColors.Surface) + .WithSize(64, 10) + .Centered() + .AddControl(body) + .OnKeyPressed((_, e) => + { + switch (e.KeyInfo.Key) + { + case ConsoleKey.Enter: + case ConsoleKey.Y: + windowSystem.CloseWindow(dialog, activateParent: true, force: false); + RunAction(description, action); + e.Handled = true; + break; - case ConsoleKey.N: - case ConsoleKey.Escape: - _pendingAction = null; - onCancelled(); - return true; - } + case ConsoleKey.Escape: + case ConsoleKey.N: + windowSystem.CloseWindow(dialog, activateParent: true, force: false); + e.Handled = true; + break; + } + }) + .Build(); - // Any other key while a confirmation is pending — consume it. - return true; + windowSystem.AddWindow(dialog, activateWindow: true); } - void RunPendingAction(string description, Func action) + void RunAction(string description, Func action) { _ = Task.Run(async () => { try { - updateStatus($"[{WorkbenchColors.Warning.ToMarkup()}]⟳ {description}...[/]"); + updateStatus($"⟳ {description}…"); await action(); - updateStatus($"[{WorkbenchColors.Success.ToMarkup()}]✓ Done[/]"); - + updateStatus("✓ Done"); await Task.Delay(3000); updateStatus(string.Empty); } catch (Exception ex) { - var msg = ex.Message.Length > 60 ? ex.Message[..60] : ex.Message; - updateStatus($"[{WorkbenchColors.Danger.ToMarkup()}]✗ {msg}[/]"); + var msg = ex.Message.Length > 80 ? ex.Message[..80] : ex.Message; + updateStatus($"✗ {msg}"); } }); } diff --git a/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchKeyDispatcher.cs b/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchKeyDispatcher.cs new file mode 100644 index 0000000..ba7e0dd --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchKeyDispatcher.cs @@ -0,0 +1,318 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using SharpConsoleUI; +using SharpConsoleUI.Controls; +using SharpConsoleUI.Themes; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Translates raw key-press events from the main window into workbench actions. +/// +/// Wired to Window.KeyPressed (fires after the focused control processes each key). +/// All shortcuts live here because MenuControl only handles arrow keys, Enter, Escape, +/// Home and End — it does NOT intercept letters or Ctrl+letter combinations, so those always +/// bubble to this handler regardless of whether the menu bar currently has focus. +/// +/// +/// Left and Right arrows use an guard so the +/// NavigationView nav pane (collapse/expand headers) and menu bar (navigate items) can handle +/// them without our focus-switch logic interfering. +/// +/// +/// Navigation — used for view jumping, sidebar mode, and picker overlays. +/// All view instances — used for jumping, filter, and view-action dispatch. +/// Action handler — owns the text-input focus state flag. +/// The window system — used for theme switching. +/// Overlays — invoked for help, command palette, read model detail, and clipboard copy. +/// Workbench settings — used by the Quit action to persist the refresh interval. +/// Workbench state — used by the Quit action to persist the last active navigation index. +/// Returns the main , used for focus control targeting. +public class WorkbenchKeyDispatcher( + WorkbenchNavigation navigation, + IWorkbenchView[] views, + WorkbenchActionHandler actionHandler, + ConsoleWindowSystem windowSystem, + WorkbenchOverlays overlays, + WorkbenchSettings settings, + WorkbenchState state, + Func getWindow) +{ + bool _sidebarExpanded = true; + + /// + /// Dispatches a key press to the appropriate workbench action. + /// Wired to Window.KeyPressed which fires after the focused control has processed the key. + /// + /// The key-press event. + public void Dispatch(KeyPressedEventArgs e) + { + if (navigation.NavView is null) + { + return; + } + + // Suppress all shortcuts while the filter prompt is active so the user can type freely. + // Escape is the only exception — it exits filter mode. + if (actionHandler.TextInputFocused) + { + if (e.KeyInfo.Key == ConsoleKey.Escape) + { + var filterIdx = navigation.CurrentViewIndex; + if (filterIdx >= 0 && filterIdx < views.Length) + { + views[filterIdx].ClearFilter(); + } + + e.Handled = true; + } + + return; + } + + var idx = navigation.CurrentViewIndex; + + switch (e.KeyInfo.Key) + { + // Left/Right: only act when the focused control did NOT already handle them. + // The NavigationView nav pane handles Left (collapse header) and the menu bar handles + // Left/Right (navigate items). We skip FocusNavigation/FocusContent in those cases. + case ConsoleKey.LeftArrow: + if (!e.AlreadyHandled) + { + FocusNavigation(); + e.Handled = true; + } + + break; + + case ConsoleKey.RightArrow: + if (!e.AlreadyHandled) + { + FocusContent(); + e.Handled = true; + } + + break; + + case ConsoleKey.B when e.KeyInfo.Modifiers.HasFlag(ConsoleModifiers.Control): + ToggleSidebar(); + e.Handled = true; + break; + + case ConsoleKey.Oem5 when e.KeyInfo.Modifiers.HasFlag(ConsoleModifiers.Control): + ToggleDetailPane(); + e.Handled = true; + break; + + case ConsoleKey.E when e.KeyInfo.Modifiers.HasFlag(ConsoleModifiers.Control): + navigation.OpenEventStorePicker(); + e.Handled = true; + break; + + case ConsoleKey.N when e.KeyInfo.Modifiers.HasFlag(ConsoleModifiers.Control): + navigation.OpenNamespacePicker(); + e.Handled = true; + break; + + case ConsoleKey.C when e.KeyInfo.Modifiers.HasFlag(ConsoleModifiers.Control): + overlays.CopyDetailToClipboard(); + e.Handled = true; + break; + + case ConsoleKey.Enter when idx == WorkbenchNavigation.IndexReadModels: + overlays.OpenReadModelDetail(); + e.Handled = true; + break; + + case ConsoleKey.Oem2 when e.KeyInfo.Modifiers == ConsoleModifiers.Shift: + if (!e.AlreadyHandled) + { + overlays.OpenHelpOverlay(); + e.Handled = true; + } + + break; + + case ConsoleKey.P when e.KeyInfo.Modifiers.HasFlag(ConsoleModifiers.Control): + overlays.OpenCommandPalette(); + e.Handled = true; + break; + + // Theme switching + case ConsoleKey.F9: + ApplyTheme(new ModernGrayTheme()); + e.Handled = true; + break; + + case ConsoleKey.F10: + ApplyTheme(new ClassicTheme()); + e.Handled = true; + break; + + case ConsoleKey.F11: + ApplyThemeByName("SharpConsoleUI.Plugins.DeveloperTools.DevDarkTheme, SharpConsoleUI"); + e.Handled = true; + break; + + // Page navigation + case ConsoleKey.Oem6: // ] + if (idx >= 0 && idx < views.Length) views[idx].NextPage(); + e.Handled = true; + break; + + case ConsoleKey.Oem4: // [ + if (idx >= 0 && idx < views.Length) views[idx].PreviousPage(); + e.Handled = true; + break; + + // Row jumping — guarded so Shift+G and Home in the filter prompt aren't intercepted. + case ConsoleKey.G when e.KeyInfo.Modifiers.HasFlag(ConsoleModifiers.Shift): + if (!e.AlreadyHandled) + { + if (idx >= 0 && idx < views.Length) views[idx].JumpToLastRow(); + e.Handled = true; + } + + break; + + case ConsoleKey.Home: + if (!e.AlreadyHandled) + { + if (idx >= 0 && idx < views.Length) views[idx].JumpToFirstRow(); + e.Handled = true; + } + + break; + + // Single-character shortcuts: only act when the focused control did NOT already consume + // the key. When the filter prompt is active and the user types 'f', 'q', 'v', etc., + // the prompt's ProcessKey returns true (alreadyHandled = true) and we skip these cases, + // so the character is inserted into the filter instead of triggering a shortcut. + case ConsoleKey.F: + if (!e.AlreadyHandled) + { + ActivateCurrentFilter(); + e.Handled = true; + } + + break; + + case ConsoleKey.Q: + if (!e.AlreadyHandled) + { + state.Interval = settings.Interval; + state.LastNavIndex = navigation.CurrentViewIndex; + state.Save(); + Environment.Exit(0); + } + + break; + + default: + // OCP: view-scoped action keys (R, V, D, T, P, S, U, A, I, etc.) + // Also guarded by AlreadyHandled — when the filter prompt is active, + // it consumes every printable key, so these action shortcuts are naturally + // suppressed without needing any separate TextInputFocused flag. + if (!e.AlreadyHandled && DispatchCurrentViewAction(e.KeyInfo.Key, e.KeyInfo.Modifiers)) + { + e.Handled = true; + } + + break; + } + } + + void FocusNavigation() + { + var window = getWindow(); + if (window is null || navigation.NavView is null) + { + return; + } + + window.FocusControl(navigation.NavView); + } + + void FocusContent() + { + var window = getWindow(); + if (window is null) + { + return; + } + + var idx = navigation.CurrentViewIndex; + if (idx >= 0 && idx < views.Length && views[idx].PrimaryFocusTarget is IInteractiveControl ic) + { + window.FocusControl(ic); + } + } + + void ToggleSidebar() + { + if (navigation.NavView is null) + { + return; + } + + _sidebarExpanded = !_sidebarExpanded; + navigation.NavView.PaneDisplayMode = _sidebarExpanded + ? NavigationViewDisplayMode.Expanded + : NavigationViewDisplayMode.Compact; + } + + void ToggleDetailPane() + { + var idx = navigation.CurrentViewIndex; + if (idx >= 0 && idx < views.Length) + { + views[idx].ToggleDetailPane(); + } + } + + void ActivateCurrentFilter() + { + var idx = navigation.CurrentViewIndex; + var window = getWindow(); + if (idx < 0 || idx >= views.Length || window is null) + { + return; + } + + views[idx].ActivateFilter(window); + } + + bool DispatchCurrentViewAction(ConsoleKey key, ConsoleModifiers modifiers) + { + var idx = navigation.CurrentViewIndex; + if (idx < 0 || idx >= views.Length) + { + return false; + } + + var match = views[idx].ViewActions.FirstOrDefault( + a => a.TriggerKey == key && a.TriggerModifiers == modifiers); + + if (match is null) + { + return false; + } + + match.Execute(); + return true; + } + + void ApplyTheme(ITheme theme) => + windowSystem.ThemeStateService.SetTheme(theme); + + void ApplyThemeByName(string typeName) + { + var type = Type.GetType(typeName); + if (type is not null && Activator.CreateInstance(type) is ITheme theme) + { + ApplyTheme(theme); + } + } +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchMenuBar.cs b/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchMenuBar.cs new file mode 100644 index 0000000..c42c9c0 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchMenuBar.cs @@ -0,0 +1,70 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using SharpConsoleUI; +using SharpConsoleUI.Builders; +using SharpConsoleUI.Controls; +using SharpConsoleUI.Themes; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Builds the horizontal sticky menu bar displayed at the top of the workbench window. +/// +/// Navigation — wired to Ctrl+E / Ctrl+N quick-switch actions. +/// Overlays — wired to Help menu items. +/// The window system — used for theme switching. +/// Workbench settings — used by the Quit action to persist the refresh interval. +/// Workbench state — used by the Quit action to persist the last active navigation index. +public class WorkbenchMenuBar( + WorkbenchNavigation navigation, + WorkbenchOverlays overlays, + ConsoleWindowSystem windowSystem, + WorkbenchSettings settings, + WorkbenchState state) +{ + /// + /// Builds and returns the configured horizontal sticky . + /// + /// The fully wired menu bar control. + public MenuControl Build() + { + var menu = Controls.Menu() + .Horizontal() + .Sticky() + .WithName("WorkbenchMenuBar") + .AddItem("File", m => m + .AddItem("Switch Event Store", "Ctrl+E", () => navigation.OpenEventStorePicker()) + .AddItem("Switch Namespace", "Ctrl+N", () => navigation.OpenNamespacePicker()) + .AddSeparator() + .AddItem("Quit", "Q", () => + { + state.Interval = settings.Interval; + state.LastNavIndex = navigation.CurrentViewIndex; + state.Save(); + Environment.Exit(0); + })) + .AddItem("Help", m => m + .AddItem("Keyboard Shortcuts", "?", () => overlays.OpenHelpOverlay()) + .AddSeparator() + .AddItem("Theme: Modern Gray", "F9", () => ApplyTheme(new ModernGrayTheme())) + .AddItem("Theme: Classic", "F10", () => ApplyTheme(new ClassicTheme())) + .AddItem("Theme: Dev Dark", "F11", () => ApplyThemeByName("SharpConsoleUI.Plugins.DeveloperTools.DevDarkTheme, SharpConsoleUI"))) + .Build(); + + menu.StickyPosition = StickyPosition.Top; + return menu; + } + + void ApplyTheme(ITheme theme) => + windowSystem.ThemeStateService.SetTheme(theme); + + void ApplyThemeByName(string typeName) + { + var type = Type.GetType(typeName); + if (type is not null && Activator.CreateInstance(type) is ITheme theme) + { + ApplyTheme(theme); + } + } +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchNavigation.cs b/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchNavigation.cs index aec8905..d894be0 100644 --- a/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchNavigation.cs +++ b/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchNavigation.cs @@ -10,9 +10,10 @@ namespace Cratis.Cli.Commands.Chronicle.Workbench; /// /// Builds and manages the navigation side pane, badge counts, and the event store / namespace picker overlays. +/// All navigation items are driven by — add a view there and it appears here automatically. /// /// The SharpConsoleUI window system. -/// The ordered array of instances, one per navigation item. +/// The ordered array of instances — must match order. /// Workbench settings — used to resolve the active event store and namespace. /// Returns the currently active event store name, or for the default. /// Returns the currently active namespace name, or for the default. @@ -31,47 +32,49 @@ public class WorkbenchNavigation( Action onDataNeeded, Func getLatestData) { - /// Navigation item index for Overview. - public const int IndexOverview = 0; + // ── View index constants ─────────────────────────────────────────────────── + // Derived from WorkbenchViewRegistry — the registry position IS the index. + // Never hardcode these values; never pass them to NavigationView.SelectedIndex directly. + // Always navigate via NavigateTo(IndexXxx) which converts to the header-inclusive index. - /// Navigation item index for Observers. - public const int IndexObservers = 1; + /// View index for Overview. + public static readonly int IndexOverview = WorkbenchViewRegistry.IndexOf(); - /// Navigation item index for Failures. - public const int IndexFailures = 2; + /// View index for Observers. + public static readonly int IndexObservers = WorkbenchViewRegistry.IndexOf(); - /// Navigation item index for Jobs. - public const int IndexJobs = 3; + /// View index for Failures. + public static readonly int IndexFailures = WorkbenchViewRegistry.IndexOf(); - /// Navigation item index for Recommendations. - public const int IndexRecommendations = 4; + /// View index for Jobs. + public static readonly int IndexJobs = WorkbenchViewRegistry.IndexOf(); - /// Navigation item index for Event Sequences. - public const int IndexEventSequences = 5; + /// View index for Recommendations. + public static readonly int IndexRecommendations = WorkbenchViewRegistry.IndexOf(); - /// Navigation item index for Event Types. - public const int IndexEventTypes = 6; + /// View index for Event Sequences. + public static readonly int IndexEventSequences = WorkbenchViewRegistry.IndexOf(); - /// Navigation item index for Projections. - public const int IndexProjections = 7; + /// View index for Event Types. + public static readonly int IndexEventTypes = WorkbenchViewRegistry.IndexOf(); - /// Navigation item index for Read Models. - public const int IndexReadModels = 8; + /// View index for Projections. + public static readonly int IndexProjections = WorkbenchViewRegistry.IndexOf(); - /// Navigation item index for Event Stores. - public const int IndexEventStores = 9; + /// View index for Read Models. + public static readonly int IndexReadModels = WorkbenchViewRegistry.IndexOf(); - /// Navigation item index for Namespaces. - public const int IndexNamespaces = 10; + /// View index for Event Stores. + public static readonly int IndexEventStores = WorkbenchViewRegistry.IndexOf(); - /// Width of the picker overlay window in columns. - const int PickerOverlayWidth = 54; + /// View index for Namespaces. + public static readonly int IndexNamespaces = WorkbenchViewRegistry.IndexOf(); - /// Maximum height of the picker overlay window in rows. + const int PickerOverlayWidth = 54; const int MaxPickerOverlayHeight = 24; - - /// Extra rows added to item count to account for picker window chrome (borders, title, padding). const int PickerOverlayHeightPadding = 6; + const int NavExpandedThreshold = 90; + const int NavCompactThreshold = 40; NavigationItem? _observersItem; NavigationItem? _failuresItem; @@ -79,90 +82,72 @@ public class WorkbenchNavigation( NavigationView? _navView; int _currentViewIndex; - /// - /// Gets the built control. - /// Only available after has been called. - /// + /// Gets the built control. Only available after has been called. public NavigationView? NavView => _navView; /// - /// Gets the zero-based item index of the currently active view, sourced from - /// OnSelectedItemChanged's NewIndex — the same index used to activate views - /// and guaranteed to match the IndexXxx constants regardless of how - /// counts headers internally. + /// Gets the zero-based item-only index of the currently active view. + /// Excludes header entries, aligns with IndexXxx constants, and can be used directly to index into views[]. /// public int CurrentViewIndex => _currentViewIndex; - /// - /// Gets the Observers navigation item (used to set badge counts). - /// Only available after has been called. - /// + /// Gets the Observers navigation item (used to set badge counts). Only available after . public NavigationItem? ObserversItem => _observersItem; - /// - /// Gets the Failures navigation item (used to set badge counts). - /// Only available after has been called. - /// + /// Gets the Failures navigation item (used to set badge counts). Only available after . public NavigationItem? FailuresItem => _failuresItem; - /// - /// Gets the Recommendations navigation item (used to set badge counts). - /// Only available after has been called. - /// + /// Gets the Recommendations navigation item (used to set badge counts). Only available after . public NavigationItem? RecommendationsItem => _recommendationsItem; /// - /// Builds the navigation view with all headers and items, wires the selection-changed callback, - /// and captures the badge item references. + /// Builds the navigation view from — headers and items are derived automatically. + /// Wires the selection-changed callback and captures the badge item references. /// /// The fully configured . public NavigationView BuildNavigationView() { - var selectedBg = new SharpConsoleUI.Color(49, 50, 68, 255); - var selectedFg = WorkbenchColors.Accent; - - var navView = Controls.NavigationView() - .WithNavWidth(26) - .WithPaneHeader($"[bold {WorkbenchColors.Accent.ToMarkup()}] CHRONICLE [/]") - .WithSelectedColors(selectedFg, selectedBg) - .WithPaneDisplayMode(NavigationViewDisplayMode.Expanded) + // Declare navView before the builder chain so the lambda can capture it. + // It will be null when OnSelectedItemChanged fires during the initial build-time + // auto-selection; the guard below handles that case gracefully. + NavigationView? navView = null; + + navView = Controls.NavigationView() + .WithNavWidth(28) + .WithPaneHeader($"[bold {WorkbenchColors.Accent.ToMarkup()}] ◆ CHRONICLE[/]") + .WithSelectedColors(new SharpConsoleUI.Color(255, 255, 255, 255), WorkbenchColors.SelectedBg) + .WithContentBorder(BorderStyle.Rounded) + .WithContentBorderColor(WorkbenchColors.ContentBorder) + .WithContentBackground(WorkbenchColors.Surface) + .WithContentPadding(1, 0, 1, 0) + .WithPaneDisplayMode(NavigationViewDisplayMode.Auto) + .WithExpandedThreshold(NavExpandedThreshold) + .WithCompactThreshold(NavCompactThreshold) .WithName("MainNav") .Fill() - .AddHeader("OVERVIEW", h => - h.AddItem("Overview", "◆", null, panel => panel.AddControl(views[0].BuildContent(windowSystem)))) - .AddHeader("OBSERVATION", h => - h.AddItem("Observers", "o", null, panel => panel.AddControl(views[1].BuildContent(windowSystem))) - .AddItem("Failures", "!", null, panel => panel.AddControl(views[2].BuildContent(windowSystem))) - .AddItem("Jobs", "~", null, panel => panel.AddControl(views[3].BuildContent(windowSystem))) - .AddItem("Recommendations", "*", null, panel => panel.AddControl(views[4].BuildContent(windowSystem)))) - .AddHeader("EVENTS", h => - h.AddItem("Event Sequences", "-", null, panel => panel.AddControl(views[5].BuildContent(windowSystem))) - .AddItem("Event Types", "#", null, panel => panel.AddControl(views[6].BuildContent(windowSystem)))) - .AddHeader("PROJECTIONS", h => - h.AddItem("Projections", ">", null, panel => panel.AddControl(views[7].BuildContent(windowSystem))) - .AddItem("Read Models", "=", null, panel => panel.AddControl(views[8].BuildContent(windowSystem)))) - .AddHeader("SERVER", h => - h.AddItem("Event Stores", "+", null, panel => panel.AddControl(views[9].BuildContent(windowSystem))) - .AddItem("Namespaces", "@", null, panel => panel.AddControl(views[10].BuildContent(windowSystem))) - .AddItem("Applications", "A", null, panel => panel.AddControl(views[11].BuildContent(windowSystem))) - .AddItem("Users", "U", null, panel => panel.AddControl(views[12].BuildContent(windowSystem))) - .AddItem("Identities", "I", null, panel => panel.AddControl(views[13].BuildContent(windowSystem))) - .AddItem("Subscriptions", "S", null, panel => panel.AddControl(views[14].BuildContent(windowSystem)))) .OnSelectedItemChanged((_, e) => { - // Deactivate the previous view so background refreshes resume rebuilding it. - if (e.OldIndex >= 0 && e.OldIndex < views.Length) - views[e.OldIndex].IsActive = false; + // navView may be null while Build() is still executing (first auto-selection). + // In that case _currentViewIndex stays at its default of 0 (Overview), which is correct. + if (navView is null) + { + return; + } - var idx = e.NewIndex; - _currentViewIndex = idx; + var oldViewIdx = ToViewIndex(navView, e.OldIndex); + if (oldViewIdx >= 0 && oldViewIdx < views.Length) + { + views[oldViewIdx].IsActive = false; + } + + var idx = ToViewIndex(navView, e.NewIndex); + _currentViewIndex = idx >= 0 ? idx : 0; if (idx < 0 || idx >= views.Length) { return; } - // Push latest data to the newly selected view (IsActive still false → will rebuild). var snapshot = getLatestData(); if (snapshot is not null) { @@ -173,24 +158,48 @@ public NavigationView BuildNavigationView() onDataNeeded(); } - // Mark as active AFTER the rebuild so future interval refreshes preserve state. views[idx].IsActive = true; }) .Build(); - var items = navView.Items; - _observersItem = FindItemByText(items, "Observers"); - _failuresItem = FindItemByText(items, "Failures"); - _recommendationsItem = FindItemByText(items, "Recommendations"); + // Add headers and items driven entirely by the registry. + // Adding a new view to WorkbenchViewRegistry.All automatically adds it here. + WorkbenchSection? lastSection = null; + NavigationItem? currentHeader = null; + + for (var i = 0; i < WorkbenchViewRegistry.All.Count; i++) + { + var def = WorkbenchViewRegistry.All[i]; + var viewIndex = i; + + if (!ReferenceEquals(def.Section, lastSection)) + { + currentHeader = navView!.AddHeader(def.Section.Title, def.Section.Color); + lastSection = def.Section; + } + + var navItem = navView!.AddItemToHeader(currentHeader!, def.NavText, def.NavIcon, def.NavSubtitle); + navView.SetItemContent(navItem, panel => panel.AddControl(views[viewIndex].BuildContent(windowSystem))); + } + + var allItems = navView!.Items; + _observersItem = FindItemByText(allItems, WorkbenchViewRegistry.All[IndexObservers].NavText); + _failuresItem = FindItemByText(allItems, WorkbenchViewRegistry.All[IndexFailures].NavText); + _recommendationsItem = FindItemByText(allItems, WorkbenchViewRegistry.All[IndexRecommendations].NavText); _navView = navView; + + // Guard: registry count must equal views.Length — they're both built from the same registry. + var nonHeaderCount = allItems.Count(i => i.ItemType != NavigationItemType.Header); + System.Diagnostics.Debug.Assert( + nonHeaderCount == views.Length, + $"Nav has {nonHeaderCount} selectable items but _views has {views.Length}. Both must match {nameof(WorkbenchViewRegistry)}."); + return navView; } - /// - /// Navigates to the specified view by index. No-op when the index is out of range. - /// - /// Zero-based index of the target view (use IndexXxx constants). + /// Navigates to the specified view by index. No-op when the index is out of range. + /// Zero-based item-only view index (use IndexXxx constants). public void NavigateTo(int viewIndex) { if (_navView is null || viewIndex < 0 || viewIndex >= views.Length) @@ -198,13 +207,14 @@ public void NavigateTo(int viewIndex) return; } - _navView.SelectedIndex = viewIndex; + var navIndex = ToNavIndex(_navView, viewIndex); + if (navIndex >= 0) + { + _navView.SelectedIndex = navIndex; + } } - /// - /// Updates the badge subtitles on the Observers, Failures, and Recommendations navigation items - /// to reflect the latest counts from . - /// + /// Updates the badge subtitles on the Observers, Failures, and Recommendations nav items. /// The latest workbench data snapshot. public void UpdateNavBadges(WorkbenchData data) { @@ -232,10 +242,7 @@ public void UpdateNavBadges(WorkbenchData data) _navView?.Invalidate(); } - /// - /// Opens a modal picker overlay that lets the user select a different event store. - /// Calls the store-switch callback when a selection is confirmed. - /// + /// Opens a modal picker that lets the user select a different event store. public void OpenEventStorePicker() { var snapshot = getLatestData(); @@ -253,10 +260,7 @@ [.. snapshot.EventStoreNames.Order()], onStoreSwitch); } - /// - /// Opens a modal picker overlay that lets the user select a different namespace. - /// Calls the namespace-switch callback when a selection is confirmed. - /// + /// Opens a modal picker that lets the user select a different namespace. public void OpenNamespacePicker() { var snapshot = getLatestData(); @@ -274,6 +278,72 @@ [.. snapshot.NamespaceNames.Order()], onNamespaceSwitch); } + /// + /// Converts a header-inclusive NavigationView item index to a zero-based view-only index. + /// Takes the nav view directly so it works inside lambdas before _navView is assigned. + /// Returns -1 for header entries or out-of-range indices. + /// + /// The NavigationView to query. + /// The header-inclusive index from the NavigationView. + /// The zero-based view-only index, or -1 if not applicable. + static int ToViewIndex(NavigationView nav, int navIndex) + { + if (navIndex < 0) + { + return -1; + } + + var items = nav.Items; + if (navIndex >= items.Count || items[navIndex].ItemType == NavigationItemType.Header) + { + return -1; + } + + var viewIdx = 0; + for (var i = 0; i < navIndex; i++) + { + if (items[i].ItemType != NavigationItemType.Header) + { + viewIdx++; + } + } + + return viewIdx; + } + + /// + /// Converts a zero-based view-only index to the header-inclusive NavigationView item index + /// required by . + /// Returns -1 when the view index is not found. + /// + /// The NavigationView to query. + /// The zero-based view-only index. + /// The header-inclusive NavigationView index, or -1 if not found. + static int ToNavIndex(NavigationView nav, int viewIndex) + { + if (viewIndex < 0) + { + return -1; + } + + var items = nav.Items; + var itemCount = 0; + for (var navIdx = 0; navIdx < items.Count; navIdx++) + { + if (items[navIdx].ItemType != NavigationItemType.Header) + { + if (itemCount == viewIndex) + { + return navIdx; + } + + itemCount++; + } + } + + return -1; + } + static NavigationItem? FindItemByText(IReadOnlyList items, string text) { foreach (var item in items) @@ -287,17 +357,6 @@ [.. snapshot.NamespaceNames.Order()], return null; } - /// - /// Opens a modal, keyboard-navigable picker overlay presenting a list of strings. - /// Highlights the currently active item with an arrow indicator; calls - /// with the chosen string when a row is activated or Enter is pressed. - /// - /// The window title shown in the overlay border. - /// The header text for the single picker column. - /// The SharpConsoleUI control name for the picker table (used for test/automation). - /// The ordered list of choices to display. - /// The item that is currently selected; shown with a ► prefix. - /// Invoked with the chosen item name when the user confirms a selection. void ShowStringPickerOverlay( string title, string columnHeader, diff --git a/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchOverlays.cs b/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchOverlays.cs new file mode 100644 index 0000000..3c58471 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchOverlays.cs @@ -0,0 +1,535 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using SharpConsoleUI; +using SharpConsoleUI.Builders; +using SharpConsoleUI.Controls; +using SharpConsoleUI.Helpers; +using UITableRow = SharpConsoleUI.Controls.TableRow; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Opens and manages modal popup windows: keyboard-shortcuts help, command palette, and read model detail. +/// Also owns clipboard copy, which is triggered from both the keyboard and the menu bar. +/// +/// The SharpConsoleUI window system. +/// All view instances — used to read current-view help and selected items. +/// Navigation — provides the current view index and navigate methods. +/// Action handler — owns the TextInputFocused flag used by the command palette prompt. +/// Refresh loop — provides the latest data snapshot and temporary panel messages. +public class WorkbenchOverlays( + ConsoleWindowSystem windowSystem, + IWorkbenchView[] views, + WorkbenchNavigation navigation, + WorkbenchActionHandler actionHandler, + WorkbenchRefreshLoop refreshLoop) +{ + const int HelpOverlayWidth = 72; + const int HelpOverlayHeight = 40; + const int CommandPaletteWidth = 80; + const int CommandPaletteHeight = 18; + const int MaxCommandPaletteResults = 10; + + /// + /// Opens the keyboard-shortcuts help overlay. Shows a view-specific section at the top when the active + /// view exposes text. + /// + public void OpenHelpOverlay() + { + var mut = WorkbenchColors.Muted.ToMarkup(); + var acc = WorkbenchColors.Accent.ToMarkup(); + + var activeIdx = navigation.CurrentViewIndex; + var currentViewHelp = string.Empty; + if (activeIdx >= 0 && activeIdx < views.Length && !string.IsNullOrEmpty(views[activeIdx].ViewHelp)) + { + currentViewHelp = + $"[bold {acc}]THIS VIEW[/]\n" + + string.Join('\n', views[activeIdx].ViewHelp.Split('\n').Select(l => $" {l}")) + + "\n\n"; + } + + var helpText = + currentViewHelp + + $"[bold {acc}]NAVIGATION[/]\n" + + $" [{mut}]↑ ↓[/] Move selection up / down\n" + + $" [{mut}]← / →[/] Focus sidebar / content pane\n" + + $" [{mut}]Home / Shift+G[/] Jump to first / last row\n" + + $" [{mut}]Ctrl+B[/] Toggle sidebar expand / compact\n" + + $" [{mut}]Ctrl+\\[/] Toggle detail pane\n" + + $" [{mut}]Enter[/] Open detail overlay\n" + + $" [{mut}]Esc[/] Close overlay\n" + + "\n" + + $"[bold {acc}]QUICK SWITCH[/]\n" + + $" [{mut}]Ctrl+E[/] Switch event store\n" + + $" [{mut}]Ctrl+N[/] Switch namespace\n" + + "\n" + + $"[bold {acc}]FILTER & SEARCH[/]\n" + + $" [{mut}]F[/] Focus filter prompt for current view\n" + + $" [{mut}]Ctrl+P[/] Open command palette\n" + + $" [{mut}]Escape[/] Clear filter and return focus to table\n" + + "\n" + + $"[bold {acc}]VIEW ACTIONS (when row selected)[/]\n" + + $" [{mut}]?[/] See this screen — actions vary per view\n" + + $" [{mut}]Y / N[/] Confirm / Cancel pending action\n" + + "\n" + + $"[bold {acc}]CLIPBOARD[/]\n" + + $" [{mut}]Ctrl+C[/] Copy detail pane content to clipboard\n" + + "\n" + + $"[bold {acc}]THEMES[/]\n" + + $" [{mut}]F9 / F10 / F11[/] Modern Gray / Classic / Dev Dark theme\n" + + "\n" + + $"[bold {acc}]GENERAL[/]\n" + + $" [{mut}]+ / -[/] Increase / decrease refresh interval\n" + + $" [{mut}]?[/] This help screen\n" + + $" [{mut}]Q[/] Quit"; + + var markup = new MarkupControl([helpText]) { Wrap = true }; + var content = Controls.ScrollablePanel() + .AddControl(markup) + .WithVerticalScroll(ScrollMode.Scroll) + .WithPadding(2, 1, 2, 1) + .Build(); + + Window? helpWindow = null; + helpWindow = new WindowBuilder(windowSystem) + .WithTitle(" Keyboard Shortcuts ") + .WithColors(WorkbenchColors.Foreground, WorkbenchColors.Background) + .WithSize(HelpOverlayWidth, HelpOverlayHeight) + .Centered() + .AddControl(content) + .OnKeyPressed((_, e) => + { + if (e.KeyInfo.Key == ConsoleKey.Escape || + (e.KeyInfo.Key == ConsoleKey.Oem2 && e.KeyInfo.Modifiers == ConsoleModifiers.Shift)) + { + windowSystem.CloseWindow(helpWindow, activateParent: true, force: false); + } + + e.Handled = true; + }) + .Build(); + + windowSystem.AddWindow(helpWindow, activateWindow: true); + } + + /// + /// Opens the command palette overlay for searching observers, event types, projections, read models, and failures. + /// + public void OpenCommandPalette() + { + var snapshot = refreshLoop.CurrentData; + if (snapshot is null) + { + return; + } + + var acc = WorkbenchColors.Accent.ToMarkup(); + + var resultsTable = Controls.Table() + .AddColumn("Kind", SharpConsoleUI.Layout.TextJustification.Left, 20) + .AddColumn("Name", SharpConsoleUI.Layout.TextJustification.Left, null) + .Interactive() + .WithVerticalScrollbar(ScrollbarVisibility.Auto) + .WithName("CommandPaletteResults") + .Build(); + + var searchPrompt = Controls.Prompt($"[{acc}]>[/] ") + .WithName("CommandPaletteSearch") + .OnGotFocus((_, _) => actionHandler.TextInputFocused = true) + .OnLostFocus((_, _) => actionHandler.TextInputFocused = false) + .Build(); + + void PopulateResults(string query) + { + resultsTable.ClearRows(); + + if (string.IsNullOrWhiteSpace(query)) + { + return; + } + + var matches = new List<(string Kind, string Label, Action Navigate)>(); + + foreach (var obs in snapshot.Observers) + { + if (obs.Id.Contains(query, StringComparison.OrdinalIgnoreCase)) + { + var obsId = obs.Id; + matches.Add(("Observer", $"{obs.Id} [{obs.RunningState}]", () => + { + navigation.NavigateTo(WorkbenchNavigation.IndexObservers); + NavigateAndFilter(WorkbenchNavigation.IndexObservers, obsId); + })); + } + } + + foreach (var et in snapshot.EventTypeRegistrations) + { + if (et.Type.Id.Contains(query, StringComparison.OrdinalIgnoreCase)) + { + var typeId = et.Type.Id; + matches.Add(("Event Type", $"{et.Type.Id} gen {et.Type.Generation}", () => + { + navigation.NavigateTo(WorkbenchNavigation.IndexEventTypes); + NavigateAndFilter(WorkbenchNavigation.IndexEventTypes, typeId); + })); + } + } + + foreach (var pd in snapshot.ProjectionDefinitions) + { + if (pd.Identifier.Contains(query, StringComparison.OrdinalIgnoreCase)) + { + var id = pd.Identifier; + matches.Add(("Projection", pd.Identifier, () => + { + navigation.NavigateTo(WorkbenchNavigation.IndexProjections); + NavigateAndFilter(WorkbenchNavigation.IndexProjections, id); + })); + } + } + + foreach (var rm in snapshot.ReadModelDefinitions) + { + if (rm.ContainerName.Contains(query, StringComparison.OrdinalIgnoreCase) || + rm.DisplayName.Contains(query, StringComparison.OrdinalIgnoreCase)) + { + var label = rm.DisplayName.Length > 0 ? rm.DisplayName : rm.ContainerName; + matches.Add(("Read Model", label, () => + { + navigation.NavigateTo(WorkbenchNavigation.IndexReadModels); + NavigateAndFilter(WorkbenchNavigation.IndexReadModels, label); + })); + } + } + + foreach (var fp in snapshot.FailedPartitions) + { + if (fp.ObserverId.Contains(query, StringComparison.OrdinalIgnoreCase) || + fp.Partition.Contains(query, StringComparison.OrdinalIgnoreCase)) + { + var obsId = fp.ObserverId; + matches.Add(("Failure", $"{fp.ObserverId}/{fp.Partition}", () => + { + navigation.NavigateTo(WorkbenchNavigation.IndexFailures); + NavigateAndFilter(WorkbenchNavigation.IndexFailures, obsId); + })); + } + } + + foreach (var (kind, label, navigate) in matches.Take(MaxCommandPaletteResults)) + { + resultsTable.AddRow(new UITableRow([kind, label]) { Tag = navigate }); + } + } + + searchPrompt.InputChanged += (_, text) => PopulateResults(text ?? string.Empty); + + Window? paletteWindow = null; + paletteWindow = new WindowBuilder(windowSystem) + .WithTitle(" Command Palette ") + .WithColors(WorkbenchColors.Foreground, WorkbenchColors.Background) + .WithSize(CommandPaletteWidth, CommandPaletteHeight) + .Centered() + .AddControl(searchPrompt) + .AddControl(resultsTable) + .OnKeyPressed((_, e) => + { + if (e.KeyInfo.Key == ConsoleKey.Escape) + { + windowSystem.CloseWindow(paletteWindow, activateParent: true, force: false); + e.Handled = true; + return; + } + + if (e.KeyInfo.Key == ConsoleKey.Enter) + { + var selectedRow = resultsTable.SelectedRow; + if (selectedRow?.Tag is Action navigate) + { + windowSystem.CloseWindow(paletteWindow, activateParent: true, force: false); + navigate(); + } + + e.Handled = true; + } + }) + .Build(); + + windowSystem.AddWindow(paletteWindow, activateWindow: true); + } + + /// + /// Opens the detail overlay for the currently selected read model entry. + /// No-op when the Read Models view is not active or nothing is selected. + /// + public void OpenReadModelDetail() + { + if (views[WorkbenchNavigation.IndexReadModels] is ReadModelsView rmv) + { + rmv.OpenSelectedDetailOverlay(); + } + } + + /// + /// Copies the detail pane content of the active view to the system clipboard. + /// Shows a brief confirmation message in the top panel. + /// No-op when no content is available. + /// + public void CopyDetailToClipboard() + { + var idx = navigation.CurrentViewIndex; + if (idx < 0 || idx >= views.Length) + { + return; + } + + var content = views[idx].DetailContent; + if (string.IsNullOrEmpty(content)) + { + return; + } + + ClipboardHelper.SetText(Markup.Remove(content)); + refreshLoop.ShowTemporaryMessage("✓ Copied to clipboard"); + } + + /// + /// Opens a modal overlay listing all that subscribe to + /// . The user can inspect each observer's detail, press Enter to + /// navigate directly to it in the Observers view, or Escape to close. + /// + /// The event type identifier — shown in the window title. + /// Pre-filtered list of observers subscribed to this event type. + /// Invoked with the selected observer when the user confirms navigation. + public void OpenObserversForEventType( + string eventTypeId, + IReadOnlyList matchingObservers, + Action navigateToObserver) + { + var mut = WorkbenchColors.Muted.ToMarkup(); + var acc = WorkbenchColors.Accent.ToMarkup(); + var warn = WorkbenchColors.Warning.ToMarkup(); + + var table = Controls.Table() + .AddColumn("State", SharpConsoleUI.Layout.TextJustification.Left, 18) + .AddColumn("Observer", SharpConsoleUI.Layout.TextJustification.Left, null) + .AddColumn("Type", SharpConsoleUI.Layout.TextJustification.Left, 14) + .Interactive() + .WithVerticalScrollbar(ScrollbarVisibility.Auto) + .WithName("ObserversForEventTypeTable") + .Build(); + + var detailPanel = Controls.Panel() + .WithContent($"[{mut}]Select an observer.[/]") + .WithHeader(" OBSERVER ") + .Rounded() + .WithBorderColor(WorkbenchColors.Warning) + .WithPadding(1, 0, 1, 0) + .FillVertical() + .WithName("ObserversForEventTypeDetail") + .Build(); + + foreach (var obs in matchingObservers.OrderBy(ObserverSortOrder).ThenBy(o => o.Id)) + { + var icon = ObserverIcon(obs); + var color = ObserverStateColor(obs); + table.AddRow(new UITableRow([$"[{color}]{icon} {obs.RunningState}[/]", obs.Id, obs.Type.ToString()]) { Tag = obs }); + } + + if (matchingObservers.Count == 0) + { + table.AddRow(new UITableRow([$"[{mut}]—[/]", $"[{mut}]No observers found for this event type[/]", string.Empty])); + } + + table.SelectedRowChanged += (_, _) => + { + if (table.SelectedRow?.Tag is ObserverInformation obs) + { + detailPanel.Content = RenderObserverDetail(obs, mut, acc, warn); + } + }; + + // Select first row to populate the detail panel immediately + if (table.Rows.Count > 0 && table.Rows[0].Tag is ObserverInformation first) + { + table.SelectedRowIndex = 0; + detailPanel.Content = RenderObserverDetail(first, mut, acc, warn); + } + + var layout = HorizontalGridControl.Create() + .Column(c => c.Add(table)) + .WithSplitterAfter(0) + .Column(c => c.Width(38).Add(detailPanel)) + .Build(); + + var width = Math.Min(96, Console.WindowWidth - 4); + var height = Math.Min(28, Console.WindowHeight - 4); + + Window? overlayWindow = null; + overlayWindow = new WindowBuilder(windowSystem) + .WithTitle($" Observers for {eventTypeId} ") + .WithColors(WorkbenchColors.Foreground, WorkbenchColors.Background) + .WithSize(width, height) + .Centered() + .AddControl(layout) + .OnKeyPressed((_, e) => + { + if (e.KeyInfo.Key == ConsoleKey.Escape) + { + windowSystem.CloseWindow(overlayWindow, activateParent: true, force: false); + e.Handled = true; + return; + } + + if (e.KeyInfo.Key == ConsoleKey.Enter && + table.SelectedRow?.Tag is ObserverInformation obs) + { + windowSystem.CloseWindow(overlayWindow, activateParent: true, force: false); + navigateToObserver(obs); + e.Handled = true; + } + }) + .Build(); + + windowSystem.AddWindow(overlayWindow, activateWindow: true); + } + + /// + /// Opens a modal overlay showing the full definition — name, generation, owner, and schema — for + /// the event type identified by . + /// + /// The event type identifier to look up. + /// The current data snapshot used to locate the registration. + public void OpenEventTypeDefinition(string eventTypeId, WorkbenchData? snapshot) + { + var mut = WorkbenchColors.Muted.ToMarkup(); + var acc = WorkbenchColors.Accent.ToMarkup(); + var teal = WorkbenchColors.Teal.ToMarkup(); + + EventTypeRegistration? reg = null; + if (snapshot is not null) + { + reg = snapshot.EventTypeRegistrations + .FirstOrDefault(r => string.Equals(r.Type.Id, eventTypeId, StringComparison.OrdinalIgnoreCase)); + } + + string content; + if (reg is null) + { + content = $"[{mut}]No registration found for[/] {eventTypeId}"; + } + else + { + var schemaContent = !string.IsNullOrEmpty(reg.Schema) + ? JsonYamlFormatter.FormatAsYaml(reg.Schema, mut) + : $"[{mut}](no schema)[/]"; + + content = string.Join( + '\n', + [ + $"[bold {teal}]{reg.Type.Id}[/] [{mut}]gen {reg.Type.Generation}[/]", + string.Empty, + $"[{mut}]Owner[/] {reg.Owner}", + $"[{mut}]Source[/] {reg.Source}", + $"[{mut}]Tombstone[/] {reg.Type.Tombstone}", + string.Empty, + $"[{acc}]Schema:[/]", + schemaContent + ]); + } + + var markup = new MarkupControl([content]) { Wrap = true }; + var scrollable = Controls.ScrollablePanel() + .AddControl(markup) + .WithVerticalScroll(ScrollMode.Scroll) + .WithPadding(2, 1, 2, 1) + .Build(); + + var width = Math.Min(80, Console.WindowWidth - 4); + var height = Math.Min(32, Console.WindowHeight - 4); + + Window? defWindow = null; + defWindow = new WindowBuilder(windowSystem) + .WithTitle($" Event Type: {eventTypeId} ") + .WithColors(WorkbenchColors.Foreground, WorkbenchColors.Background) + .WithSize(width, height) + .Centered() + .AddControl(scrollable) + .OnKeyPressed((_, e) => + { + if (e.KeyInfo.Key == ConsoleKey.Escape) + { + windowSystem.CloseWindow(defWindow, activateParent: true, force: false); + e.Handled = true; + } + }) + .Build(); + + windowSystem.AddWindow(defWindow, activateWindow: true); + } + + static string RenderObserverDetail(ObserverInformation obs, string mut, string acc, string warn) + { + var color = ObserverStateColor(obs); + var lastSeq = obs.LastHandledEventSequenceNumber == ulong.MaxValue + ? "N/A" + : obs.LastHandledEventSequenceNumber.ToString("N0"); + + var lines = new List + { + $"[{mut}]Id[/] {obs.Id}", + $"[{mut}]Type[/] {obs.Type}", + $"[{mut}]State[/] [{color}]{obs.RunningState}[/]", + $"[{mut}]Next #[/] {obs.NextEventSequenceNumber:N0}", + $"[{mut}]Last #[/] {lastSeq}", + string.Empty, + $"[{acc}]Event Types:[/]" + }; + + foreach (var et in (obs.EventTypes ?? []).OrderBy(e => e.Id)) + { + lines.Add($" [{mut}]•[/] {et.Id} [{mut}]gen {et.Generation}[/]"); + } + + lines.Add(string.Empty); + lines.Add($"[{mut}]Enter[/] → go to observer in main view"); + + return string.Join('\n', lines); + } + + static int ObserverSortOrder(ObserverInformation o) => o.RunningState switch + { + ObserverRunningState.Disconnected => 0, + ObserverRunningState.Replaying => 1, + ObserverRunningState.Active => 2, + ObserverRunningState.Suspended => 3, + _ => 4 + }; + + static string ObserverStateColor(ObserverInformation obs) => obs.RunningState switch + { + ObserverRunningState.Active => WorkbenchColors.Success.ToMarkup(), + ObserverRunningState.Replaying => WorkbenchColors.Warning.ToMarkup(), + ObserverRunningState.Disconnected => WorkbenchColors.Danger.ToMarkup(), + _ => WorkbenchColors.Muted.ToMarkup() + }; + + static string ObserverIcon(ObserverInformation obs) => obs.RunningState switch + { + ObserverRunningState.Active => "●", + ObserverRunningState.Replaying => "▲", + ObserverRunningState.Disconnected => "⊘", + _ => "○" + }; + + void NavigateAndFilter(int viewIndex, string filter) + { + if (viewIndex >= 0 && viewIndex < views.Length) + { + views[viewIndex].SetFilter(filter); + } + } +} diff --git a/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchRefreshLoop.cs b/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchRefreshLoop.cs new file mode 100644 index 0000000..fc44221 --- /dev/null +++ b/Source/Cli/Commands/Chronicle/Workbench/windows/WorkbenchRefreshLoop.cs @@ -0,0 +1,205 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using SharpConsoleUI; + +namespace Cratis.Cli.Commands.Chronicle.Workbench; + +/// +/// Manages the periodic data refresh loop, pushes snapshots to views, and keeps the top status panel current. +/// +/// The Chronicle data service. +/// Workbench settings — controls the refresh interval and connection string. +/// All view instances — each receives every fresh snapshot. +/// Navigation — used to update badge counts after each refresh. +/// The window system — used to write to the top panel. +/// Returns the currently active event store name, or for the default. +/// Returns the currently active namespace name, or for the default. +public class WorkbenchRefreshLoop( + WorkbenchDataService dataService, + WorkbenchSettings settings, + IWorkbenchView[] views, + WorkbenchNavigation navigation, + ConsoleWindowSystem windowSystem, + Func getActiveEventStore, + Func getActiveNamespace) +{ + readonly object _dataLock = new(); + WorkbenchData? _currentData; + bool _wasDisconnected; + + /// + /// Gets the most recently fetched snapshot, or if no fetch has completed yet. + /// Thread-safe — acquires the internal data lock on every access. + /// + public WorkbenchData? CurrentData + { + get + { + lock (_dataLock) + { + return _currentData; + } + } + } + + /// + /// Seeds the loop with a pre-fetched snapshot: pushes it to all views, updates the top panel, and + /// updates navigation badge counts. Call once, before the window is shown. + /// + /// The pre-fetched initial snapshot. + public void Initialize(WorkbenchData data) + { + lock (_dataLock) + { + _currentData = data; + } + + PushDataToViews(data); + UpdateTopPanel(data); + navigation.UpdateNavBadges(data); + } + + /// + /// Runs the periodic refresh loop as a SharpConsoleUI async window thread. + /// Performs an immediate fetch on entry, then repeats on the configured interval until cancellation. + /// + /// The host window (unused — required by the window-thread delegate signature). + /// Cancellation token that signals the window is closing. + public async Task RunAsync(Window window, CancellationToken ct) + { + await FetchAndUpdate(ct); + + while (!ct.IsCancellationRequested) + { + try + { + await Task.Delay(TimeSpan.FromSeconds(settings.Interval), ct); + } + catch (OperationCanceledException) + { + break; + } + + await FetchAndUpdate(ct); + } + } + + /// + /// Fetches a fresh snapshot and distributes it to all views, the top panel, and navigation badges. + /// + /// Cancellation token. + public async Task FetchAndUpdate(CancellationToken ct) + { + try + { + SetPanelText("↻ refreshing…"); + + var data = await dataService.FetchAsync( + getActiveEventStore(), + getActiveNamespace(), + readModelContainerName: null, + ct); + + lock (_dataLock) + { + _currentData = data; + } + + PushDataToViews(data); + + if (_wasDisconnected && data.IsConnected) + { + _ = Task.Run( + async () => + { + SetPanelText("✓ Reconnected"); + await Task.Delay(3000, ct); + UpdateTopPanel(_currentData); + }, + ct); + } + + _wasDisconnected = !data.IsConnected; + + UpdateTopPanel(data); + navigation.UpdateNavBadges(data); + } + catch (OperationCanceledException) + { + } + catch + { + // Swallow — connectivity errors are surfaced via IsConnected on the next successful fetch. + } + } + + /// + /// Displays a temporary message in the top panel, then resets to the normal connection summary after two seconds. + /// + /// The temporary text to display. + public void ShowTemporaryMessage(string text) + { + SetPanelText(text); + _ = Task.Delay(2000).ContinueWith(_ => UpdateTopPanel(CurrentData), TaskScheduler.Default); + } + + /// + /// Updates the top panel with the current connection / store / namespace summary. + /// Falls back to when is . + /// + /// Optional snapshot to use; falls back to when omitted. + public void UpdateTopPanel(WorkbenchData? data = null) + { + data ??= CurrentData; + if (data is null) + { + return; + } + + var host = ExtractHostFromConnectionString(settings.ResolveConnectionString()); + var eventStore = getActiveEventStore() ?? settings.ResolveEventStore(); + var ns = getActiveNamespace() ?? settings.ResolveNamespace(); + var connDot = data.IsConnected ? "●" : "○"; + var seqText = data.TailSequenceNumber.HasValue ? $" seq#{data.TailSequenceNumber.Value:N0}" : string.Empty; + + windowSystem.PanelStateService.TopStatus = + $"◆ CHRONICLE WORKBENCH · {host} · {eventStore}/{ns} · ↻{settings.Interval}s{seqText} {connDot}"; + } + + static string ExtractHostFromConnectionString(string connectionString) + { + const string scheme = "chronicle://"; + if (!connectionString.StartsWith(scheme, StringComparison.OrdinalIgnoreCase)) + { + return connectionString; + } + + var afterScheme = connectionString[scheme.Length..]; + + var queryStart = afterScheme.IndexOf('?'); + if (queryStart >= 0) + { + afterScheme = afterScheme[..queryStart]; + } + + var atSign = afterScheme.IndexOf('@'); + if (atSign >= 0) + { + afterScheme = afterScheme[(atSign + 1)..]; + } + + return $"chronicle://{afterScheme}"; + } + + void PushDataToViews(WorkbenchData data) + { + foreach (var view in views) + { + view.UpdateData(data); + } + } + + void SetPanelText(string text) => + windowSystem.PanelStateService.TopStatus = text; +} From 49967a32e7d91522e11571ed957e1c143ec8846d Mon Sep 17 00:00:00 2001 From: woksin Date: Fri, 22 May 2026 23:50:40 +0200 Subject: [PATCH 5/5] Fix: update RunningState to use the Quarantined enum value for clarity in observer state determination --- .../and_running_state_has_quarantined_value.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/Cli.Specs/for_ListObserversCommand/when_determining_if_observer_is_quarantined/and_running_state_has_quarantined_value.cs b/Source/Cli.Specs/for_ListObserversCommand/when_determining_if_observer_is_quarantined/and_running_state_has_quarantined_value.cs index 266fa00..5ff7241 100644 --- a/Source/Cli.Specs/for_ListObserversCommand/when_determining_if_observer_is_quarantined/and_running_state_has_quarantined_value.cs +++ b/Source/Cli.Specs/for_ListObserversCommand/when_determining_if_observer_is_quarantined/and_running_state_has_quarantined_value.cs @@ -11,7 +11,7 @@ public class and_running_state_has_quarantined_value : Specification void Because() => _isQuarantined = ListObserversCommand.IsQuarantined(new ObserverInformation { - RunningState = (ObserverRunningState)5 + RunningState = ObserverRunningState.Quarantined }); [Fact] void should_return_true() => _isQuarantined.ShouldBeTrue();