From 6dbc29818c86eb03e1847902276f96e4a4992f5f Mon Sep 17 00:00:00 2001 From: Stone_Red <56473591+Stone-Red-Code@users.noreply.github.com> Date: Tue, 7 Apr 2026 19:14:53 +0200 Subject: [PATCH 01/15] fix: error when trying to zip open log file --- .../Services/UpdateBackupService.cs | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/EchoHub.Client/Services/UpdateBackupService.cs b/src/EchoHub.Client/Services/UpdateBackupService.cs index fb1e5d6..38e709d 100644 --- a/src/EchoHub.Client/Services/UpdateBackupService.cs +++ b/src/EchoHub.Client/Services/UpdateBackupService.cs @@ -40,7 +40,37 @@ public static class UpdateBackupService Log.Information("Creating pre-update backup of {AppDir} (v{Version})", appDir, version); - ZipFile.CreateFromDirectory(appDir, BackupZipPath, CompressionLevel.Fastest, includeBaseDirectory: false); + using (var archive = ZipFile.Open(BackupZipPath, ZipArchiveMode.Create)) + { + foreach (var file in Directory.EnumerateFiles(appDir, "*", SearchOption.AllDirectories)) + { + var relativePath = Path.GetRelativePath(appDir, file); + + // Skip log files to prevent locking errors with Serilog while zipping + if (relativePath.StartsWith("logs" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || + relativePath.StartsWith("logs" + Path.AltDirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || + relativePath.EndsWith(".log", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + // Normalize path separators for the zip archive format + var entryName = relativePath.Replace(Path.DirectorySeparatorChar, '/').Replace(Path.AltDirectorySeparatorChar, '/'); + + try + { + archive.CreateEntryFromFile(file, entryName, CompressionLevel.Fastest); + } + catch (IOException ex) + { + Log.Warning(ex, "Skipped locked file {FileName} during backup calculation", relativePath); + } + catch (UnauthorizedAccessException ex) + { + Log.Warning(ex, "Skipped inaccessible file {FileName} during backup calculation", relativePath); + } + } + } var info = new BackupInfo(version, appDir, DateTimeOffset.UtcNow); var json = JsonSerializer.Serialize(info, BackupJsonContext.Default.BackupInfo); From 5b8df9d505195043df8a9059fb24beae7c30f342 Mon Sep 17 00:00:00 2001 From: Stone_Red <56473591+Stone-Red-Code@users.noreply.github.com> Date: Tue, 7 Apr 2026 19:31:41 +0200 Subject: [PATCH 02/15] fix: remove unnecessary _app.Invoke calls around update progress dialog updates --- src/EchoHub.Client/Services/UpdateChecker.cs | 26 ++++++++------------ 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/src/EchoHub.Client/Services/UpdateChecker.cs b/src/EchoHub.Client/Services/UpdateChecker.cs index 776736a..d8e43bc 100644 --- a/src/EchoHub.Client/Services/UpdateChecker.cs +++ b/src/EchoHub.Client/Services/UpdateChecker.cs @@ -71,7 +71,7 @@ public sealed class UpdateChecker : IDisposable // Create backup before the update starts try { - _app.Invoke(() => _progressDialog?.UpdateProgress(0f, "Creating backup...")); + _progressDialog?.UpdateProgress(0f, "Creating backup..."); UpdateBackupService.CreateBackup(); } catch (Exception ex) @@ -79,27 +79,21 @@ public sealed class UpdateChecker : IDisposable Log.Error(ex, "Failed to create pre-update backup"); var proceed = false; - _app.Invoke(() => - { - proceed = MessageBox.Query( - _app, - "Backup Warning", - $"Could not create backup: {ex.Message}\n\nContinue update without backup?", - "Continue", "Cancel") == 0; - }); + proceed = MessageBox.Query( + _app, + "Backup Warning", + $"Could not create backup: {ex.Message}\n\nContinue update without backup?", + "Continue", "Cancel") == 0; if (!proceed) { - _app.Invoke(() => - { - _progressDialog?.Close(); - _progressDialog = null; - }); + _progressDialog?.Close(); + _progressDialog = null; return; } } - _app.Invoke(() => _progressDialog?.UpdateProgress(0f, "Downloading update...")); + _progressDialog?.UpdateProgress(0f, "Downloading update..."); await _updater.UpdateAsync(); }); @@ -118,7 +112,7 @@ public sealed class UpdateChecker : IDisposable statusText = $"{step}..."; } - _app.Invoke(() => _progressDialog?.UpdateProgress(fraction, statusText)); + _progressDialog?.UpdateProgress(fraction, statusText); } private void OnUpdateStarted(string version) From 6aef6890cfaae88f30a53070303feb49261c1fe0 Mon Sep 17 00:00:00 2001 From: HueByte Date: Tue, 7 Apr 2026 15:56:12 +0200 Subject: [PATCH 03/15] fix: ensure progress dialog updates on the UI thread --- src/EchoHub.Client/Services/UpdateChecker.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/EchoHub.Client/Services/UpdateChecker.cs b/src/EchoHub.Client/Services/UpdateChecker.cs index e136864..776736a 100644 --- a/src/EchoHub.Client/Services/UpdateChecker.cs +++ b/src/EchoHub.Client/Services/UpdateChecker.cs @@ -118,7 +118,7 @@ public sealed class UpdateChecker : IDisposable statusText = $"{step}..."; } - _progressDialog?.UpdateProgress(fraction, statusText); + _app.Invoke(() => _progressDialog?.UpdateProgress(fraction, statusText)); } private void OnUpdateStarted(string version) From b98673f8c59093af319978d45640db20b420e9be Mon Sep 17 00:00:00 2001 From: Stone_Red <56473591+Stone-Red-Code@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:18:40 +0100 Subject: [PATCH 04/15] fix: set input field insertion point to end after autocomplete --- src/EchoHub.Client/UI/MainWindow.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index f46e5cc..77ebece 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -572,6 +572,9 @@ public sealed partial class MainWindow : Runnable if (prefix.Length > text.Length) _inputField.Text = prefix; } + + // Move cursor to end after autocomplete + _inputField.InsertionPoint = new System.Drawing.Point(_inputField.Text?.Length ?? 0, 0); } private void OnChatViewportChanged() From 56fccf5cfbb7e3080a8a0fe0b8ddf578158529b7 Mon Sep 17 00:00:00 2001 From: Stone_Red <56473591+Stone-Red-Code@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:23:12 +0100 Subject: [PATCH 05/15] chore: add cursor position fix to changelog --- docs/changelog/v0.2.10.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/changelog/v0.2.10.md b/docs/changelog/v0.2.10.md index b14b56f..9406c2e 100644 --- a/docs/changelog/v0.2.10.md +++ b/docs/changelog/v0.2.10.md @@ -1,9 +1,10 @@ # v0.2.10 -Follow-up patch release for v0.2.9 addressing regressions in the auto-updater flow. +Follow-up patch release for v0.2.9 addressing auto-updater regressions and input polish. ## Bug Fixes - Fix update progress dialog freezing / not repainting — progress callbacks now run on the UI thread so the download and extraction percentage actually updates while an update is in progress - Fix pre-update backup failing when a Serilog-held log file is locked — `UpdateBackupService` now enumerates files manually, skips the `logs/` directory and `.log` files, and logs-and-continues on `IOException`/`UnauthorizedAccessException` instead of aborting the whole backup - Simplify update progress dispatch — remove redundant `Application.Invoke` wrappers around progress updates that are already called from the UI thread (introduced while fixing the freeze above) +- Fix cursor position being reset to the start of the line when auto-completing commands in the CLI app — insertion point is now moved to the end of the completed text From 000764fdb433913d78d2c7ee1f651510188ede4b Mon Sep 17 00:00:00 2001 From: HueByte Date: Mon, 20 Apr 2026 16:33:44 +0200 Subject: [PATCH 06/15] chore: update version to 0.2.10 and enhance installation scripts and changelog --- docs/articles/getting-started.md | 2 +- docs/changelog/index.md | 3 ++- docs/changelog/toc.yml | 2 ++ docs/changelog/v0.2.10.md | 9 +++++++++ docs/changelog/v0.2.9.md | 18 ++++++++++++++++++ scripts/install.sh | 2 +- src/Directory.Build.props | 2 +- 7 files changed, 34 insertions(+), 4 deletions(-) create mode 100644 docs/changelog/v0.2.10.md diff --git a/docs/articles/getting-started.md b/docs/articles/getting-started.md index c248c1e..805823b 100644 --- a/docs/articles/getting-started.md +++ b/docs/articles/getting-started.md @@ -17,7 +17,7 @@ curl -sSfL https://raw.githubusercontent.com/HueByte/EchoHub/master/scripts/inst To install a specific version or to a custom directory: ```bash -curl -sSfL .../install.sh | sh -s -- --version 0.2.8 +curl -sSfL .../install.sh | sh -s -- --version 0.2.10 curl -sSfL .../install.sh | sh -s -- --install-dir /opt/echohub ``` diff --git a/docs/changelog/index.md b/docs/changelog/index.md index de7183c..4deb405 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -4,7 +4,8 @@ Release history for EchoHub. ## Releases -- [v0.2.9](v0.2.9.md) - Linux/macOS Single-File Publish Fix +- [v0.2.10](v0.2.10.md) - Auto-Updater Hotfixes +- [v0.2.9](v0.2.9.md) - Install Script & Chocolatey Fixes - [v0.2.8](v0.2.8.md) - Docker Support, IRC Account Creation & BOM Fix - [v0.2.7](v0.2.7.md) - User List Fix & Terminal.Gui NuGet Migration - [v0.2.6](v0.2.6.md) - Major Refactoring & Code Organization diff --git a/docs/changelog/toc.yml b/docs/changelog/toc.yml index fcc13b3..b9b6073 100644 --- a/docs/changelog/toc.yml +++ b/docs/changelog/toc.yml @@ -1,5 +1,7 @@ - name: Overview href: index.md +- name: v0.2.10 + href: v0.2.10.md - name: v0.2.9 href: v0.2.9.md - name: v0.2.8 diff --git a/docs/changelog/v0.2.10.md b/docs/changelog/v0.2.10.md new file mode 100644 index 0000000..b14b56f --- /dev/null +++ b/docs/changelog/v0.2.10.md @@ -0,0 +1,9 @@ +# v0.2.10 + +Follow-up patch release for v0.2.9 addressing regressions in the auto-updater flow. + +## Bug Fixes + +- Fix update progress dialog freezing / not repainting — progress callbacks now run on the UI thread so the download and extraction percentage actually updates while an update is in progress +- Fix pre-update backup failing when a Serilog-held log file is locked — `UpdateBackupService` now enumerates files manually, skips the `logs/` directory and `.log` files, and logs-and-continues on `IOException`/`UnauthorizedAccessException` instead of aborting the whole backup +- Simplify update progress dispatch — remove redundant `Application.Invoke` wrappers around progress updates that are already called from the UI thread (introduced while fixing the freeze above) diff --git a/docs/changelog/v0.2.9.md b/docs/changelog/v0.2.9.md index 3fc6db7..ff85d54 100644 --- a/docs/changelog/v0.2.9.md +++ b/docs/changelog/v0.2.9.md @@ -3,3 +3,21 @@ ## Bug Fixes - Fix Linux/macOS client install — enable single-file publish so the install script copies one self-contained binary instead of just the native host (which failed with "does not exist: EchoHub.Client.dll") +- Fix Chocolatey install path on Windows — `chocolateyInstall.ps1` was joining the install directory and executable name into a single segment, producing an invalid target path +- Fix Chocolatey package metadata — corrected GitHub repository URLs and documentation URLs in `echohub.nuspec` that pointed at the wrong location +- Fix double-click on "Public" checkbox in the Create Channel dialog accidentally submitting the dialog — checkbox toggle commands no longer bubble up to the dialog's default button + +## Documentation + +- Add a dedicated configuration guide (`docs/articles/configuration.md`) covering server settings, client settings, and environment overrides +- Refresh README badges and reorganize the articles table of contents for better discoverability +- Polish Docker, getting-started, and flow docs to match the current configuration surface + +## Dependencies + +- Bump `Terminal.Gui` to `2.0.0-develop.5043` (from `5039`) + +## CI + +- Release workflow now publishes a single-file self-contained client binary for Linux and macOS so the install script works out-of-the-box +- Chocolatey publishing step now triggers only when the package source actually changes and performs a proper version check against the feed before pushing diff --git a/scripts/install.sh b/scripts/install.sh index 238604d..7a56afd 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -30,7 +30,7 @@ while [ $# -gt 0 ]; do sed -n '2,8p' "$0" 2>/dev/null || true echo "" echo " curl -sSfL https://raw.githubusercontent.com/$REPO/master/scripts/install.sh | sh" - echo " curl ... | sh -s -- --version 0.2.8" + echo " curl ... | sh -s -- --version 0.2.10" echo " curl ... | sh -s -- --install-dir /opt/echohub" exit 0 ;; diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 24de7d9..e74631e 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,6 +1,6 @@ - 0.2.9 + 0.2.10 true $(NoWarn);CS1591 From aa6599a4e0547d327421160029879a5c13bc9d56 Mon Sep 17 00:00:00 2001 From: Stone_Red <56473591+Stone-Red-Code@users.noreply.github.com> Date: Thu, 26 Feb 2026 16:47:58 +0100 Subject: [PATCH 07/15] feat: search dialog to navigate app --- src/EchoHub.Client/UI/Dialogs/SearchDialog.cs | 160 ++++++++++++++++++ .../UI/ListSources/SearchListSource.cs | 87 ++++++++++ src/EchoHub.Client/UI/MainWindow.cs | 47 ++++- 3 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 src/EchoHub.Client/UI/Dialogs/SearchDialog.cs create mode 100644 src/EchoHub.Client/UI/ListSources/SearchListSource.cs diff --git a/src/EchoHub.Client/UI/Dialogs/SearchDialog.cs b/src/EchoHub.Client/UI/Dialogs/SearchDialog.cs new file mode 100644 index 0000000..6f7b930 --- /dev/null +++ b/src/EchoHub.Client/UI/Dialogs/SearchDialog.cs @@ -0,0 +1,160 @@ +using EchoHub.Client.UI.ListSources; + +using System.Collections; +using System.Collections.Specialized; +using System.Diagnostics; + +using Terminal.Gui.App; +using Terminal.Gui.Drawing; +using Terminal.Gui.Input; +using Terminal.Gui.Text; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; + +namespace EchoHub.Client.UI.Dialogs; + +public enum SearchResultType +{ + Channel, + Action +} + +public record SearchResult(SearchResultType Type, string Key, string Label); + +/// +/// Command-palette style search dialog (Ctrl+K) for navigating channels and triggering app actions. +/// +public static class SearchDialog +{ + private static readonly IReadOnlyList DefaultActions = [ + new(SearchResultType.Action, "connect", "Connect to Server"), + new(SearchResultType.Action, "disconnect", "Disconnect"), + new(SearchResultType.Action, "logout", "Logout"), + new(SearchResultType.Action, "profile", "My Profile"), + new(SearchResultType.Action, "status", "Set Status"), + new(SearchResultType.Action, "create-channel", "Create Channel"), + new(SearchResultType.Action, "delete-channel", "Delete Channel"), + new(SearchResultType.Action, "servers", "Saved Servers"), + new(SearchResultType.Action, "toggle-users", "Toggle Users Panel"), + new(SearchResultType.Action, "updates", "Check for Updates"), + new(SearchResultType.Action, "quit", "Quit"), + ]; + + public static SearchResult? Show(IApplication app, IReadOnlyList channels) + { + SearchResult? result = null; + var source = new SearchListSource(BuildAllItems(channels)); + + var dialog = new Dialog + { + Title = "Search", + Width = 59, + Height = 22, + }; + + var hintLabel = new Label + { + Text = "Channels and actions \u2502 \u2193 to navigate \u2502 Enter to select", + X = 1, + Y = 1, + }; + + var searchField = new TextField + { + X = 1, + Y = 2, + Title = "Search", + Width = Dim.Fill(2), + }; + + var resultList = new ListView + { + X = 1, + Y = 4, + Width = Dim.Fill(2), + Height = Dim.Fill(3), + Source = source + }; + + var cancelButton = new Button + { + Text = "Cancel", + X = Pos.Center(), + Y = Pos.AnchorEnd(1), + }; + + if (source.Count > 0) + resultList.SelectedItem = 0; + + searchField.KeyDown += (s, e) => + { + if (e.KeyCode == Key.K.WithCtrl) + { + e.Handled = true; + app.RequestStop(); + } + }; + + searchField.TextChanged += (s, e) => + { + source.Filter(searchField.Text ?? string.Empty); + resultList.Source = source; + if (source.Count > 0) + resultList.SelectedItem = 0; + }; + + searchField.Accepting += (s, e) => TryConfirm(e); + + resultList.Accepting += (s, e) => TryConfirm(e); + + resultList.KeystrokeNavigator.SearchStringChanged += (s, e) => + { + app.Invoke(() => + { + searchField.SetFocus(); + }); + }; + + cancelButton.Accepting += (s, e) => + { + result = null; + e.Handled = true; + app.RequestStop(); + }; + + dialog.KeyDown += (s, e) => + { + if (e.KeyCode == Key.K.WithCtrl) + { + e.Handled = true; + app.RequestStop(); + } + }; + + dialog.Add(hintLabel, searchField, resultList, cancelButton); + searchField.SetFocus(); + app.Run(dialog); + + return result; + + void TryConfirm(CommandEventArgs e) + { + var idx = resultList.SelectedItem ?? 0; + if (source.Count > 0 && idx >= 0 && idx < source.Count) + { + result = source.GetItem(idx); + e.Handled = true; + app.RequestStop(); + } + } + } + + private static List BuildAllItems(IReadOnlyList channels) + { + var items = new List(); + foreach (var ch in channels) + items.Add(new SearchResult(SearchResultType.Channel, ch, $"#{ch}")); + items.AddRange(DefaultActions); + return items; + } +} diff --git a/src/EchoHub.Client/UI/ListSources/SearchListSource.cs b/src/EchoHub.Client/UI/ListSources/SearchListSource.cs new file mode 100644 index 0000000..bc57a7a --- /dev/null +++ b/src/EchoHub.Client/UI/ListSources/SearchListSource.cs @@ -0,0 +1,87 @@ +using EchoHub.Client.UI.Chat; +using EchoHub.Client.UI.Dialogs; + +using System.Collections; +using System.Collections.Specialized; + +using Terminal.Gui.Drawing; +using Terminal.Gui.Text; +using Terminal.Gui.Views; + +using Attribute = Terminal.Gui.Drawing.Attribute; + +namespace EchoHub.Client.UI.ListSources; + +/// +/// List data source for the search dialog with filtering and colored rendering. +/// +public class SearchListSource(List items) : IListDataSource +{ + private readonly List _allItems = items; + private List _filtered = [.. items]; + + private static readonly Attribute ChannelAttribute = new(Color.BrightCyan, Color.None); + private static readonly Attribute ActionAttribute = new(Color.White, Color.None); + + public event NotifyCollectionChangedEventHandler? CollectionChanged; + public int Count => _filtered.Count; + public int MaxItemLength => _filtered.Count > 0 ? _filtered.Max(i => i.Label.GetColumns()) : 0; + public bool SuspendCollectionChangedEvent { get; set; } + + public void Filter(string query) + { + if (string.IsNullOrWhiteSpace(query)) + { + _filtered = [.. _allItems]; + } + else + { + _filtered = [.. _allItems.Where(i => + i.Label.Contains(query, StringComparison.OrdinalIgnoreCase) + || i.Key.Contains(query, StringComparison.OrdinalIgnoreCase))]; + } + + if (!SuspendCollectionChangedEvent) + CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + } + + public SearchResult? GetItem(int index) => index >= 0 && index < _filtered.Count ? _filtered[index] : null; + + public bool IsMarked(int item) => false; + public void SetMark(int item, bool value) { } + public IList ToList() => _filtered.Select(i => (object)i.Label).ToList(); + + public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0) + { + listView.Move(Math.Max(col - viewportX, 0), row); + + var entry = _filtered[item]; + var fillAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal); + + Attribute itemAttr; + if (selected) + { + itemAttr = fillAttr; + } + else + { + var raw = entry.Type switch + { + SearchResultType.Channel => ChannelAttribute, + SearchResultType.Action => ActionAttribute, + _ => fillAttr + }; + itemAttr = raw.Background == Color.None ? raw with { Background = fillAttr.Background } : raw; + } + + listView.SetAttribute(itemAttr); + + var drawn = RenderHelpers.WriteText(listView, entry.Label, 0, width); + + listView.SetAttribute(fillAttr); + for (var i = drawn; i < width; i++) + listView.AddStr(" "); + } + + public void Dispose() { } +} diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index 77ebece..17ed1e0 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -48,6 +48,7 @@ public sealed partial class MainWindow : Runnable private static readonly Key NewlineKey = Key.N.WithCtrl; private static readonly Key AltQKey = Key.Q.WithAlt; private static readonly Key TabKey = Key.Tab; + private static readonly Key CtrlKKey = Key.K.WithCtrl; // Available slash commands for Tab autocomplete private static readonly string[] SlashCommands = @@ -222,7 +223,7 @@ public sealed partial class MainWindow : Runnable // Bottom input area _inputFrame = new FrameView { - Title = "Message \u2502 Enter=send \u2502 Ctrl+N=newline \u2502 Tab=complete", + Title = "Message \u2502 Enter=send \u2502 Ctrl+N=newline \u2502 Tab=complete \u2502 Ctrl+K=search", X = 22, Y = Pos.Bottom(_chatFrame), Width = Dim.Fill(UsersPanelWidth), @@ -513,6 +514,11 @@ public sealed partial class MainWindow : Runnable _app.RequestStop(); e.Handled = true; } + else if (e.KeyCode == CtrlKKey.KeyCode) + { + ShowSearchDialog(); + e.Handled = true; + } } private bool _suppressEmojiReplace; @@ -600,6 +606,11 @@ public sealed partial class MainWindow : Runnable ToggleUsersPanel(); e.Handled = true; } + else if (e.KeyCode == CtrlKKey.KeyCode) + { + ShowSearchDialog(); + e.Handled = true; + } } private void OnMessagesChanged(string channelName) @@ -899,6 +910,40 @@ public sealed partial class MainWindow : Runnable SetNeedsDraw(); } + /// + /// Open the command-palette search dialog (Ctrl+K) and dispatch the selected result. + /// + private void ShowSearchDialog() + { + var result = Dialogs.SearchDialog.Show(_app, _channelNames.AsReadOnly()); + if (result is null) return; + + switch (result.Type) + { + case Dialogs.SearchResultType.Channel: + SwitchToChannel(result.Key); + OnChannelSelected?.Invoke(result.Key); + break; + + case Dialogs.SearchResultType.Action: + switch (result.Key) + { + case "connect": OnConnectRequested?.Invoke(); break; + case "disconnect": OnDisconnectRequested?.Invoke(); break; + case "logout": OnLogoutRequested?.Invoke(); break; + case "profile": OnProfileRequested?.Invoke(); break; + case "status": OnStatusRequested?.Invoke(); break; + case "create-channel": OnCreateChannelRequested?.Invoke(); break; + case "delete-channel": OnDeleteChannelRequested?.Invoke(); break; + case "servers": OnSavedServersRequested?.Invoke(); break; + case "toggle-users": ToggleUsersPanel(); break; + case "updates": OnCheckForUpdatesRequested?.Invoke(); break; + case "quit": _app.RequestStop(); break; + } + break; + } + } + /// /// Toggle the online users panel visibility (F2). /// From ff9a9e3dd0b5ef8f0db9ac44d4b3fceb194e8a46 Mon Sep 17 00:00:00 2001 From: Stone_Red <56473591+Stone-Red-Code@users.noreply.github.com> Date: Thu, 26 Feb 2026 16:49:52 +0100 Subject: [PATCH 08/15] chore: mark search bar/modal as completed in the todo list --- docs/todo.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/todo.md b/docs/todo.md index eb04e7d..a33c4ca 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -17,7 +17,7 @@ - that means basically multiple servers linked, so users can chat cross-server in this network - [x] when users clicks public -> private -> public checkbox in the channel creation, it ends up creating the channel on 3rd check switch - [ ] add keyboard only controls | at least for most important parts and the rest might be accessible with: (down) -- [ ] add search bar / search modal – that will allow users to instantly navigate to room / focus on app element & etc +- [x] add search bar / search modal – that will allow users to instantly navigate to room / focus on app element & etc - [ ] Actually smart data management – cache messages, lazy load messages on scroll (currently hardcoded 100msgs fetched + new ones) - [x] Another thing would be stateful userlist – basically fetch once and listen for userlist updates - [x] Send to EchohubSpace only state changes, currently we send user count periodically, instead of updating it on update From bd8b88add2b3a2a8ea287256239a5ed67849a78b Mon Sep 17 00:00:00 2001 From: Stone_Red <56473591+Stone-Red-Code@users.noreply.github.com> Date: Thu, 26 Feb 2026 16:51:34 +0100 Subject: [PATCH 09/15] chore: add command palette with Ctrl+K for quick navigation and actions to changelog --- docs/changelog/v0.2.10.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/changelog/v0.2.10.md b/docs/changelog/v0.2.10.md index 9406c2e..1023f5a 100644 --- a/docs/changelog/v0.2.10.md +++ b/docs/changelog/v0.2.10.md @@ -1,6 +1,10 @@ # v0.2.10 -Follow-up patch release for v0.2.9 addressing auto-updater regressions and input polish. +Follow-up patch release for v0.2.9 addressing auto-updater regressions, adding a command palette, and input polish. + +## New Features + +- Command palette — press Ctrl+K from the message input (or anywhere in the main window) to open a searchable dialog for navigating channels and triggering app actions (connect, disconnect, logout, profile, status, create/delete channel, saved servers, toggle users panel, check for updates, quit). Fuzzy matches against both the label and the underlying key so typing `ch` surfaces channel actions alongside `#channel` entries ## Bug Fixes @@ -8,3 +12,7 @@ Follow-up patch release for v0.2.9 addressing auto-updater regressions and input - Fix pre-update backup failing when a Serilog-held log file is locked — `UpdateBackupService` now enumerates files manually, skips the `logs/` directory and `.log` files, and logs-and-continues on `IOException`/`UnauthorizedAccessException` instead of aborting the whole backup - Simplify update progress dispatch — remove redundant `Application.Invoke` wrappers around progress updates that are already called from the UI thread (introduced while fixing the freeze above) - Fix cursor position being reset to the start of the line when auto-completing commands in the CLI app — insertion point is now moved to the end of the completed text + +## Refactoring + +- Move search-dialog dispatch out of `MainWindow` into `AppOrchestrator` — `MainWindow` now just raises `OnSearchRequested`, keeping the view dumb and letting the orchestrator own navigation/action routing From d6282885e3dc44cebe09be827156b4272b3208f2 Mon Sep 17 00:00:00 2001 From: Stone_Red <56473591+Stone-Red-Code@users.noreply.github.com> Date: Thu, 26 Feb 2026 17:04:18 +0100 Subject: [PATCH 10/15] refactor: move search dialog handling to AppOrchestrator --- src/EchoHub.Client/AppOrchestrator.cs | 33 ++++++++++++++++++++ src/EchoHub.Client/UI/MainWindow.cs | 44 ++++++--------------------- 2 files changed, 43 insertions(+), 34 deletions(-) diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs index 5c1f52d..fc47c3c 100644 --- a/src/EchoHub.Client/AppOrchestrator.cs +++ b/src/EchoHub.Client/AppOrchestrator.cs @@ -91,6 +91,7 @@ public sealed class AppOrchestrator : IDisposable _mainWindow.OnRollbackRequested += HandleRollbackRequested; _mainWindow.OnUserProfileRequested += HandleViewProfile; _mainWindow.OnChannelJoinRequested += HandleChannelJoinFromMessage; + _mainWindow.OnSearchRequested += HandleSearchRequested; } // ── Command Handler Wiring ───────────────────────────────────────────── @@ -733,6 +734,38 @@ public sealed class AppOrchestrator : IDisposable HandleChannelSelected(channelName); } + + private void HandleSearchRequested() + { + var result = SearchDialog.Show(_app, _mainWindow.GetChannelNames()); + if (result is null) return; + + switch (result.Type) + { + case SearchResultType.Channel: + _mainWindow.SwitchToChannel(result.Key); + HandleChannelSelected(result.Key); + break; + + case SearchResultType.Action: + switch (result.Key) + { + case "connect": HandleConnect(); break; + case "disconnect": HandleDisconnect(); break; + case "logout": HandleLogout(); break; + case "profile": HandleProfileRequested(); break; + case "status": HandleStatusRequested(); break; + case "create-channel": HandleCreateChannelRequested(); break; + case "delete-channel": HandleDeleteChannelRequested(); break; + case "servers": HandleSavedServersRequested(); break; + case "toggle-users": _mainWindow.ToggleUsersPanel(); break; + case "updates": HandleCheckForUpdatesRequested(); break; + case "quit": _app.RequestStop(); break; + } + break; + } + } + private void HandleProfileRequested() { HandleViewProfile(null); diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index 17ed1e0..a30230b 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -152,6 +152,11 @@ public sealed partial class MainWindow : Runnable /// public event Action? OnChannelJoinRequested; + /// + /// Fired when the user requests to open the search dialog (via menu or Ctrl+K). + /// + public event Action? OnSearchRequested; + public MainWindow(IApplication app, ChatMessageManager messageManager) { _app = app; @@ -613,6 +618,11 @@ public sealed partial class MainWindow : Runnable } } + private void ShowSearchDialog() + { + OnSearchRequested?.Invoke(); + } + private void OnMessagesChanged(string channelName) { if (channelName == _messageManager.CurrentChannel) @@ -910,40 +920,6 @@ public sealed partial class MainWindow : Runnable SetNeedsDraw(); } - /// - /// Open the command-palette search dialog (Ctrl+K) and dispatch the selected result. - /// - private void ShowSearchDialog() - { - var result = Dialogs.SearchDialog.Show(_app, _channelNames.AsReadOnly()); - if (result is null) return; - - switch (result.Type) - { - case Dialogs.SearchResultType.Channel: - SwitchToChannel(result.Key); - OnChannelSelected?.Invoke(result.Key); - break; - - case Dialogs.SearchResultType.Action: - switch (result.Key) - { - case "connect": OnConnectRequested?.Invoke(); break; - case "disconnect": OnDisconnectRequested?.Invoke(); break; - case "logout": OnLogoutRequested?.Invoke(); break; - case "profile": OnProfileRequested?.Invoke(); break; - case "status": OnStatusRequested?.Invoke(); break; - case "create-channel": OnCreateChannelRequested?.Invoke(); break; - case "delete-channel": OnDeleteChannelRequested?.Invoke(); break; - case "servers": OnSavedServersRequested?.Invoke(); break; - case "toggle-users": ToggleUsersPanel(); break; - case "updates": OnCheckForUpdatesRequested?.Invoke(); break; - case "quit": _app.RequestStop(); break; - } - break; - } - } - /// /// Toggle the online users panel visibility (F2). /// From e42f1a0965c8c60957b0b856dd0fe7f1974ac2fa Mon Sep 17 00:00:00 2001 From: Stone_Red <56473591+Stone-Red-Code@users.noreply.github.com> Date: Wed, 25 Feb 2026 22:30:48 +0100 Subject: [PATCH 11/15] feat: load more message history when scrolling to top --- src/EchoHub.Client/AppOrchestrator.cs | 27 ++++++++++++++ .../Services/ConnectionManager.cs | 4 +-- .../Services/EchoHubConnection.cs | 4 +-- .../UI/Chat/ChatMessageManager.cs | 33 +++++++++++++++++ src/EchoHub.Client/UI/MainWindow.cs | 36 +++++++++++++++++++ .../Constants/ValidationConstants.cs | 2 +- src/EchoHub.Core/Contracts/IChatService.cs | 2 +- src/EchoHub.Server/Hubs/ChatHub.cs | 4 +-- src/EchoHub.Server/Services/ChatService.cs | 8 +++-- src/EchoHub.Tests/Irc/TestHelpers.cs | 2 +- 10 files changed, 110 insertions(+), 12 deletions(-) diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs index fc47c3c..2e8e4c1 100644 --- a/src/EchoHub.Client/AppOrchestrator.cs +++ b/src/EchoHub.Client/AppOrchestrator.cs @@ -30,6 +30,7 @@ public sealed class AppOrchestrator : IDisposable private readonly ConnectionManager _conn = new(); private readonly Dictionary> _channelUsers = new(StringComparer.OrdinalIgnoreCase); private readonly Lock _channelUsersLock = new(); + private readonly HashSet _channelsLoadingMore = new(StringComparer.OrdinalIgnoreCase); private ClientConfig _config; private readonly UserSession _session = new(); @@ -92,6 +93,7 @@ public sealed class AppOrchestrator : IDisposable _mainWindow.OnUserProfileRequested += HandleViewProfile; _mainWindow.OnChannelJoinRequested += HandleChannelJoinFromMessage; _mainWindow.OnSearchRequested += HandleSearchRequested; + _mainWindow.OnLoadMoreRequested += HandleLoadMoreRequested; } // ── Command Handler Wiring ───────────────────────────────────────────── @@ -721,6 +723,31 @@ public sealed class AppOrchestrator : IDisposable }, "Failed to join channel"); } + private void HandleLoadMoreRequested() + { + if (!_conn.IsConnected) return; + + var channel = _mainWindow.CurrentChannel; + if (string.IsNullOrEmpty(channel)) return; + + if (!_channelsLoadingMore.Add(channel)) return; + + var offset = _messageManager.GetMessages(channel)?.Count ?? 0; + + RunAsync(async () => + { + try + { + var history = await _conn.GetHistoryAsync(channel, HubConstants.DefaultHistoryCount, offset); + InvokeUI(() => _messageManager.PrependHistory(channel, history)); + } + finally + { + _channelsLoadingMore.Remove(channel); + } + }, "Failed to load more messages"); + } + private void HandleChannelJoinFromMessage(string channelName) { if (!_conn.IsConnected) return; diff --git a/src/EchoHub.Client/Services/ConnectionManager.cs b/src/EchoHub.Client/Services/ConnectionManager.cs index ebb4285..381be64 100644 --- a/src/EchoHub.Client/Services/ConnectionManager.cs +++ b/src/EchoHub.Client/Services/ConnectionManager.cs @@ -196,8 +196,8 @@ internal sealed class ConnectionManager : IAsyncDisposable _connection?.SendMessageAsync(channel, content) ?? throw new InvalidOperationException("Not connected"); - public Task> GetHistoryAsync(string channel) => - _connection?.GetHistoryAsync(channel) + public Task> GetHistoryAsync(string channel, int count = HubConstants.DefaultHistoryCount, int offset = 0) => + _connection?.GetHistoryAsync(channel, count, offset) ?? throw new InvalidOperationException("Not connected"); public Task> GetOnlineUsersAsync(string channel) => diff --git a/src/EchoHub.Client/Services/EchoHubConnection.cs b/src/EchoHub.Client/Services/EchoHubConnection.cs index c79fccd..5ab24e8 100644 --- a/src/EchoHub.Client/Services/EchoHubConnection.cs +++ b/src/EchoHub.Client/Services/EchoHubConnection.cs @@ -154,9 +154,9 @@ public sealed class EchoHubConnection : IAsyncDisposable await _connection.InvokeAsync("SendMessage", channelName, encrypted); } - public async Task> GetHistoryAsync(string channelName, int count = HubConstants.DefaultHistoryCount) + public async Task> GetHistoryAsync(string channelName, int count = HubConstants.DefaultHistoryCount, int offset = 0) { - var messages = await _connection.InvokeAsync>("GetChannelHistory", channelName, count); + var messages = await _connection.InvokeAsync>("GetChannelHistory", channelName, count, offset); return DecryptMessages(messages); } diff --git a/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs b/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs index 440b57e..cccd834 100644 --- a/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs +++ b/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs @@ -186,6 +186,39 @@ public sealed class ChatMessageManager MessagesChanged?.Invoke(channelName); } + /// + /// Prepend older messages at the front of a channel's buffer, skipping any that are already present. + /// Fires when new lines are actually inserted. + /// + public void PrependHistory(string channelName, List olderMessages) + { + if (!_channelMessages.TryGetValue(channelName, out var existing)) + return; + + var existingIds = existing + .Where(l => l.MessageId.HasValue) + .Select(l => l.MessageId!.Value) + .ToHashSet(); + + var newLines = olderMessages + .Where(m => !existingIds.Contains(m.Id)) + .SelectMany(FormatMessage) + .ToList(); + + if (newLines.Count == 0) + return; + + existing.InsertRange(0, newLines); + + if (channelName == _currentChannel) + HistoryPrepended?.Invoke(channelName); + } + + /// + /// Fired after older messages are prepended to a channel's buffer. Parameter is the channel name. + /// + public event Action? HistoryPrepended; + /// /// Reset all message state (used on disconnect). /// diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index a30230b..919a4a0 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Text.RegularExpressions; using EchoHub.Client.Services; using EchoHub.Client.Themes; @@ -117,6 +118,11 @@ public sealed partial class MainWindow : Runnable /// public event Action? OnSavedServersRequested; + /// + /// Fired when the user scrolls to the top of the message list and older messages should be loaded. + /// + public event Action? OnLoadMoreRequested; + /// /// Fired when the user requests to create a new channel. /// @@ -162,6 +168,7 @@ public sealed partial class MainWindow : Runnable _app = app; _messageManager = messageManager; _messageManager.MessagesChanged += OnMessagesChanged; + _messageManager.HistoryPrepended += OnHistoryPrepended; Arrangement = ViewArrangement.Fixed; // Menu bar at the top @@ -222,6 +229,9 @@ public sealed partial class MainWindow : Runnable }; _messageList.Source = new ChatListSource(); _messageList.Accepting += OnMessageListAccepting; + _messageList.VerticalScrollBar.Scrolled += OnMessageListVerticalScrollBarScrolled; + _messageList.VerticalScrollBar.Visible = true; + _chatFrame.Add(_messageList); Add(_chatFrame); @@ -478,6 +488,12 @@ public sealed partial class MainWindow : Runnable } } + private void OnMessageListVerticalScrollBarScrolled(object? sender, EventArgs e) + { + if (_messageList.VerticalScrollBar.Value == 0) + OnLoadMoreRequested?.Invoke(); + } + private void OnUsersListAccepting(object? sender, CommandEventArgs e) { var index = _usersList.SelectedItem; @@ -631,6 +647,26 @@ public sealed partial class MainWindow : Runnable RefreshChannelList(); } + private void OnHistoryPrepended(string channelName) + { + if (channelName != _messageManager.CurrentChannel) + return; + + var messages = _messageManager.GetMessages(channelName); + if (messages is null) + return; + + var oldCount = (_messageList.Source as ChatListSource)?.Count ?? 0; + + RefreshMessages(); + + // Scroll to the item that was at the top before the prepend so the user + // stays at their previous reading position rather than jumping to the top. + var prependedCount = (_messageList.Source as ChatListSource)?.Count - oldCount; + if (prependedCount > 0) + _messageList.SelectedItem = prependedCount; + } + /// /// Set the list of available channels, storing topics, and refresh the channel list view. /// diff --git a/src/EchoHub.Core/Constants/ValidationConstants.cs b/src/EchoHub.Core/Constants/ValidationConstants.cs index 266c874..fd8659b 100644 --- a/src/EchoHub.Core/Constants/ValidationConstants.cs +++ b/src/EchoHub.Core/Constants/ValidationConstants.cs @@ -13,7 +13,7 @@ public static partial class ValidationConstants public const int MaxBioLength = 500; public const int MaxStatusMessageLength = 100; public const int MaxChannelTopicLength = 500; - public const int MaxHistoryCount = 100; + public const int MaxHistoryCount = 200; [GeneratedRegex(UsernamePattern)] public static partial Regex UsernameRegex(); diff --git a/src/EchoHub.Core/Contracts/IChatService.cs b/src/EchoHub.Core/Contracts/IChatService.cs index 2c1e0cb..4b10f28 100644 --- a/src/EchoHub.Core/Contracts/IChatService.cs +++ b/src/EchoHub.Core/Contracts/IChatService.cs @@ -15,7 +15,7 @@ public interface IChatService // Messaging Task SendMessageAsync(Guid userId, string username, string channelName, string content); - Task> GetChannelHistoryAsync(string channelName, int count); + Task> GetChannelHistoryAsync(string channelName, int count, int offset = 0); // Presence Task UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage); diff --git a/src/EchoHub.Server/Hubs/ChatHub.cs b/src/EchoHub.Server/Hubs/ChatHub.cs index eb73a7a..723ac07 100644 --- a/src/EchoHub.Server/Hubs/ChatHub.cs +++ b/src/EchoHub.Server/Hubs/ChatHub.cs @@ -106,11 +106,11 @@ public class ChatHub : Hub } } - public async Task> GetChannelHistory(string channelName, int count = HubConstants.DefaultHistoryCount) + public async Task> GetChannelHistory(string channelName, int count = HubConstants.DefaultHistoryCount, int offset = 0) { try { - return await _chatService.GetChannelHistoryAsync(channelName, count); + return await _chatService.GetChannelHistoryAsync(channelName, count, offset); } catch (Exception ex) { diff --git a/src/EchoHub.Server/Services/ChatService.cs b/src/EchoHub.Server/Services/ChatService.cs index cf7bb17..a7bf032 100644 --- a/src/EchoHub.Server/Services/ChatService.cs +++ b/src/EchoHub.Server/Services/ChatService.cs @@ -245,15 +245,16 @@ public class ChatService : IChatService return null; } - public async Task> GetChannelHistoryAsync(string channelName, int count) + public async Task> GetChannelHistoryAsync(string channelName, int count, int offset = 0) { channelName = channelName.ToLowerInvariant().Trim(); count = Math.Clamp(count, 1, ValidationConstants.MaxHistoryCount); + offset = Math.Max(offset, 0); using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - return await GetChannelHistoryInternalAsync(db, channelName, count); + return await GetChannelHistoryInternalAsync(db, channelName, count, offset); } public async Task UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage) @@ -366,7 +367,7 @@ public class ChatService : IChatService return string.Join('\n', result); } - private async Task> GetChannelHistoryInternalAsync(EchoHubDbContext db, string channelName, int count) + private async Task> GetChannelHistoryInternalAsync(EchoHubDbContext db, string channelName, int count, int offset = 0) { var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); if (channel is null) @@ -375,6 +376,7 @@ public class ChatService : IChatService var raw = await db.Messages .Where(m => m.ChannelId == channel.Id) .OrderByDescending(m => m.SentAt) + .Skip(offset) .Take(count) .Join(db.Users, m => m.SenderUserId, diff --git a/src/EchoHub.Tests/Irc/TestHelpers.cs b/src/EchoHub.Tests/Irc/TestHelpers.cs index 358dec2..7c5407e 100644 --- a/src/EchoHub.Tests/Irc/TestHelpers.cs +++ b/src/EchoHub.Tests/Irc/TestHelpers.cs @@ -190,7 +190,7 @@ internal sealed class FakeChatService : IChatService return Task.FromResult(SendMessageError); } - public Task> GetChannelHistoryAsync(string channelName, int count) => + public Task> GetChannelHistoryAsync(string channelName, int count, int offset = 0) => Task.FromResult(HistoryToReturn); public Task UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage) From 83d257591efb75d73e4b227c41662eb6df6722f8 Mon Sep 17 00:00:00 2001 From: Stone_Red <56473591+Stone-Red-Code@users.noreply.github.com> Date: Wed, 25 Feb 2026 14:48:57 +0100 Subject: [PATCH 12/15] fix: error when playing notification sounds in quick succession --- .../Services/NotificationSoundService.cs | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/EchoHub.Client/Services/NotificationSoundService.cs b/src/EchoHub.Client/Services/NotificationSoundService.cs index 2118d40..e72e0b2 100644 --- a/src/EchoHub.Client/Services/NotificationSoundService.cs +++ b/src/EchoHub.Client/Services/NotificationSoundService.cs @@ -6,7 +6,11 @@ namespace EchoHub.Client.Services; public class NotificationSoundService { + // Safety net: if PlaybackFinished never fires we don't want to block future notifications forever. + private static readonly TimeSpan PlaybackTimeout = TimeSpan.FromSeconds(10); + private readonly Player _player = new(); + private readonly SemaphoreSlim _lock = new(1, 1); private readonly NotificationConfig _config; private string? _resolvedSoundPath; @@ -41,18 +45,31 @@ public class NotificationSoundService private async Task PlayInternal() { + await _lock.WaitAsync(); + + // _player.Play returns as soon as playback starts, so we wait on PlaybackFinished + // to hold the lock for the duration of the sound. A one-shot handler + timeout + // keeps the finally release robust: never-fires → timeout; fires twice → ignored + // (TrySetResult); handler throws → caller's catch still runs finally. + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + void OnFinished(object? s, EventArgs e) => completion.TrySetResult(); + _player.PlaybackFinished += OnFinished; + try { - if (_player.Playing) - await _player.Stop(); - await _player.SetVolume(_config.Volume); await _player.Play(_resolvedSoundPath!); + await Task.WhenAny(completion.Task, Task.Delay(PlaybackTimeout)); } catch (Exception ex) { Log.Warning(ex, "Failed to play notification sound"); } + finally + { + _player.PlaybackFinished -= OnFinished; + _lock.Release(); + } } private void ResolveSoundPath() From 6e7cbf39f02242522949caa02a29c82be25a7082 Mon Sep 17 00:00:00 2001 From: Stone_Red <56473591+Stone-Red-Code@users.noreply.github.com> Date: Wed, 25 Feb 2026 22:36:44 +0100 Subject: [PATCH 13/15] chore: add channel history loading and refactor GetChannelHistory with offset to changelog --- docs/changelog/v0.2.10.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/changelog/v0.2.10.md b/docs/changelog/v0.2.10.md index 1023f5a..20b9f48 100644 --- a/docs/changelog/v0.2.10.md +++ b/docs/changelog/v0.2.10.md @@ -1,10 +1,11 @@ # v0.2.10 -Follow-up patch release for v0.2.9 addressing auto-updater regressions, adding a command palette, and input polish. +Follow-up patch release for v0.2.9 addressing auto-updater regressions, adding a command palette, infinite-scroll message history, and input polish. ## New Features - Command palette — press Ctrl+K from the message input (or anywhere in the main window) to open a searchable dialog for navigating channels and triggering app actions (connect, disconnect, logout, profile, status, create/delete channel, saved servers, toggle users panel, check for updates, quit). Fuzzy matches against both the label and the underlying key so typing `ch` surfaces channel actions alongside `#channel` entries +- Scroll-to-load message history — scrolling to the top of a channel now fetches the next batch of older messages in the background (previously only the most recent 100 messages were available). Duplicate messages are filtered by ID, a per-channel guard prevents concurrent fetches, and the scroll position is preserved after the prepend so your reading position doesn't jump ## Bug Fixes @@ -16,3 +17,5 @@ Follow-up patch release for v0.2.9 addressing auto-updater regressions, adding a ## Refactoring - Move search-dialog dispatch out of `MainWindow` into `AppOrchestrator` — `MainWindow` now just raises `OnSearchRequested`, keeping the view dumb and letting the orchestrator own navigation/action routing +- `ChatHub.GetChannelHistory` and `IChatService.GetChannelHistoryAsync` gain an additional `offset` parameter for paginated history loading (defaults to `0` — existing callers are unaffected) +- `ValidationConstants.MaxHistoryCount` raised from `100` to `200` so power users and paginated fetches can request larger batches; `DefaultHistoryCount` stays at `100` From 240892495bc9d1d0fc299ddfa0d44a34664a2edd Mon Sep 17 00:00:00 2001 From: Stone_Red <56473591+Stone-Red-Code@users.noreply.github.com> Date: Wed, 25 Feb 2026 22:46:39 +0100 Subject: [PATCH 14/15] chore: add notification bug fix to changelog --- docs/changelog/v0.2.10.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/changelog/v0.2.10.md b/docs/changelog/v0.2.10.md index 20b9f48..3320fa6 100644 --- a/docs/changelog/v0.2.10.md +++ b/docs/changelog/v0.2.10.md @@ -13,6 +13,7 @@ Follow-up patch release for v0.2.9 addressing auto-updater regressions, adding a - Fix pre-update backup failing when a Serilog-held log file is locked — `UpdateBackupService` now enumerates files manually, skips the `logs/` directory and `.log` files, and logs-and-continues on `IOException`/`UnauthorizedAccessException` instead of aborting the whole backup - Simplify update progress dispatch — remove redundant `Application.Invoke` wrappers around progress updates that are already called from the UI thread (introduced while fixing the freeze above) - Fix cursor position being reset to the start of the line when auto-completing commands in the CLI app — insertion point is now moved to the end of the completed text +- Fix notification sounds crashing or being silently dropped when several arrive in quick succession — playback is now serialized through a semaphore that's held for the duration of each sound (using `PlaybackFinished` with a 10s safety timeout) and always released in `finally`, so back-to-back notifications queue up and play in order instead of racing the underlying audio player (fixes #20) ## Refactoring From 9279e8be06d6796a3b1664a8bfa99e7b9bfb415b Mon Sep 17 00:00:00 2001 From: HueByte Date: Mon, 20 Apr 2026 17:35:48 +0200 Subject: [PATCH 15/15] fix: update release notes for v0.2.10 to reflect Command Palette and Infinite History Scroll features --- docs/changelog/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 4deb405..d29fb82 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -4,7 +4,7 @@ Release history for EchoHub. ## Releases -- [v0.2.10](v0.2.10.md) - Auto-Updater Hotfixes +- [v0.2.10](v0.2.10.md) - Command Palette, Infinite History Scroll & Auto-Updater Fixes - [v0.2.9](v0.2.9.md) - Install Script & Chocolatey Fixes - [v0.2.8](v0.2.8.md) - Docker Support, IRC Account Creation & BOM Fix - [v0.2.7](v0.2.7.md) - User List Fix & Terminal.Gui NuGet Migration