diff --git a/.github/workflows/ci-release.yml b/.github/workflows/ci-release.yml index 33396f3..66379e6 100644 --- a/.github/workflows/ci-release.yml +++ b/.github/workflows/ci-release.yml @@ -73,86 +73,88 @@ jobs: - name: Test run: dotnet test src/EchoHub.slnx --no-build --configuration Release --verbosity normal - release: - name: Create Release - needs: [lint-markdown, detect-changes, test] - if: needs.detect-changes.outputs.src_changed == 'true' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Setup .NET 10 - uses: actions/setup-dotnet@v4 - with: - dotnet-version: '10.0.x' - - - name: Read version - id: version - run: | - VERSION=$(grep -oP '(?<=)[^<]+' src/Directory.Build.props) - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "tag=v$VERSION" >> "$GITHUB_OUTPUT" - - - name: Check if release exists - id: check_release - run: | - if gh release view "${{ steps.version.outputs.tag }}" &>/dev/null; then - echo "exists=true" >> "$GITHUB_OUTPUT" - else - echo "exists=false" >> "$GITHUB_OUTPUT" - fi - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Publish Server win-x64 - if: steps.check_release.outputs.exists == 'false' - run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r win-x64 --self-contained true -o publish/server-win-x64 - - - name: Publish Server linux-x64 - if: steps.check_release.outputs.exists == 'false' - run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r linux-x64 --self-contained true -o publish/server-linux-x64 - - - name: Publish Server osx-x64 - if: steps.check_release.outputs.exists == 'false' - run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r osx-x64 --self-contained true -o publish/server-osx-x64 - - - name: Publish Client win-x64 - if: steps.check_release.outputs.exists == 'false' - run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r win-x64 --self-contained true -o publish/client-win-x64 - - - name: Publish Client linux-x64 - if: steps.check_release.outputs.exists == 'false' - run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r linux-x64 --self-contained true -o publish/client-linux-x64 - - - name: Publish Client osx-x64 - if: steps.check_release.outputs.exists == 'false' - run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r osx-x64 --self-contained true -o publish/client-osx-x64 - - - name: Zip artifacts - if: steps.check_release.outputs.exists == 'false' - run: | - cd publish - zip -r ../EchoHub-Server-win-x64.zip server-win-x64/ - zip -r ../EchoHub-Server-linux-x64.zip server-linux-x64/ - zip -r ../EchoHub-Server-osx-x64.zip server-osx-x64/ - zip -r ../EchoHub-Client-win-x64.zip client-win-x64/ - zip -r ../EchoHub-Client-linux-x64.zip client-linux-x64/ - zip -r ../EchoHub-Client-osx-x64.zip client-osx-x64/ - - - name: Create GitHub Release - if: steps.check_release.outputs.exists == 'false' - run: | - gh release create "${{ steps.version.outputs.tag }}" \ - --title "EchoHub ${{ steps.version.outputs.tag }}" \ - --generate-notes \ - EchoHub-Server-win-x64.zip \ - EchoHub-Server-linux-x64.zip \ - EchoHub-Server-osx-x64.zip \ - EchoHub-Client-win-x64.zip \ - EchoHub-Client-linux-x64.zip \ - EchoHub-Client-osx-x64.zip - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # NOTE: Release job disabled until workflows are verified. + # Uncomment to enable automatic releases with tags. + # release: + # name: Create Release + # needs: [lint-markdown, detect-changes, test] + # if: needs.detect-changes.outputs.src_changed == 'true' + # runs-on: ubuntu-latest + # steps: + # - uses: actions/checkout@v4 + # + # - name: Setup .NET 10 + # uses: actions/setup-dotnet@v4 + # with: + # dotnet-version: '10.0.x' + # + # - name: Read version + # id: version + # run: | + # VERSION=$(grep -oP '(?<=)[^<]+' src/Directory.Build.props) + # echo "version=$VERSION" >> "$GITHUB_OUTPUT" + # echo "tag=v$VERSION" >> "$GITHUB_OUTPUT" + # + # - name: Check if release exists + # id: check_release + # run: | + # if gh release view "${{ steps.version.outputs.tag }}" &>/dev/null; then + # echo "exists=true" >> "$GITHUB_OUTPUT" + # else + # echo "exists=false" >> "$GITHUB_OUTPUT" + # fi + # env: + # GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # + # - name: Publish Server win-x64 + # if: steps.check_release.outputs.exists == 'false' + # run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r win-x64 --self-contained true -o publish/server-win-x64 + # + # - name: Publish Server linux-x64 + # if: steps.check_release.outputs.exists == 'false' + # run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r linux-x64 --self-contained true -o publish/server-linux-x64 + # + # - name: Publish Server osx-x64 + # if: steps.check_release.outputs.exists == 'false' + # run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r osx-x64 --self-contained true -o publish/server-osx-x64 + # + # - name: Publish Client win-x64 + # if: steps.check_release.outputs.exists == 'false' + # run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r win-x64 --self-contained true -o publish/client-win-x64 + # + # - name: Publish Client linux-x64 + # if: steps.check_release.outputs.exists == 'false' + # run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r linux-x64 --self-contained true -o publish/client-linux-x64 + # + # - name: Publish Client osx-x64 + # if: steps.check_release.outputs.exists == 'false' + # run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r osx-x64 --self-contained true -o publish/client-osx-x64 + # + # - name: Zip artifacts + # if: steps.check_release.outputs.exists == 'false' + # run: | + # cd publish + # zip -r ../EchoHub-Server-win-x64.zip server-win-x64/ + # zip -r ../EchoHub-Server-linux-x64.zip server-linux-x64/ + # zip -r ../EchoHub-Server-osx-x64.zip server-osx-x64/ + # zip -r ../EchoHub-Client-win-x64.zip client-win-x64/ + # zip -r ../EchoHub-Client-linux-x64.zip client-linux-x64/ + # zip -r ../EchoHub-Client-osx-x64.zip client-osx-x64/ + # + # - name: Create GitHub Release + # if: steps.check_release.outputs.exists == 'false' + # run: | + # gh release create "${{ steps.version.outputs.tag }}" \ + # --title "EchoHub ${{ steps.version.outputs.tag }}" \ + # --generate-notes \ + # EchoHub-Server-win-x64.zip \ + # EchoHub-Server-linux-x64.zip \ + # EchoHub-Server-osx-x64.zip \ + # EchoHub-Client-win-x64.zip \ + # EchoHub-Client-linux-x64.zip \ + # EchoHub-Client-osx-x64.zip + # env: + # GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} docs: name: Build & Deploy Docs diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs index 0875670..829426b 100644 --- a/src/EchoHub.Client/AppOrchestrator.cs +++ b/src/EchoHub.Client/AppOrchestrator.cs @@ -203,7 +203,11 @@ public sealed class AppOrchestrator : IDisposable try { await _apiClient!.UpdateChannelTopicAsync(channel, topic); - InvokeUI(() => _mainWindow.AddSystemMessage(channel, $"Topic set to: {topic}")); + InvokeUI(() => + { + _mainWindow.SetChannelTopic(channel, topic); + _mainWindow.AddSystemMessage(channel, $"Topic set to: {topic}"); + }); } catch (Exception ex) { diff --git a/src/EchoHub.Client/UI/ChatRenderer.cs b/src/EchoHub.Client/UI/ChatRenderer.cs index aa72b5c..911c595 100644 --- a/src/EchoHub.Client/UI/ChatRenderer.cs +++ b/src/EchoHub.Client/UI/ChatRenderer.cs @@ -190,6 +190,15 @@ public class ChatListSource : IListDataSource public void Dispose() { } } +/// +/// Shared color attributes for chat rendering (timestamps, system messages). +/// +public static class ChatColors +{ + public static readonly Attribute TimestampAttr = new(Color.DarkGray, Color.Black); + public static readonly Attribute SystemAttr = new(new Color(0, 180, 180), Color.Black); +} + /// /// Helper to parse hex colors to Terminal.Gui Attributes. /// diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index 72da4c8..ff2eadd 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -22,15 +22,27 @@ public sealed class MainWindow : Runnable private readonly TextView _inputField; private readonly FrameView _chatFrame; private readonly Label _statusLabel; + private readonly Label _topicLabel; private MenuBar _menuBar; // Cached Key constants — compare via .KeyCode to avoid Key.Equals (which also checks Handled) private static readonly Key EnterKey = Key.Enter; private static readonly Key AltEnterKey = Key.Enter.WithAlt; private static readonly Key CtrlCKey = Key.C.WithCtrl; + private static readonly Key TabKey = Key.Tab; + + // Available slash commands for Tab autocomplete + private static readonly string[] SlashCommands = + [ + "/status", "/nick", "/color", "/theme", "/send", + "/profile", "/servers", "/join", "/leave", "/topic", + "/users", "/quit", "/help" + ]; private readonly List _channelNames = []; private readonly Dictionary> _channelMessages = []; + private readonly Dictionary _channelUnread = []; + private readonly Dictionary _channelTopics = []; private string _currentChannel = string.Empty; private string _currentUser = string.Empty; @@ -110,12 +122,24 @@ public sealed class MainWindow : Runnable channelsFrame.Add(_channelList); Add(channelsFrame); + // Topic bar — sits above the chat frame in the right column + _topicLabel = new Label + { + Text = "", + X = 25, + Y = 1, + Width = Dim.Fill(), + Height = 1, + Visible = false + }; + Add(_topicLabel); + // Center panel - messages _chatFrame = new FrameView { Title = "Chat", X = 25, - Y = 1, // below menu bar + Y = 1, // below menu bar (shifts to 2 when topic is visible) Width = Dim.Fill(), Height = Dim.Fill(6) // leave room for input area and status bar }; @@ -134,7 +158,7 @@ public sealed class MainWindow : Runnable // Bottom input area var inputFrame = new FrameView { - Title = "Message (Enter=send, Alt+Enter=newline)", + Title = "Message (Enter=send, Tab=autocomplete)", X = 25, Y = Pos.Bottom(_chatFrame), Width = Dim.Fill(), @@ -168,7 +192,7 @@ public sealed class MainWindow : Runnable // Apply our custom color schemes to all views ApplyColorSchemes(); - // Window-level key handling for Ctrl+S (send) and Ctrl+C (quit) + // Window-level key handling for Ctrl+C (quit) KeyDown += OnWindowKeyDown; } @@ -188,7 +212,7 @@ public sealed class MainWindow : Runnable // Propagate to all child views that should use the base scheme foreach (var sub in SubViews) { - if (sub != _menuBar && sub != _statusLabel) + if (sub != _menuBar && sub != _statusLabel && sub != _topicLabel) sub.SetScheme(baseScheme); } } @@ -197,6 +221,7 @@ public sealed class MainWindow : Runnable { _menuBar.SetScheme(menuScheme); _statusLabel.SetScheme(menuScheme); + _topicLabel.SetScheme(menuScheme); } } @@ -282,7 +307,12 @@ public sealed class MainWindow : Runnable private void OnInputKeyDown(object? sender, Key e) { - if (e.KeyCode == AltEnterKey.KeyCode) + if (e.KeyCode == TabKey.KeyCode) + { + TryAutocompleteCommand(); + e.Handled = true; + } + else if (e.KeyCode == AltEnterKey.KeyCode) { _inputField.InsertText("\n"); e.Handled = true; @@ -304,6 +334,40 @@ public sealed class MainWindow : Runnable } } + /// + /// Tab-complete slash commands in the input field. + /// + private void TryAutocompleteCommand() + { + var text = _inputField.Text ?? string.Empty; + if (!text.StartsWith('/') || text.Contains(' ')) + return; + + var matches = SlashCommands + .Where(c => c.StartsWith(text, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (matches.Count == 1) + { + _inputField.Text = matches[0] + " "; + } + else if (matches.Count > 1) + { + // Complete to longest common prefix + var prefix = matches[0]; + foreach (var m in matches.Skip(1)) + { + var len = 0; + while (len < prefix.Length && len < m.Length + && char.ToLowerInvariant(prefix[len]) == char.ToLowerInvariant(m[len])) + len++; + prefix = prefix[..len]; + } + if (prefix.Length > text.Length) + _inputField.Text = prefix; + } + } + private void OnWindowKeyDown(object? sender, Key e) { // Ctrl+C quits from anywhere @@ -316,6 +380,7 @@ public sealed class MainWindow : Runnable /// /// Add a message to the specified channel's message list and refresh if it is the current channel. + /// Tracks unread count for non-active channels. /// public void AddMessage(MessageDto message) { @@ -335,20 +400,33 @@ public sealed class MainWindow : Runnable { RefreshMessages(); } + else + { + // Increment unread count for non-active channels + _channelUnread.TryGetValue(message.ChannelName, out var count); + _channelUnread[message.ChannelName] = count + 1; + RefreshChannelList(); + } } /// - /// Add a system/informational message to a channel. + /// Add a system/informational message to a channel with colored styling. /// public void AddSystemMessage(string channelName, string text) { - var formatted = $"[{DateTimeOffset.Now:HH:mm}] ** {text}"; + var time = DateTimeOffset.Now.ToString("HH:mm"); + var segments = new List + { + new($"[{time}] ", ChatColors.TimestampAttr), + new($"** {text}", ChatColors.SystemAttr) + }; + if (!_channelMessages.TryGetValue(channelName, out var messages)) { messages = []; _channelMessages[channelName] = messages; } - messages.Add(new ChatLine(formatted)); + messages.Add(new ChatLine(segments)); if (channelName == _currentChannel) { @@ -357,17 +435,23 @@ public sealed class MainWindow : Runnable } /// - /// Add a status change message to a channel. + /// Add a status change message to a channel with colored styling. /// public void AddStatusMessage(string channelName, string username, string status) { - var formatted = $"[{DateTimeOffset.Now:HH:mm}] ** {username} is now {status}"; + var time = DateTimeOffset.Now.ToString("HH:mm"); + var segments = new List + { + new($"[{time}] ", ChatColors.TimestampAttr), + new($"** {username} is now {status}", ChatColors.SystemAttr) + }; + if (!_channelMessages.TryGetValue(channelName, out var messages)) { messages = []; _channelMessages[channelName] = messages; } - messages.Add(new ChatLine(formatted)); + messages.Add(new ChatLine(segments)); if (channelName == _currentChannel) { @@ -376,18 +460,30 @@ public sealed class MainWindow : Runnable } /// - /// Set the list of available channels and refresh the channel list view. + /// Set the list of available channels, storing topics, and refresh the channel list view. /// public void SetChannels(List channels) { _channelNames.Clear(); + _channelTopics.Clear(); foreach (var ch in channels) { _channelNames.Add(ch.Name); + _channelTopics[ch.Name] = ch.Topic; if (!_channelMessages.ContainsKey(ch.Name)) _channelMessages[ch.Name] = []; } - _channelList.SetSource(new ObservableCollection(_channelNames)); + RefreshChannelList(); + } + + /// + /// Update the topic for a specific channel. + /// + public void SetChannelTopic(string channelName, string? topic) + { + _channelTopics[channelName] = topic; + if (channelName == _currentChannel) + UpdateTopicBar(); } /// @@ -427,13 +523,19 @@ public sealed class MainWindow : Runnable public IReadOnlyList GetChannelNames() => _channelNames.AsReadOnly(); /// - /// Switch the chat view to the given channel. + /// Switch the chat view to the given channel, resetting its unread count and updating the topic bar. /// public void SwitchToChannel(string channelName) { _currentChannel = channelName; _chatFrame.Title = $"#{channelName}"; + + // Reset unread count for this channel + _channelUnread[channelName] = 0; + RefreshChannelList(); + RefreshMessages(); + UpdateTopicBar(); // Update channel list selection var idx = _channelNames.IndexOf(channelName); @@ -469,10 +571,14 @@ public sealed class MainWindow : Runnable { _channelNames.Clear(); _channelMessages.Clear(); + _channelUnread.Clear(); + _channelTopics.Clear(); _currentChannel = string.Empty; _currentUser = string.Empty; _channelList.SetSource(new ObservableCollection(_channelNames)); _chatFrame.Title = "Chat"; + _topicLabel.Visible = false; + _chatFrame.Y = 1; RefreshMessages(); } @@ -500,8 +606,47 @@ public sealed class MainWindow : Runnable } } + /// + /// Refresh the channel list view, showing unread counts next to channel names. + /// + private void RefreshChannelList() + { + var displayNames = _channelNames.Select(name => + { + _channelUnread.TryGetValue(name, out var unread); + return unread > 0 ? $"#{name} ({unread})" : $"#{name}"; + }).ToList(); + + _channelList.SetSource(new ObservableCollection(displayNames)); + + // Restore selection to current channel + var idx = _channelNames.IndexOf(_currentChannel); + if (idx >= 0) + _channelList.SelectedItem = idx; + } + + /// + /// Show or hide the topic bar based on the current channel's topic. + /// + private void UpdateTopicBar() + { + _channelTopics.TryGetValue(_currentChannel, out var topic); + if (!string.IsNullOrWhiteSpace(topic)) + { + _topicLabel.Text = $" Topic: {topic}"; + _topicLabel.Visible = true; + _chatFrame.Y = 2; + } + else + { + _topicLabel.Visible = false; + _chatFrame.Y = 1; + } + } + /// /// Format a message DTO into one or more display lines based on its MessageType. + /// Timestamps are dimmed and sender names are colored. /// private static List FormatMessage(MessageDto message) { @@ -514,7 +659,7 @@ public sealed class MainWindow : Runnable switch (message.Type) { case MessageType.Image: - lines.Add(BuildChatLine($"[{time}] ", senderName, senderColor, " [Image]")); + lines.Add(BuildChatLine(time, senderName, senderColor, " [Image]")); // Content IS the ASCII art — add each line as a separate list item if (!string.IsNullOrWhiteSpace(message.Content)) { @@ -533,16 +678,15 @@ public sealed class MainWindow : Runnable case MessageType.File: var fileName = message.AttachmentFileName ?? "unknown"; var fileContent = !string.IsNullOrWhiteSpace(message.Content) ? $" {message.Content}" : ""; - lines.Add(BuildChatLine($"[{time}] ", senderName, senderColor, $" [File: {fileName}]{fileContent}")); + lines.Add(BuildChatLine(time, senderName, senderColor, $" [File: {fileName}]{fileContent}")); break; case MessageType.Text: default: var contentLines = message.Content.Split('\n'); var firstLine = contentLines[0].TrimEnd('\r'); - lines.Add(BuildChatLine($"[{time}] ", senderName, senderColor, $" {firstLine}")); + lines.Add(BuildChatLine(time, senderName, senderColor, $" {firstLine}")); // Continuation lines indented to align with first line's content - // Prefix is: [HH:mm] + space + senderName + space var indent = new string(' ', $"[{time}] {senderName} ".Length); for (int i = 1; i < contentLines.Length; i++) { @@ -555,17 +699,14 @@ public sealed class MainWindow : Runnable } /// - /// Build a chat line with an optionally colored sender name. + /// Build a chat line with a dimmed timestamp and optionally colored sender name. /// - private static ChatLine BuildChatLine(string prefix, string senderName, Attribute? senderColor, string suffix) + private static ChatLine BuildChatLine(string time, string senderName, Attribute? senderColor, string suffix) { - if (senderColor is null) - return new ChatLine($"{prefix}{senderName}{suffix}"); - var segments = new List { - new(prefix, null), - new(senderName, senderColor.Value), + new($"[{time}] ", ChatColors.TimestampAttr), + new(senderName, senderColor), new(suffix, null) }; return new ChatLine(segments);