From 395fc8a08a61ee8d67cee975bcf22ec76502ce12 Mon Sep 17 00:00:00 2001 From: FromSi Date: Sat, 23 May 2026 01:12:10 +0500 Subject: [PATCH] feat(tui): add log panel (#1329) ## What? Added an optional in-TUI log panel enabled with `--logs`. The panel is backed by a small in-memory logger buffer and renders the latest log entries at the bottom of the TUI without overwriting the active view. The main TUI layout now reserves space for the log panel when it is enabled, so the current screen receives a reduced height instead of being drawn under the logs. The visual log panel itself lives in `tui/log_panel.go`. Also updated `internal/loglevel` so logs written through it are consistently prefixed: - `debug:` - `verbose:` - `info:` Added `make run-log`, which starts Matcha with both debug logging and the in-TUI log panel enabled: ```bash make run-log ``` view ## Why? While reviewing recent PRs, I noticed it was hard to observe logs cleanly in the TUI. Logs written through `log.Printf` or `internal/loglevel` could appear over the Bubble Tea screen, or required redirecting stderr to a separate file. This gives us a simple way to inspect runtime logs directly inside Matcha while testing behavior such as TLS resumption, plugin loading, background fetches, and other debug/verbose paths. I did not search for an existing issue for this. --- Makefile | 5 +- go.mod | 2 +- 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 +++++++++++++ 11 files changed, 398 insertions(+), 48 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 dc632fc8a0f5e43e8f958b531dbe28c3c15deea3..600e962fc7abf1b87547b34701a9b9d300b74684 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/go.mod b/go.mod index 56876cd8ab3b170f4739744c1bc5d448a8e0908b..856bf45238c5a8697a9c7980b0dce4a5106a9794 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 diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 48355f98ca933a992d0e5c306c4707d3bf4216e4..f553c1e490f327fa2506df7120b16b7d265d1bd8 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 0000000000000000000000000000000000000000..20b30a2e3553f02f77200257f47689a181c64709 --- /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 0000000000000000000000000000000000000000..1ae819fec388d514b357e0686c6895fd5ede7328 --- /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 0000000000000000000000000000000000000000..1b433ea0819c6603ca2d17eb97f1f95cd14df5e1 --- /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 d3279c73f6fdd2b54aa65eb378e0da81d59195fa..422e426ce0c1c6c504117f85d7f272f77f1f99f7 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 2db27bf1cfa788220e306996e7e9ee8bb9655603..62a033ff220fd7960125b7c20ed2d896f29e5f1f 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 5d54f53bb6f1f20bd1799db0bbc7449bc6231b62..dba5b4ab09619a196776ea9dd9a763376db98b7d 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 ea0b44882a2b1b86022a5eb58f749ebc0d653ab2..6484635ced0bfcc079a463bacc6360c2e81433c6 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 0000000000000000000000000000000000000000..05389be40480570a72f86aa3cf85dd8ef4b091ba --- /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 +}