From c63b1af0803db830913f86603e6d5e6da0ac917e Mon Sep 17 00:00:00 2001 From: FromSi Date: Fri, 22 May 2026 02:23:38 +0500 Subject: [PATCH 1/3] feat(tui): add log panel --- Makefile | 5 +- i18n/locales/en.json | 4 +- internal/logging/buffer.go | 79 +++++++++++++++ internal/logging/buffer_test.go | 73 ++++++++++++++ internal/logging/logger.go | 16 +++ internal/loglevel/level.go | 4 +- internal/loglevel/level_test.go | 4 +- main.go | 169 ++++++++++++++++++++++++-------- main_test.go | 20 ++++ tui/log_panel.go | 70 +++++++++++++ 10 files changed, 397 insertions(+), 47 deletions(-) create mode 100644 internal/logging/buffer.go create mode 100644 internal/logging/buffer_test.go create mode 100644 internal/logging/logger.go create mode 100644 tui/log_panel.go diff --git a/Makefile b/Makefile index dc632fc..600e962 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build test run clean lint fmt vet build-full generate_screenshots +.PHONY: build test run run-log clean lint fmt vet build-full generate_screenshots BINARY_NAME=matcha BUILD_DIR=bin @@ -36,6 +36,9 @@ build-full: run: go run . +run-log: + go run . --debug --logs + test: go test ./... diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 48355f9..f553c1e 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -14,7 +14,9 @@ "previous": "Previous", "loading": "Loading...", "error": "Error", - "success": "Success" + "success": "Success", + "logs": "Logs", + "no_logs_yet": "No logs yet" }, "composer": { "title": "Compose New Email", diff --git a/internal/logging/buffer.go b/internal/logging/buffer.go new file mode 100644 index 0000000..20b30a2 --- /dev/null +++ b/internal/logging/buffer.go @@ -0,0 +1,79 @@ +package logging + +import ( + "strings" + "sync" +) + +type Buffer struct { + mu sync.Mutex + maxEntries int + entries []Entry + subs []chan Entry +} + +func NewBuffer(maxEntries int) *Buffer { + if maxEntries < 1 { + maxEntries = DefaultMaxEntries + } + return &Buffer{maxEntries: maxEntries} +} + +func (b *Buffer) MaxEntries() int { + return b.maxEntries +} + +func (b *Buffer) Write(p []byte) (int, error) { + for _, line := range strings.Split(strings.TrimRight(string(p), "\n"), "\n") { + if line == "" { + continue + } + b.append(Entry{Text: line}) + } + return len(p), nil +} + +func (b *Buffer) Subscribe() <-chan Entry { + ch := make(chan Entry, 64) + b.mu.Lock() + b.subs = append(b.subs, ch) + b.mu.Unlock() + return ch +} + +func (b *Buffer) Tail(n int) []Entry { + b.mu.Lock() + defer b.mu.Unlock() + + if n <= 0 || len(b.entries) == 0 { + return nil + } + if n > len(b.entries) { + n = len(b.entries) + } + + start := len(b.entries) - n + entries := make([]Entry, n) + copy(entries, b.entries[start:]) + return entries +} + +func (b *Buffer) append(entry Entry) { + b.mu.Lock() + if len(b.entries) >= b.maxEntries { + copy(b.entries, b.entries[1:]) + b.entries[len(b.entries)-1] = entry + } else { + b.entries = append(b.entries, entry) + } + + subs := append([]chan Entry(nil), b.subs...) + b.mu.Unlock() + + for _, ch := range subs { + select { + case ch <- entry: + default: + } + } +} diff --git a/internal/logging/buffer_test.go b/internal/logging/buffer_test.go new file mode 100644 index 0000000..1ae819f --- /dev/null +++ b/internal/logging/buffer_test.go @@ -0,0 +1,73 @@ +package logging + +import "testing" + +func TestBufferStoresLines(t *testing.T) { + buffer := NewBuffer(DefaultMaxEntries) + + if _, err := buffer.Write([]byte("first\nsecond\n")); err != nil { + t.Fatalf("Write returned error: %v", err) + } + + got := buffer.Tail(DefaultMaxEntries) + if len(got) != 2 { + t.Fatalf("Tail returned %d entries, want 2", len(got)) + } + if got[0].Text != "first" || got[1].Text != "second" { + t.Fatalf("unexpected entries: %+v", got) + } +} + +func TestBufferKeepsLastMaxEntries(t *testing.T) { + buffer := NewBuffer(DefaultMaxEntries) + + for i := 0; i < DefaultMaxEntries+2; i++ { + if _, err := buffer.Write([]byte{byte('a' + i), '\n'}); err != nil { + t.Fatalf("Write returned error: %v", err) + } + } + + got := buffer.Tail(DefaultMaxEntries) + if len(got) != DefaultMaxEntries { + t.Fatalf("Tail returned %d entries, want %d", len(got), DefaultMaxEntries) + } + if got[0].Text != "c" { + t.Fatalf("first retained entry = %q, want %q", got[0].Text, "c") + } +} + +func TestBufferTailReturnsRequestedCount(t *testing.T) { + buffer := NewBuffer(DefaultMaxEntries) + + for _, line := range []string{"first\n", "second\n", "third\n"} { + if _, err := buffer.Write([]byte(line)); err != nil { + t.Fatalf("Write returned error: %v", err) + } + } + + got := buffer.Tail(2) + if len(got) != 2 { + t.Fatalf("Tail returned %d entries, want 2", len(got)) + } + if got[0].Text != "second" || got[1].Text != "third" { + t.Fatalf("unexpected entries: %+v", got) + } +} + +func TestBufferTailReturnsNilForNonPositiveCount(t *testing.T) { + buffer := NewBuffer(DefaultMaxEntries) + + if _, err := buffer.Write([]byte("first\n")); err != nil { + t.Fatalf("Write returned error: %v", err) + } + if got := buffer.Tail(0); got != nil { + t.Fatalf("Tail(0) returned %+v, want nil", got) + } +} + +func TestNewBufferUsesDefaultForInvalidMax(t *testing.T) { + buffer := NewBuffer(0) + if got := buffer.MaxEntries(); got != DefaultMaxEntries { + t.Fatalf("MaxEntries = %d, want %d", got, DefaultMaxEntries) + } +} diff --git a/internal/logging/logger.go b/internal/logging/logger.go new file mode 100644 index 0000000..1b433ea --- /dev/null +++ b/internal/logging/logger.go @@ -0,0 +1,16 @@ +package logging + +import "io" + +const DefaultMaxEntries = 10 + +type Entry struct { + Text string +} + +type Logger interface { + io.Writer + MaxEntries() int + Tail(n int) []Entry + Subscribe() <-chan Entry +} diff --git a/internal/loglevel/level.go b/internal/loglevel/level.go index d3279c7..422e426 100644 --- a/internal/loglevel/level.go +++ b/internal/loglevel/level.go @@ -36,12 +36,12 @@ func Debugf(format string, args ...any) { func Verbosef(format string, args ...any) { if Get() >= LevelVerbose { - log.Printf(format, args...) + log.Printf("verbose: "+format, args...) } } func Infof(format string, args ...any) { if Get() >= LevelInfo { - log.Printf(format, args...) + log.Printf("info: "+format, args...) } } diff --git a/internal/loglevel/level_test.go b/internal/loglevel/level_test.go index 2db27bf..62a033f 100644 --- a/internal/loglevel/level_test.go +++ b/internal/loglevel/level_test.go @@ -60,7 +60,7 @@ func TestVerbosefRequiresVerboseLevel(t *testing.T) { output := captureLog(t, func() { Verbosef("more details") }) - if !strings.Contains(output, "more details") { + if !strings.Contains(output, "verbose: more details") { t.Fatalf("Verbosef did not write at level %v: %q", level, output) } } @@ -79,7 +79,7 @@ func TestInfofRequiresInfoLevel(t *testing.T) { output = captureLog(t, func() { Infof("hello") }) - if !strings.Contains(output, "hello") { + if !strings.Contains(output, "info: hello") { t.Fatalf("Infof did not write at info level: %q", output) } } diff --git a/main.go b/main.go index 5d54f53..dba5b4a 100644 --- a/main.go +++ b/main.go @@ -27,6 +27,7 @@ import ( "unicode/utf8" tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/floatpane/matcha/backend" _ "github.com/floatpane/matcha/backend/imap" _ "github.com/floatpane/matcha/backend/jmap" @@ -44,6 +45,7 @@ import ( "github.com/floatpane/matcha/i18n" _ "github.com/floatpane/matcha/i18n/languages" "github.com/floatpane/matcha/internal/httpclient" + "github.com/floatpane/matcha/internal/logging" "github.com/floatpane/matcha/internal/loglevel" "github.com/floatpane/matcha/notify" "github.com/floatpane/matcha/plugin" @@ -112,6 +114,14 @@ type mainModel struct { pendingPrompt *plugin.PendingPrompt // mailto: URL parsed from os.Args mailtoURL *url.URL + // Optional in-app log panel. + showLogPanel bool + logCh <-chan logging.Entry + logPanel *tui.LogPanel +} + +type logEntryMsg struct { + entry logging.Entry } func newInitialModel(cfg *config.Config, mailtoURL *url.URL) *mainModel { @@ -203,7 +213,18 @@ func (m *mainModel) getProvider(acct *config.Account) backend.Provider { } func (m *mainModel) Init() tea.Cmd { - return tea.Batch(m.current.Init(), checkForUpdatesCmd()) + cmds := []tea.Cmd{m.current.Init(), checkForUpdatesCmd()} + if m.showLogPanel && m.logCh != nil { + cmds = append(cmds, waitForLogEntry(m.logCh)) + } + return tea.Batch(cmds...) +} + +func waitForLogEntry(ch <-chan logging.Entry) tea.Cmd { + return func() tea.Msg { + entry := <-ch + return logEntryMsg{entry: entry} + } } func (m *mainModel) syncUnreadBadge() { @@ -237,6 +258,18 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { filterWasActive := false splitWasOpen := false + if msg, ok := msg.(logEntryMsg); ok { + _ = msg.entry + return m, waitForLogEntry(m.logCh) + } + + if msg, ok := msg.(tea.WindowSizeMsg); ok { + m.width = msg.Width + m.height = msg.Height + m.current, cmd = m.current.Update(m.currentWindowSize()) + return m, cmd + } + if keyMsg, ok := msg.(tea.KeyPressMsg); ok && keyMsg.String() == config.Keybinds.Global.Cancel { switch current := m.current.(type) { case *tui.Inbox: @@ -271,11 +304,6 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - return m, nil - case tea.KeyPressMsg: if msg.String() == "ctrl+c" { m.idleWatcher.StopAll() @@ -294,7 +322,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.idleWatcher.StopAll() m.current = tui.NewChoice() - m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + m.current, _ = m.current.Update(m.currentWindowSize()) return m, m.current.Init() } } @@ -304,7 +332,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.current = m.folderInbox } else { m.current = tui.NewChoice() - m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + m.current, _ = m.current.Update(m.currentWindowSize()) } return m, nil @@ -316,7 +344,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } m.current = tui.NewChoice() - m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + m.current, _ = m.current.Update(m.currentWindowSize()) return m, nil case tui.DiscardDraftMsg: @@ -330,7 +358,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.current = tui.NewChoice() - m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + m.current, _ = m.current.Update(m.currentWindowSize()) return m, m.current.Init() case tui.OAuth2CompleteMsg: @@ -339,7 +367,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // After OAuth2 flow, go to the choice menu so user can proceed m.current = tui.NewChoice() - m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + m.current, _ = m.current.Update(m.currentWindowSize()) return m, m.current.Init() case tui.Credentials: @@ -470,7 +498,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { m.current = tui.NewChoice() } - m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + m.current, _ = m.current.Update(m.currentWindowSize()) return m, m.current.Init() case tui.GoToInboxMsg: @@ -516,7 +544,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.folderInbox.SetEmails(diskCached, m.config.Accounts) } m.current = m.folderInbox - m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + m.current, _ = m.current.Update(m.currentWindowSize()) // Initialize daemon service if not already set. if m.service == nil { m.service = daemonclient.NewService(m.config) @@ -1052,14 +1080,14 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { m.current = tui.NewComposer("", msg.To, msg.Subject, msg.Body, hideTips) } - m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + m.current, _ = m.current.Update(m.currentWindowSize()) m.syncPluginKeyBindings() return m, m.current.Init() case tui.GoToDraftsMsg: drafts := config.GetAllDrafts() m.current = tui.NewDrafts(drafts) - m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + m.current, _ = m.current.Update(m.currentWindowSize()) return m, m.current.Init() case tui.OpenDraftMsg: @@ -1071,7 +1099,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } composer := tui.NewComposerFromDraft(msg.Draft, accounts, hideTips) m.current = composer - m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + m.current, _ = m.current.Update(m.currentWindowSize()) m.syncPluginKeyBindings() return m, m.current.Init() @@ -1087,7 +1115,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tui.GoToMarketplaceMsg: m.current = tui.NewMarketplace(false) - m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + m.current, _ = m.current.Update(m.currentWindowSize()) return m, m.current.Init() case tui.ConfigSavedMsg: @@ -1125,12 +1153,12 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // For other views, return to choice menu m.current = tui.NewChoice() } - m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + m.current, _ = m.current.Update(m.currentWindowSize()) return m, m.current.Init() case tui.GoToSettingsMsg: m.current = m.newSettings() - m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + m.current, _ = m.current.Update(m.currentWindowSize()) return m, m.current.Init() case tui.GoToAddAccountMsg: @@ -1139,12 +1167,12 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { hideTips = m.config.HideTips } m.current = tui.NewLogin(hideTips) - m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + m.current, _ = m.current.Update(m.currentWindowSize()) return m, m.current.Init() case tui.GoToAddMailingListMsg: m.current = tui.NewMailingListEditor() - m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + m.current, _ = m.current.Update(m.currentWindowSize()) return m, m.current.Init() case tui.GoToEditAccountMsg: @@ -1155,14 +1183,14 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { login := tui.NewLogin(hideTips) login.SetEditMode(msg.AccountID, msg.Protocol, msg.Provider, msg.Name, msg.Email, msg.FetchEmail, msg.SendAsEmail, msg.IMAPServer, msg.IMAPPort, msg.SMTPServer, msg.SMTPPort, msg.Insecure, msg.JMAPEndpoint, msg.POP3Server, msg.POP3Port, msg.CatchAll, msg.MaildirPath) m.current = login - m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + m.current, _ = m.current.Update(m.currentWindowSize()) return m, m.current.Init() case tui.GoToEditMailingListMsg: editor := tui.NewMailingListEditor() editor.SetEditMode(msg.Index, msg.Name, msg.Addresses) m.current = editor - m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + m.current, _ = m.current.Update(m.currentWindowSize()) return m, m.current.Init() case tui.SaveMailingListMsg: @@ -1191,12 +1219,12 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Return to settings m.current = m.newSettings() // Try to navigate to the mailing list view internally if possible, but NewSettings will go to SettingsMain by default. - m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + m.current, _ = m.current.Update(m.currentWindowSize()) return m, m.current.Init() case tui.GoToSignatureEditorMsg: m.current = tui.NewSignatureEditor(msg.AccountID) - m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + m.current, _ = m.current.Update(m.currentWindowSize()) return m, m.current.Init() case tui.PasswordVerifiedMsg: @@ -1247,7 +1275,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.current = tui.NewChoice() } } - m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + m.current, _ = m.current.Update(m.currentWindowSize()) return m, m.current.Init() case tui.SecureModeEnabledMsg: @@ -1264,7 +1292,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tui.GoToChoiceMenuMsg: m.current = tui.NewChoice() - m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + m.current, _ = m.current.Update(m.currentWindowSize()) return m, m.current.Init() case tui.DeleteAccountMsg: @@ -1289,7 +1317,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Go back to settings m.current = m.newSettings() - m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + m.current, _ = m.current.Update(m.currentWindowSize()) } return m, m.current.Init() @@ -1506,7 +1534,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { composer.SetReplyContext(inReplyTo, references) m.current = composer - m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + m.current, _ = m.current.Update(m.currentWindowSize()) m.syncPluginKeyBindings() return m, m.current.Init() @@ -1542,7 +1570,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.current = composer - m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + m.current, _ = m.current.Update(m.currentWindowSize()) m.syncPluginKeyBindings() return m, m.current.Init() @@ -1577,7 +1605,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.previousModel = m.current wd, _ := os.Getwd() m.current = tui.NewFilePicker(wd) - m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + m.current, _ = m.current.Update(m.currentWindowSize()) return m, m.current.Init() case tui.FileSelectedMsg, tui.CancelFilePickerMsg: @@ -1654,7 +1682,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.Err != nil { log.Printf("Failed to send RSVP: %v", msg.Err) m.previousModel = tui.NewChoice() - m.previousModel, _ = m.previousModel.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + m.previousModel, _ = m.previousModel.Update(m.currentWindowSize()) m.current = tui.NewStatus(fmt.Sprintf("RSVP error: %v", msg.Err)) return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg { return tui.RestoreViewMsg{} @@ -1673,7 +1701,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.Err != nil { log.Printf("Failed to send email: %v", msg.Err) m.previousModel = tui.NewChoice() - m.previousModel, _ = m.previousModel.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + m.previousModel, _ = m.previousModel.Update(m.currentWindowSize()) m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err)) return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg { return tui.RestoreViewMsg{} @@ -1683,7 +1711,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.plugins.CallHook(plugin.HookEmailSendAfter) } m.current = tui.NewChoice() - m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + m.current, _ = m.current.Update(m.currentWindowSize()) return m, m.current.Init() case tui.DeleteEmailMsg: @@ -1756,11 +1784,11 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.folderInbox != nil { m.folderInbox.RemoveEmail(msg.UID, msg.AccountID) m.current = m.folderInbox - m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + m.current, _ = m.current.Update(m.currentWindowSize()) return m, m.current.Init() } m.current = tui.NewChoice() - m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + m.current, _ = m.current.Update(m.currentWindowSize()) return m, m.current.Init() case tui.BatchDeleteEmailsMsg: @@ -1915,10 +1943,58 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *mainModel) View() tea.View { v := m.current.View() + if m.showLogPanel { + v.Content = m.renderWithLogPanel(v.Content) + } v.AltScreen = true return v } +func (m *mainModel) currentWindowSize() tea.WindowSizeMsg { + return tea.WindowSizeMsg{ + Width: m.width, + Height: m.contentHeight(), + } +} + +func (m *mainModel) contentHeight() int { + height := m.height - m.logPanelHeight() + if height < 1 { + return 1 + } + return height +} + +func (m *mainModel) renderWithLogPanel(content string) string { + panelHeight := m.logPanelHeight() + if panelHeight == 0 { + return content + } + + contentHeight := m.contentHeight() + + mainContent := lipgloss.NewStyle(). + MaxHeight(contentHeight). + Height(contentHeight). + Render(content) + + if m.logPanel == nil { + return mainContent + } + m.logPanel.SetSize(m.width, panelHeight) + return lipgloss.JoinVertical(lipgloss.Left, mainContent, m.logPanel.View()) +} + +func (m *mainModel) logPanelHeight() int { + if !m.showLogPanel || m.height < 12 || m.width < 20 { + return 0 + } + if m.height < 20 { + return 4 + } + return 7 +} + func (m *mainModel) getEmailByIndex(index int, mailbox tui.MailboxKind) *fetcher.Email { if index >= 0 && index < len(m.emails) { return &m.emails[index] @@ -3917,10 +3993,11 @@ func filterUnique(existing, incoming []fetcher.Email) []fetcher.Email { return unique } -func parseGlobalFlags(args []string) ([]string, loglevel.Level) { +func parseGlobalFlags(args []string) ([]string, loglevel.Level, bool) { level := loglevel.LevelInfo + showLogPanel := false if len(args) <= 1 { - return args, level + return args, level, showLogPanel } filtered := make([]string, 0, len(args)) @@ -3934,17 +4011,19 @@ func parseGlobalFlags(args []string) ([]string, loglevel.Level) { if level < loglevel.LevelVerbose { level = loglevel.LevelVerbose } + case "--logs": + showLogPanel = true default: filtered = append(filtered, args[i:]...) - return filtered, level + return filtered, level, showLogPanel } } - return filtered, level + return filtered, level, showLogPanel } func main() { - args, level := parseGlobalFlags(os.Args) + args, level, showLogPanel := parseGlobalFlags(os.Args) os.Args = args loglevel.Set(level) @@ -4097,6 +4176,14 @@ func main() { } } + if showLogPanel { + logger := logging.NewBuffer(logging.DefaultMaxEntries) + log.SetOutput(logger) + initialModel.showLogPanel = true + initialModel.logCh = logger.Subscribe() + initialModel.logPanel = tui.NewLogPanel(logger) + } + // Initialize plugin system plugins := plugin.NewManager() plugins.LoadPlugins() diff --git a/main_test.go b/main_test.go index ea0b448..6484635 100644 --- a/main_test.go +++ b/main_test.go @@ -38,3 +38,23 @@ func TestSanitizeFilenameTruncatesEmojiOnUTF8Boundary(t *testing.T) { t.Fatalf("sanitizeFilename lost extension: got %q", got) } } + +func TestParseGlobalFlagsEnablesLogPanel(t *testing.T) { + args, _, show := parseGlobalFlags([]string{"matcha", "--debug", "--logs", "--version"}) + if !show { + t.Fatal("expected log panel flag to be enabled") + } + if got := strings.Join(args, " "); got != "matcha --version" { + t.Fatalf("args = %q, want %q", got, "matcha --version") + } +} + +func TestParseGlobalFlagsDoesNotConsumeSubcommandFlags(t *testing.T) { + args, _, show := parseGlobalFlags([]string{"matcha", "send", "--logs"}) + if show { + t.Fatal("did not expect log panel flag after subcommand to be consumed") + } + if got := strings.Join(args, " "); got != "matcha send --logs" { + t.Fatalf("args = %q, want %q", got, "matcha send --logs") + } +} diff --git a/tui/log_panel.go b/tui/log_panel.go new file mode 100644 index 0000000..70e9033 --- /dev/null +++ b/tui/log_panel.go @@ -0,0 +1,70 @@ +package tui + +import ( + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + "github.com/floatpane/matcha/internal/logging" + "github.com/floatpane/matcha/theme" +) + +type LogPanel struct { + logger logging.Logger + width int + height int +} + +func NewLogPanel(logger logging.Logger) *LogPanel { + return &LogPanel{logger: logger} +} + +func (p *LogPanel) SetSize(width, height int) { + p.width = width + p.height = height +} + +func (p *LogPanel) View() string { + innerHeight := max(p.height - 1, 2) + visibleLogLines := max(innerHeight - 1, 1) + + lines := p.tailLines(visibleLogLines) + if len(lines) == 0 { + lines = []string{t("common.no_logs_yet")} + } + + innerWidth := max(p.width, 1) + for i, line := range lines { + lines[i] = ansi.Truncate(line, innerWidth, "…") + } + + header := lipgloss.NewStyle(). + Foreground(theme.ActiveTheme.Accent). + Bold(true). + Render("[" + t("common.logs") + "]") + separator := lipgloss.NewStyle(). + BorderForeground(theme.ActiveTheme.Secondary). + Render(strings.Repeat("─", p.width)) + body := header + "\n" + strings.Join(lines, "\n") + content := lipgloss.NewStyle(). + Width(p.width). + Height(innerHeight). + MaxHeight(innerHeight). + Foreground(theme.ActiveTheme.SubtleText). + Render(body) + + return lipgloss.JoinVertical(lipgloss.Left, separator, content) +} + +func (p *LogPanel) tailLines(n int) []string { + if p.logger == nil { + return nil + } + + entries := p.logger.Tail(n) + lines := make([]string, 0, len(entries)) + for _, entry := range entries { + lines = append(lines, entry.Text) + } + return lines +} From 195c5dd7e702269acdac8c38935e0c979ddbb04b Mon Sep 17 00:00:00 2001 From: FromSi Date: Fri, 22 May 2026 02:39:38 +0500 Subject: [PATCH 2/3] feat(tui): add log panel --- tui/log_panel.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tui/log_panel.go b/tui/log_panel.go index 70e9033..05389be 100644 --- a/tui/log_panel.go +++ b/tui/log_panel.go @@ -25,8 +25,8 @@ func (p *LogPanel) SetSize(width, height int) { } func (p *LogPanel) View() string { - innerHeight := max(p.height - 1, 2) - visibleLogLines := max(innerHeight - 1, 1) + innerHeight := max(p.height-1, 2) + visibleLogLines := max(innerHeight-1, 1) lines := p.tailLines(visibleLogLines) if len(lines) == 0 { From cd858ec05b93a7e54785d81c494a8bad911345b5 Mon Sep 17 00:00:00 2001 From: FromSi Date: Fri, 22 May 2026 02:41:21 +0500 Subject: [PATCH 3/3] feat(tui): add log panel --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 97bf142..bfc2af4 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/ProtonMail/go-crypto v1.4.1 github.com/PuerkitoBio/goquery v1.12.0 github.com/arran4/golang-ical v0.3.5 + github.com/charmbracelet/x/ansi v0.11.7 github.com/ebfe/scard v0.0.0-20241214075232-7af069cabc25 github.com/emersion/go-imap/v2 v2.0.0-beta.8 github.com/emersion/go-maildir v0.6.0 @@ -38,7 +39,6 @@ require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect - github.com/charmbracelet/x/ansi v0.11.7 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect