From ce35531d2dc53063ad88c3a64d9143f5ba1ef779 Mon Sep 17 00:00:00 2001 From: FromSi Date: Fri, 29 May 2026 19:41:38 +0500 Subject: [PATCH 01/15] feat(composer): filter account picker --- config/default_keybinds.json | 1 + config/keybinds.go | 26 ++-- config/keybinds_test.go | 5 +- docs/docs/Features/Keybinds.md | 4 +- i18n/locales/en.json | 5 + tui/composer.go | 243 ++++++++++++++++++++++++++++++--- tui/composer_test.go | 156 +++++++++++++++++++++ 7 files changed, 405 insertions(+), 35 deletions(-) diff --git a/config/default_keybinds.json b/config/default_keybinds.json index 2e4e8385..a19ab0b0 100644 --- a/config/default_keybinds.json +++ b/config/default_keybinds.json @@ -33,6 +33,7 @@ "next_field": "tab", "prev_field": "shift+tab", "delete": "d", + "account_picker_filter": "f", "spell_next": "ctrl+n", "spell_prev": "ctrl+p", "spell_accept": "tab", diff --git a/config/keybinds.go b/config/keybinds.go index 7f7c54b1..80438ccb 100644 --- a/config/keybinds.go +++ b/config/keybinds.go @@ -60,14 +60,15 @@ type EmailKeys struct { } type ComposerKeys struct { - ExternalEditor string `json:"external_editor"` - NextField string `json:"next_field"` - PrevField string `json:"prev_field"` - Delete string `json:"delete"` - SpellNext string `json:"spell_next"` - SpellPrev string `json:"spell_prev"` - SpellAccept string `json:"spell_accept"` - SpellDismiss string `json:"spell_dismiss"` + ExternalEditor string `json:"external_editor"` + NextField string `json:"next_field"` + PrevField string `json:"prev_field"` + Delete string `json:"delete"` + AccountPickerFilter string `json:"account_picker_filter"` + SpellNext string `json:"spell_next"` + SpellPrev string `json:"spell_prev"` + SpellAccept string `json:"spell_accept"` + SpellDismiss string `json:"spell_dismiss"` } type FolderKeys struct { @@ -171,10 +172,11 @@ func ValidateKeybinds(kb KeybindsConfig) []string { "focus_attachments": kb.Email.FocusAttachments, }) check("composer", map[string]string{ - "external_editor": kb.Composer.ExternalEditor, - "next_field": kb.Composer.NextField, - "prev_field": kb.Composer.PrevField, - keyDelete: kb.Composer.Delete, + "external_editor": kb.Composer.ExternalEditor, + "next_field": kb.Composer.NextField, + "prev_field": kb.Composer.PrevField, + keyDelete: kb.Composer.Delete, + "account_picker_filter": kb.Composer.AccountPickerFilter, // spell_* bindings intentionally excluded from this conflict // check — spell_accept reusing "tab" with next_field, and // spell_dismiss reusing "esc" with cancel, are deliberate: the diff --git a/config/keybinds_test.go b/config/keybinds_test.go index 23a86438..1f069b32 100644 --- a/config/keybinds_test.go +++ b/config/keybinds_test.go @@ -73,7 +73,7 @@ func TestLoadKeybindsFromDir_ParsesCustom(t *testing.T) { } // Override inbox delete key - custom := `{"inbox":{"delete":"x","archive":"a","refresh":"r","open":"enter","next_tab":"l","prev_tab":"h","visual_mode":"v"},"global":{"quit":"ctrl+c","cancel":"esc","nav_up":"k","nav_down":"j"},"email":{"reply":"r","forward":"f","delete":"d","archive":"a","toggle_images":"i","rsvp_accept":"1","rsvp_decline":"2","rsvp_tentative":"3","focus_attachments":"tab"},"composer":{"external_editor":"ctrl+e","next_field":"tab","prev_field":"shift+tab"},"folder":{"next_folder":"tab","prev_folder":"shift+tab","move":"m","focus_preview":"]","focus_inbox":"["},"drafts":{"open":"enter","delete":"d"}}` + custom := `{"inbox":{"delete":"x","archive":"a","refresh":"r","open":"enter","next_tab":"l","prev_tab":"h","visual_mode":"v"},"global":{"quit":"ctrl+c","cancel":"esc","nav_up":"k","nav_down":"j"},"email":{"reply":"r","forward":"f","delete":"d","archive":"a","toggle_images":"i","rsvp_accept":"1","rsvp_decline":"2","rsvp_tentative":"3","focus_attachments":"tab"},"composer":{"external_editor":"ctrl+e","next_field":"tab","prev_field":"shift+tab","account_picker_filter":"ctrl+f"},"folder":{"next_folder":"tab","prev_folder":"shift+tab","move":"m","focus_preview":"]","focus_inbox":"["},"drafts":{"open":"enter","delete":"d"}}` if err := os.WriteFile(filepath.Join(dir, "keybinds.json"), []byte(custom), 0600); err != nil { t.Fatalf("write custom: %v", err) } @@ -83,4 +83,7 @@ func TestLoadKeybindsFromDir_ParsesCustom(t *testing.T) { if Keybinds.Inbox.Delete != "x" { t.Errorf("expected inbox.delete=x, got %q", Keybinds.Inbox.Delete) } + if Keybinds.Composer.AccountPickerFilter != "ctrl+f" { + t.Errorf("expected composer.account_picker_filter=ctrl+f, got %q", Keybinds.Composer.AccountPickerFilter) + } } diff --git a/docs/docs/Features/Keybinds.md b/docs/docs/Features/Keybinds.md index 544697af..9db4ea81 100644 --- a/docs/docs/Features/Keybinds.md +++ b/docs/docs/Features/Keybinds.md @@ -46,7 +46,9 @@ Plain text, not encrypted. Edit with any text editor. Restart matcha to apply ch "composer": { "external_editor": "ctrl+e", "next_field": "tab", - "prev_field": "shift+tab" + "prev_field": "shift+tab", + "delete": "d", + "account_picker_filter": "f" }, "folder": { "next_folder": "tab", diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 63b02153..06b4dfa9 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -36,8 +36,13 @@ "switchable": "switchable", "enter_to_switch": "Enter to switch", "no_account": "no account configured", + "account_picker_title": "Select Account:", + "account_picker_no_matches": "No matching accounts", "send_confirm": "Press Enter to send the email.", "help": "Markdown/HTML • tab/shift+tab: navigate • ctrl+e: $EDITOR • esc: save draft & exit", + "account_picker_help_filtering": "enter: apply filter • esc: clear filter", + "account_picker_help_filtered": "↑/↓: navigate • enter: select • esc: clear filter", + "account_picker_help": "{key}: filter • ↑/↓: navigate • enter: select • esc: cancel", "exit_confirm": "Are you sure you want to exit? This draft will be saved", "sending": "Sending email...", "sent": "Email sent successfully", diff --git a/tui/composer.go b/tui/composer.go index 92d2819b..f75838af 100644 --- a/tui/composer.go +++ b/tui/composer.go @@ -82,10 +82,13 @@ type Composer struct { hideTips bool // Multi-account support - accounts []config.Account - selectedAccountIdx int - showAccountPicker bool - fromInput textinput.Model // editable From when account is catch-all + accounts []config.Account + selectedAccountIdx int + showAccountPicker bool + accountPickerCursor int + accountPickerFilter string + accountPickerFiltering bool + fromInput textinput.Model // editable From when account is catch-all // Contact suggestions suggestions []config.Contact @@ -532,6 +535,135 @@ func (m *Composer) getSelectedAccount() *config.Account { return nil } +func (m *Composer) openAccountPicker() { + m.showAccountPicker = true + m.accountPickerFilter = "" + m.accountPickerFiltering = false + m.accountPickerCursor = m.accountPickerCursorForSelected() +} + +func (m *Composer) closeAccountPicker() { + m.showAccountPicker = false + m.accountPickerFilter = "" + m.accountPickerFiltering = false + m.accountPickerCursor = 0 +} + +func (m *Composer) accountPickerCursorForSelected() int { + for i, idx := range m.filteredAccountIndexes() { + if idx == m.selectedAccountIdx { + return i + } + } + return 0 +} + +func (m *Composer) filteredAccountIndexes() []int { + query := strings.ToLower(strings.TrimSpace(m.accountPickerFilter)) + indexes := make([]int, 0, len(m.accounts)) + for i, acc := range m.accounts { + if query == "" || accountMatchesFilter(acc, query) { + indexes = append(indexes, i) + } + } + return indexes +} + +func accountMatchesFilter(acc config.Account, query string) bool { + values := []string{ + acc.ID, + acc.Name, + acc.Email, + acc.FetchEmail, + acc.SendAsEmail, + acc.GetSendAsEmail(), + accountPickerDisplay(acc), + } + for _, value := range values { + if strings.Contains(strings.ToLower(value), query) { + return true + } + } + return false +} + +func accountPickerDisplay(acc config.Account) string { + display := acc.GetSendAsEmail() + if acc.Name != "" { + display = fmt.Sprintf("%s (%s)", acc.Name, acc.GetSendAsEmail()) + } + return display +} + +func accountPickerStatus(visible, total int, filter string) string { + name := "accounts" + if visible == 1 { + name = "account" + } + + var status string + if strings.TrimSpace(filter) != "" { + status = fmt.Sprintf("“%s” ", truncateAccountPickerFilter(filter)) + } + if visible == 0 { + status += "Nothing matched" + } else { + status += fmt.Sprintf("%d %s", visible, name) + } + if filtered := total - visible; filtered > 0 { + status += fmt.Sprintf(" • %d filtered", filtered) + } + return HelpStyle.Render(status) +} + +func truncateAccountPickerFilter(filter string) string { + const maxRunes = 10 + runes := []rune(strings.TrimSpace(filter)) + if len(runes) <= maxRunes { + return string(runes) + } + return string(runes[:maxRunes-1]) + "…" +} + +func accountPickerFilterKey() string { + if config.Keybinds.Composer.AccountPickerFilter != "" { + return config.Keybinds.Composer.AccountPickerFilter + } + return "f" +} + +func accountPickerHelp(filtering bool, filter string) string { + if filtering { + return t("composer.account_picker_help_filtering") + } + if filter != "" { + return t("composer.account_picker_help_filtered") + } + return tpl("composer.account_picker_help", map[string]interface{}{"key": accountPickerFilterKey()}) +} + +func (m *Composer) clampAccountPickerCursor(indexes []int) { + if len(indexes) == 0 { + m.accountPickerCursor = 0 + return + } + if m.accountPickerCursor >= len(indexes) { + m.accountPickerCursor = len(indexes) - 1 + } + if m.accountPickerCursor < 0 { + m.accountPickerCursor = 0 + } +} + +func (m *Composer) selectAccountPickerCursor(indexes []int) { + if len(indexes) == 0 { + return + } + m.clampAccountPickerCursor(indexes) + m.selectedAccountIdx = indexes[m.accountPickerCursor] + m.updateSignature() +} + func formatAttachmentName(path string) string { name := filepath.Base(path) info, err := os.Stat(path) @@ -751,21 +883,71 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo // Handle account picker mode if m.showAccountPicker { + if m.accountPickerFiltering { + switch msg.String() { + case keyEnter: + indexes := m.filteredAccountIndexes() + if len(indexes) == 0 { + m.accountPickerFilter = "" + m.accountPickerCursor = m.accountPickerCursorForSelected() + } else { + m.accountPickerFiltering = false + m.clampAccountPickerCursor(indexes) + m.selectAccountPickerCursor(indexes) + } + return m, nil + case "esc": + m.accountPickerFilter = "" + m.accountPickerFiltering = false + m.accountPickerCursor = m.accountPickerCursorForSelected() + return m, nil + case "backspace", "ctrl+h": + if m.accountPickerFilter != "" { + runes := []rune(m.accountPickerFilter) + m.accountPickerFilter = string(runes[:len(runes)-1]) + m.accountPickerCursor = 0 + } + return m, nil + } + + if msg.Text != "" { + for _, r := range msg.Text { + if unicode.IsPrint(r) { + m.accountPickerFilter += string(r) + } + } + m.accountPickerCursor = 0 + } + return m, nil + } + + indexes := m.filteredAccountIndexes() + m.clampAccountPickerCursor(indexes) switch msg.String() { + case accountPickerFilterKey(): + m.accountPickerFiltering = true case "up", "k": - if m.selectedAccountIdx > 0 { - m.selectedAccountIdx-- - m.updateSignature() + if m.accountPickerCursor > 0 { + m.accountPickerCursor-- + m.selectAccountPickerCursor(indexes) } case keyDown, "j": - if m.selectedAccountIdx < len(m.accounts)-1 { - m.selectedAccountIdx++ - m.updateSignature() + if m.accountPickerCursor < len(indexes)-1 { + m.accountPickerCursor++ + m.selectAccountPickerCursor(indexes) } case keyEnter: - m.showAccountPicker = false + if len(indexes) > 0 { + m.selectAccountPickerCursor(indexes) + m.closeAccountPicker() + } case "esc": - m.showAccountPicker = false + if m.accountPickerFilter != "" { + m.accountPickerFilter = "" + m.accountPickerCursor = m.accountPickerCursorForSelected() + return m, nil + } + m.closeAccountPicker() } return m, nil } @@ -903,7 +1085,7 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo switch m.focusIndex { case focusFrom: if msg.String() == keyEnter && len(m.accounts) > 1 { - m.showAccountPicker = true + m.openAccountPicker() return m, nil } if m.isCatchAllAccount() && msg.String() == " " { @@ -1253,13 +1435,32 @@ func (m *Composer) View() tea.View { //nolint:gocyclo // Account picker overlay if m.showAccountPicker { var accountList strings.Builder - accountList.WriteString("Select Account:\n\n") - for i, acc := range m.accounts { - display := acc.GetSendAsEmail() - if acc.Name != "" { - display = fmt.Sprintf("%s (%s)", acc.Name, acc.GetSendAsEmail()) - } - if i == m.selectedAccountIdx { + accountList.WriteString(t("composer.account_picker_title") + "\n") + if m.accountPickerFiltering { + accountList.WriteString(focusedStyle.Render("Filter: ") + m.accountPickerFilter + "\n") + } + indexes := m.filteredAccountIndexes() + if m.accountPickerFiltering { + accountList.WriteString(accountPickerStatus(len(indexes), len(m.accounts), m.accountPickerFilter)) + accountList.WriteString("\n") + } else if m.accountPickerFilter != "" { + accountList.WriteString(accountPickerStatus(len(indexes), len(m.accounts), m.accountPickerFilter)) + accountList.WriteString("\n") + } + accountList.WriteString("\n") + if len(indexes) == 0 { + accountList.WriteString(itemStyle.Render(" "+t("composer.account_picker_no_matches")) + "\n") + } + cursorIndex := m.accountPickerCursor + if cursorIndex >= len(indexes) { + cursorIndex = len(indexes) - 1 + } + if cursorIndex < 0 { + cursorIndex = 0 + } + for cursor, idx := range indexes { + display := accountPickerDisplay(m.accounts[idx]) + if cursor == cursorIndex && !m.accountPickerFiltering { accountList.WriteString(selectedItemStyle.Render(fmt.Sprintf("> %s", display))) } else { accountList.WriteString(itemStyle.Render(fmt.Sprintf(" %s", display))) @@ -1267,7 +1468,7 @@ func (m *Composer) View() tea.View { //nolint:gocyclo accountList.WriteString("\n") } accountList.WriteString("\n") - accountList.WriteString(HelpStyle.Render("↑/↓: navigate • enter: select • esc: cancel")) + accountList.WriteString(HelpStyle.Render(accountPickerHelp(m.accountPickerFiltering, m.accountPickerFilter))) dialog := DialogBoxStyle.Render(accountList.String()) return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)) diff --git a/tui/composer_test.go b/tui/composer_test.go index a9cb65c9..b8b65463 100644 --- a/tui/composer_test.go +++ b/tui/composer_test.go @@ -8,6 +8,7 @@ import ( "testing" tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" "github.com/floatpane/matcha/config" ) @@ -386,6 +387,161 @@ func TestComposerUpdate(t *testing.T) { } }) + t.Run("Account picker filters accounts", func(t *testing.T) { + accounts := []config.Account{ + {ID: "personal", Email: "personal@example.com", Name: "Personal"}, + {ID: "billing", Email: "billing@example.com", Name: "Billing"}, + {ID: "support", Email: "support@example.com", Name: "Support"}, + } + composer := NewComposerWithAccounts(accounts, "personal", "", "", "", false) + composer.focusIndex = focusFrom + + model, _ := composer.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + composer = model.(*Composer) + view := ansi.Strip(composer.View().Content) + if strings.Contains(view, "Filter:") { + t.Fatalf("Expected account picker filter to be hidden before pressing f, got:\n%s", view) + } + + model, _ = composer.Update(tea.KeyPressMsg{Code: 'f', Text: "f"}) + composer = model.(*Composer) + for _, r := range "supp" { + model, _ = composer.Update(tea.KeyPressMsg{Code: r, Text: string(r)}) + composer = model.(*Composer) + } + + if composer.accountPickerFilter != "supp" { + t.Fatalf("Expected filter %q, got %q", "supp", composer.accountPickerFilter) + } + if composer.GetSelectedAccountID() != "personal" { + t.Fatalf("Expected active filter input not to select an account, got %q", composer.GetSelectedAccountID()) + } + + view = ansi.Strip(composer.View().Content) + if !strings.Contains(view, "Filter: supp") { + t.Fatalf("Expected account picker view to show filter, got:\n%s", view) + } + if !strings.Contains(view, "“supp” 1 account • 2 filtered") { + t.Fatalf("Expected account picker status below active filter, got:\n%s", view) + } + if strings.Contains(view, "> Support") { + t.Fatalf("Expected active filter input not to show an account cursor, got:\n%s", view) + } + if strings.Contains(view, "Billing") { + t.Fatalf("Expected filtered account picker view to hide Billing, got:\n%s", view) + } + + model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + composer = model.(*Composer) + if composer.accountPickerFiltering { + t.Fatal("Expected enter to leave account picker filter input mode") + } + view = ansi.Strip(composer.View().Content) + if !strings.Contains(view, "> Support") { + t.Fatalf("Expected applied filter to focus the filtered account list, got:\n%s", view) + } + if strings.Contains(view, "Filter: supp") { + t.Fatalf("Expected applied filter view to hide filter input, got:\n%s", view) + } + if !strings.Contains(view, "“supp” 1 account • 2 filtered") { + t.Fatalf("Expected applied filter status to remain visible, got:\n%s", view) + } + if strings.Index(view, "“supp” 1 account") > strings.Index(view, "> Support") { + t.Fatalf("Expected applied filter status above account list, got:\n%s", view) + } + if !composer.showAccountPicker { + t.Fatal("Expected account picker to remain open after applying filter") + } + if composer.GetSelectedAccountID() != "support" { + t.Fatalf("Expected selected account ID support, got %q", composer.GetSelectedAccountID()) + } + + model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + composer = model.(*Composer) + if composer.showAccountPicker { + t.Fatal("Expected account picker to close after selection") + } + }) + + t.Run("Account picker filter treats j and k as input until applied", func(t *testing.T) { + accounts := []config.Account{ + {ID: "one", Email: "one@example.com", Name: "One"}, + {ID: "jk", Email: "jk@example.com", Name: "JK"}, + {ID: "two", Email: "two@example.com", Name: "Two"}, + } + composer := NewComposerWithAccounts(accounts, "one", "", "", "", false) + composer.focusIndex = focusFrom + + model, _ := composer.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + composer = model.(*Composer) + model, _ = composer.Update(tea.KeyPressMsg{Code: 'f', Text: "f"}) + composer = model.(*Composer) + model, _ = composer.Update(tea.KeyPressMsg{Code: 'j', Text: "j"}) + composer = model.(*Composer) + model, _ = composer.Update(tea.KeyPressMsg{Code: 'k', Text: "k"}) + composer = model.(*Composer) + + if composer.accountPickerFilter != "jk" { + t.Fatalf("Expected j/k to be typed into active filter, got %q", composer.accountPickerFilter) + } + if composer.GetSelectedAccountID() != "one" { + t.Fatalf("Expected active filter typing not to navigate accounts, got %q", composer.GetSelectedAccountID()) + } + + model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + composer = model.(*Composer) + if composer.accountPickerFiltering { + t.Fatal("Expected enter to apply filter and focus account list") + } + if composer.GetSelectedAccountID() != "jk" { + t.Fatalf("Expected applied jk filter to select jk account, got %q", composer.GetSelectedAccountID()) + } + + model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + composer = model.(*Composer) + if composer.accountPickerFilter != "" { + t.Fatalf("Expected esc to clear applied filter, got %q", composer.accountPickerFilter) + } + if !composer.showAccountPicker { + t.Fatal("Expected esc to clear filter before closing account picker") + } + + model, _ = composer.Update(tea.KeyPressMsg{Code: 'j', Text: "j"}) + composer = model.(*Composer) + if composer.GetSelectedAccountID() != "two" { + t.Fatalf("Expected j to navigate after filter is cleared, got %q", composer.GetSelectedAccountID()) + } + }) + + t.Run("Account picker filter uses configured keybind", func(t *testing.T) { + previous := config.Keybinds + t.Cleanup(func() { + config.Keybinds = previous + }) + config.Keybinds.Composer.AccountPickerFilter = "ctrl+f" + + accounts := []config.Account{ + {ID: "one", Email: "one@example.com", Name: "One"}, + {ID: "two", Email: "two@example.com", Name: "Two"}, + } + composer := NewComposerWithAccounts(accounts, "one", "", "", "", false) + composer.focusIndex = focusFrom + + model, _ := composer.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + composer = model.(*Composer) + model, _ = composer.Update(tea.KeyPressMsg{Code: 'f', Text: "f"}) + composer = model.(*Composer) + if composer.accountPickerFiltering { + t.Fatal("Expected default f key not to start account picker filtering after remap") + } + + model, _ = composer.Update(tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl}) + composer = model.(*Composer) + if !composer.accountPickerFiltering { + t.Fatal("Expected configured ctrl+f key to start account picker filtering") + } + }) + t.Run("Single account no picker", func(t *testing.T) { singleAccounts := []config.Account{ {ID: "account-1", Email: "test@example.com"}, From bda3d3ac5b80e62f234dbf7a49fba40e9ca3adec Mon Sep 17 00:00:00 2001 From: Drew Smirnoff Date: Mon, 1 Jun 2026 23:20:26 +0400 Subject: [PATCH 02/15] ci: prepare release v1 workflows (#1400) ## What? Adds a v1 release backporting, merge queue behaivor, e.t.c. ## Why? We are ready to start working on v1 of matcha. v0 will still be maintained and supplied security updates, bug fixes, QoL features --------- Signed-off-by: drew (cherry picked from commit dad48a0d9d05d9cdf4e8a393ed860df1e447aef9) --- .github/labels.yml | 17 ++ .github/workflows/backport.yml | 127 ++++++++++++++ .github/workflows/ci.yml | 5 +- .github/workflows/integration.yml | 5 +- .github/workflows/pr-target-labeler.yml | 105 ++++++++++++ .github/workflows/release-rc.yml | 212 ++++++++++++++++++++++++ .github/workflows/security.yml | 5 +- .goreleaser.rc.yml | 77 +++++++++ 8 files changed, 547 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/backport.yml create mode 100644 .github/workflows/pr-target-labeler.yml create mode 100644 .github/workflows/release-rc.yml create mode 100644 .goreleaser.rc.yml diff --git a/.github/labels.yml b/.github/labels.yml index 18d245e6..f8371db4 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -139,6 +139,23 @@ color: c2e0c6 description: Docs site / README +# --- Release / v1 routing --- +- name: backport/v1 + color: 0e8a16 + description: Merge to master and backport to release/v1 +- name: target/v1 + color: 1d76db + description: PR targets the release/v1 branch directly +- name: backported + color: c5def5 + description: Cherry-picked onto release/v1 +- name: backport-failed + color: b60205 + description: Cherry-pick conflict — needs manual backport +- name: skip-changelog + color: ededed + description: Exclude from generated release notes + # --- Size --- - name: size/XS color: "3cbf00" diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml new file mode 100644 index 00000000..e9a0702e --- /dev/null +++ b/.github/workflows/backport.yml @@ -0,0 +1,127 @@ +name: Backport to release/v1 + +# Cherry-picks a merged PR's commit straight onto release/v1 and pushes it — +# no intermediate PR. Fires when: +# - a PR labeled `backport/v1` is merged into master, or +# - a maintainer comments `/backport v1` on an already-merged PR. +# On a clean cherry-pick the commit is pushed directly. On conflict nothing is +# pushed; the source PR is labeled `backport-failed` and a comment asks for a +# manual cherry-pick. + +on: + pull_request_target: + types: [closed] + issue_comment: + types: [created] + +permissions: + contents: write + pull-requests: write + issues: write + +jobs: + backport: + name: Backport + runs-on: ubuntu-latest + if: > + (github.event_name == 'pull_request_target' + && github.event.pull_request.merged == true + && contains(github.event.pull_request.labels.*.name, 'backport/v1')) + || (github.event_name == 'issue_comment' + && github.event.issue.pull_request + && github.event.issue.state == 'closed' + && startsWith(github.event.comment.body, '/backport v1') + && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)) + steps: + - name: Resolve PR and merge commit + id: pr + uses: actions/github-script@v9 + with: + github-token: ${{ secrets.HOMEBREW_GITHUB_TOKEN }} + script: | + const { owner, repo } = context.repo; + const num = context.eventName === 'issue_comment' + ? context.payload.issue.number + : context.payload.pull_request.number; + + const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: num }); + + if (!pr.merged || !pr.merge_commit_sha) { + core.notice(`PR #${num} is not merged; nothing to backport.`); + core.setOutput('run', 'false'); + return; + } + core.setOutput('run', 'true'); + core.setOutput('number', String(num)); + core.setOutput('sha', pr.merge_commit_sha); + core.setOutput('title', pr.title); + + - name: Checkout + if: steps.pr.outputs.run == 'true' + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: release/v1 + token: ${{ secrets.HOMEBREW_GITHUB_TOKEN }} + + - name: Cherry-pick onto release/v1 + if: steps.pr.outputs.run == 'true' + id: pick + run: | + set -euo pipefail + SHA="${{ steps.pr.outputs.sha }}" + git config user.name "Floatpane Bot" + git config user.email "us@floatpane.com" + git fetch --no-tags origin "$SHA" + + # A true merge commit has >1 parent and needs a mainline (-m 1); + # squash/rebase merges are a single commit cherry-picked as-is. + PARENTS=$(git rev-list --parents -n1 "$SHA" | wc -w) + PICK_ARGS="-x" + if [ "$PARENTS" -gt 2 ]; then PICK_ARGS="-x -m 1"; fi + + if git cherry-pick $PICK_ARGS "$SHA"; then + git push origin HEAD:release/v1 + echo "status=ok" >> "$GITHUB_OUTPUT" + else + git cherry-pick --abort || true + echo "status=conflict" >> "$GITHUB_OUTPUT" + fi + + - name: Report result + if: steps.pr.outputs.run == 'true' + uses: actions/github-script@v9 + with: + github-token: ${{ secrets.HOMEBREW_GITHUB_TOKEN }} + script: | + const { owner, repo } = context.repo; + const issue_number = Number('${{ steps.pr.outputs.number }}'); + const status = '${{ steps.pick.outputs.status }}'; + const sha = '${{ steps.pr.outputs.sha }}'; + + if (status === 'ok') { + await github.rest.issues.addLabels({ owner, repo, issue_number, labels: ['backported'] }); + try { + await github.rest.issues.removeLabel({ owner, repo, issue_number, name: 'backport-failed' }); + } catch (e) { if (e.status !== 404) throw e; } + await github.rest.issues.createComment({ + owner, repo, issue_number, + body: `Cherry-picked \`${sha.substring(0, 7)}\` onto \`release/v1\`.`, + }); + } else { + await github.rest.issues.addLabels({ owner, repo, issue_number, labels: ['backport-failed'] }); + await github.rest.issues.createComment({ + owner, repo, issue_number, + body: [ + `Backport to \`release/v1\` hit a conflict — cherry-pick it manually:`, + '```bash', + 'git fetch origin', + 'git checkout release/v1', + `git cherry-pick -x ${sha}`, + '# resolve conflicts, then:', + 'git push origin release/v1', + '```', + ].join('\n'), + }); + core.setFailed('Cherry-pick conflict.'); + } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80ee948f..cdbd10ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,10 @@ name: CI on: push: - branches: [master] + branches: [master, release/v1] pull_request: - branches: [master] + branches: [master, release/v1] + merge_group: jobs: build: diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 130e252c..5958b5bd 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -2,9 +2,10 @@ name: Integration on: push: - branches: [master] + branches: [master, release/v1] pull_request: - branches: [master] + branches: [master, release/v1] + merge_group: jobs: imap-smtp: diff --git a/.github/workflows/pr-target-labeler.yml b/.github/workflows/pr-target-labeler.yml new file mode 100644 index 00000000..195cde03 --- /dev/null +++ b/.github/workflows/pr-target-labeler.yml @@ -0,0 +1,105 @@ +name: PR Target Labeler + +# Decides where a PR is destined and reflects it with labels: +# (no label) -> master only (default) +# backport/v1 -> merge to master AND backport to release/v1 ("both") +# target/v1 -> the PR already targets release/v1 directly (v1 only) +# +# Maintainers steer routing with comment commands on the PR: +# /backport v1 add the backport/v1 label +# /no-backport v1 remove it +# The actual cherry-pick is performed by backport.yml once the PR is merged. + +on: + pull_request_target: + types: [opened, edited, reopened, synchronize] + issue_comment: + types: [created] + +permissions: + contents: read + pull-requests: write + issues: write + +jobs: + # Auto-label based on the PR's base branch and an explicit "backport" hint + # in the title/body. Never removes backport/v1 (a maintainer may have set it). + auto: + if: github.event_name == 'pull_request_target' + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v9 + with: + github-token: ${{ secrets.HOMEBREW_GITHUB_TOKEN }} + script: | + const pr = context.payload.pull_request; + const { owner, repo } = context.repo; + const issue_number = pr.number; + const base = pr.base.ref; + const have = new Set(pr.labels.map(l => l.name)); + const add = []; + + if (base === 'release/v1') { + if (!have.has('target/v1')) add.push('target/v1'); + } else { + const text = `${pr.title}\n${pr.body || ''}`; + if (/\bback[- ]?port\b/i.test(text) && !have.has('backport/v1')) { + add.push('backport/v1'); + } + } + + if (add.length) { + await github.rest.issues.addLabels({ owner, repo, issue_number, labels: add }); + console.log(`added: ${add.join(', ')}`); + } + + # Comment commands. Restricted to users with write access to the repo. + command: + if: > + github.event_name == 'issue_comment' + && github.event.issue.pull_request + && (startsWith(github.event.comment.body, '/backport') + || startsWith(github.event.comment.body, '/no-backport')) + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v9 + with: + github-token: ${{ secrets.HOMEBREW_GITHUB_TOKEN }} + script: | + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + const body = context.payload.comment.body.trim(); + const assoc = context.payload.comment.author_association; + const commenter = context.payload.comment.user.login; + + if (!['OWNER', 'MEMBER', 'COLLABORATOR'].includes(assoc)) { + await github.rest.issues.createComment({ + owner, repo, issue_number, + body: `Sorry @${commenter}, only maintainers can change backport routing.`, + }); + return; + } + + // Only the v1 target is supported for now. + if (!/\bv1\b/.test(body)) { + await github.rest.issues.createComment({ + owner, repo, issue_number, + body: `Usage: \`/backport v1\` or \`/no-backport v1\`.`, + }); + return; + } + + const remove = body.startsWith('/no-backport'); + if (remove) { + try { + await github.rest.issues.removeLabel({ owner, repo, issue_number, name: 'backport/v1' }); + } catch (e) { + if (e.status !== 404) throw e; + } + } else { + await github.rest.issues.addLabels({ owner, repo, issue_number, labels: ['backport/v1'] }); + } + + await github.rest.reactions.createForIssueComment({ + owner, repo, comment_id: context.payload.comment.id, content: 'rocket', + }); diff --git a/.github/workflows/release-rc.yml b/.github/workflows/release-rc.yml new file mode 100644 index 00000000..acc739eb --- /dev/null +++ b/.github/workflows/release-rc.yml @@ -0,0 +1,212 @@ +name: Release Candidate + +# Cuts a v1 release-candidate pre-release (e.g. v1.0.0-rc3) from the +# release/v1 stabilization branch. The rc number is computed automatically +# from existing tags. Run manually from the Actions tab. + +on: + workflow_dispatch: + inputs: + base_version: + description: "Base version the rc leads up to" + type: string + default: "1.0.0" + dry_run: + description: "Compute and print the next tag without tagging or releasing" + type: boolean + default: false + +permissions: + contents: write # to create tags and the GitHub pre-release + id-token: write # to sign the release + +jobs: + tag: + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.compute.outputs.tag }} + prev_stable: ${{ steps.compute.outputs.prev_stable }} + steps: + - name: Guard branch + if: github.ref != 'refs/heads/release/v1' + run: | + echo "::error::Release candidates must be cut from release/v1 (got ${{ github.ref }})." + exit 1 + + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Compute next rc tag + id: compute + run: | + set -euo pipefail + BASE="${{ inputs.base_version }}" + git fetch --tags --force + # Highest existing rc number for this base version, 0 if none yet. + LATEST=$(git tag -l "v${BASE}-rc*" \ + | sed -E "s/^v${BASE}-rc//" \ + | grep -E '^[0-9]+$' \ + | sort -n | tail -1 || true) + NEXT=$(( ${LATEST:-0} + 1 )) + TAG="v${BASE}-rc${NEXT}" + echo "Next release candidate: $TAG" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + # Last STABLE release tag (no -rc / nightly / preview). Used as the + # changelog base so every rc's notes are cumulative since the last + # stable — covering all prior rc changes too, not just the last rc. + PREV_STABLE=$(git tag -l 'v*' \ + | grep -vE '\-rc|nightly|preview' \ + | sort -V | tail -1 || true) + echo "Changelog base (last stable): ${PREV_STABLE:-}" + echo "prev_stable=$PREV_STABLE" >> "$GITHUB_OUTPUT" + + - name: Create and push tag + if: ${{ !inputs.dry_run }} + run: | + set -euo pipefail + TAG="${{ steps.compute.outputs.tag }}" + git config user.name "Floatpane Bot" + git config user.email "us@floatpane.com" + git tag -a "$TAG" -m "Release candidate $TAG" + git push origin "$TAG" + + goreleaser: + needs: tag + if: ${{ !inputs.dry_run }} + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ needs.tag.outputs.tag }} + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "1.26.3" + + - name: Set up Zig + uses: goto-bus-stop/setup-zig@v2 + + - name: Set up libpcsclite for Linux cross-compilation + run: | + PCSC_DIR="$RUNNER_TEMP/pcsclite" + mkdir -p "$PCSC_DIR/include" "$PCSC_DIR/lib/pkgconfig" + + # Download pcsc-lite headers from upstream + PCSC_URL="https://raw.githubusercontent.com/LudovicRousseau/PCSC/master/src/PCSC" + for header in winscard.h wintypes.h; do + curl -fsSL "$PCSC_URL/$header" -o "$PCSC_DIR/include/$header" + done + # pcsclite.h is generated from pcsclite.h.in — download and substitute the version placeholder + curl -fsSL "$PCSC_URL/pcsclite.h.in" -o "$PCSC_DIR/include/pcsclite.h" + sed -i '' 's/@VERSION@/1.9.0/' "$PCSC_DIR/include/pcsclite.h" + + # Build stub library — the real libpcsclite is loaded at runtime on the user's system, + # but the linker needs symbols to resolve during cross-compilation. + cat > "$RUNNER_TEMP/pcsclite_stub.c" << 'STUB' + #include + typedef long LONG; + typedef unsigned long DWORD; + typedef void *LPCVOID; + typedef char *LPSTR; + typedef const char *LPCSTR; + typedef unsigned char *LPBYTE; + typedef unsigned char BYTE; + typedef LONG SCARDCONTEXT; + typedef LONG SCARDHANDLE; + typedef struct { DWORD dwProtocol; DWORD cbPciLength; } SCARD_IO_REQUEST; + typedef struct { LPCSTR szReader; DWORD dwCurrentState; DWORD dwEventState; DWORD cbAtr; unsigned char rgbAtr[36]; void *pvUserData; } SCARD_READERSTATE; + LONG SCardEstablishContext(DWORD s, LPCVOID r1, LPCVOID r2, SCARDCONTEXT *c) { return 0; } + LONG SCardReleaseContext(SCARDCONTEXT c) { return 0; } + LONG SCardIsValidContext(SCARDCONTEXT c) { return 0; } + LONG SCardCancel(SCARDCONTEXT c) { return 0; } + LONG SCardConnect(SCARDCONTEXT c, LPCSTR r, DWORD s, DWORD p, SCARDHANDLE *h, DWORD *ap) { return 0; } + LONG SCardReconnect(SCARDHANDLE h, DWORD s, DWORD p, DWORD d, DWORD *ap) { return 0; } + LONG SCardDisconnect(SCARDHANDLE h, DWORD d) { return 0; } + LONG SCardBeginTransaction(SCARDHANDLE h) { return 0; } + LONG SCardEndTransaction(SCARDHANDLE h, DWORD d) { return 0; } + LONG SCardStatus(SCARDHANDLE h, LPSTR r, DWORD *rl, DWORD *s, DWORD *p, LPBYTE a, DWORD *al) { return 0; } + LONG SCardTransmit(SCARDHANDLE h, const SCARD_IO_REQUEST *si, const BYTE *s, DWORD sl, SCARD_IO_REQUEST *ri, BYTE *r, DWORD *rl) { return 0; } + LONG SCardControl(SCARDHANDLE h, DWORD c, LPCVOID i, DWORD il, void *o, DWORD ol, DWORD *br) { return 0; } + LONG SCardGetAttrib(SCARDHANDLE h, DWORD a, LPBYTE b, DWORD *bl) { return 0; } + LONG SCardSetAttrib(SCARDHANDLE h, DWORD a, const BYTE *b, DWORD bl) { return 0; } + LONG SCardListReaders(SCARDCONTEXT c, LPCSTR g, LPSTR r, DWORD *rl) { return 0; } + LONG SCardListReaderGroups(SCARDCONTEXT c, LPSTR g, DWORD *gl) { return 0; } + LONG SCardGetStatusChange(SCARDCONTEXT c, DWORD t, SCARD_READERSTATE *s, DWORD n) { return 0; } + LONG SCardFreeMemory(SCARDCONTEXT c, LPCVOID m) { return 0; } + const char *pcsc_stringify_error(LONG e) { return "stub"; } + STUB + + zig cc -c -target x86_64-linux-musl -o "$RUNNER_TEMP/pcsclite_stub_amd64.o" "$RUNNER_TEMP/pcsclite_stub.c" + zig cc -c -target aarch64-linux-musl -o "$RUNNER_TEMP/pcsclite_stub_arm64.o" "$RUNNER_TEMP/pcsclite_stub.c" + + mkdir -p "$PCSC_DIR/lib/amd64" "$PCSC_DIR/lib/arm64" + ar rcs "$PCSC_DIR/lib/amd64/libpcsclite.a" "$RUNNER_TEMP/pcsclite_stub_amd64.o" + ar rcs "$PCSC_DIR/lib/arm64/libpcsclite.a" "$RUNNER_TEMP/pcsclite_stub_arm64.o" + + # Create pkg-config file — Libs will be overridden per-arch via CGO_LDFLAGS in goreleaser + cat > "$PCSC_DIR/lib/pkgconfig/libpcsclite.pc" << EOF + Name: libpcsclite + Description: PC/SC Lite + Version: 1.9.0 + Cflags: -I$PCSC_DIR/include + Libs: -L$PCSC_DIR/lib/amd64 -lpcsclite + EOF + + echo "PKG_CONFIG_PATH=$PCSC_DIR/lib/pkgconfig" >> $GITHUB_ENV + echo "PCSC_DIR=$PCSC_DIR" >> $GITHUB_ENV + + - name: Get macOS SDK path + id: macos_sdk + run: echo "path=$(xcrun --show-sdk-path)" >> $GITHUB_OUTPUT + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v7 + with: + version: latest + args: release --clean --config .goreleaser.rc.yml + env: + SDK_PATH: ${{ steps.macos_sdk.outputs.path }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Force the changelog base to the last stable tag so rc notes are + # cumulative across every rc since that release. + GORELEASER_PREVIOUS_TAG: ${{ needs.tag.outputs.prev_stable }} + + snapcraft: + needs: [tag, goreleaser] + if: ${{ !inputs.dry_run }} + runs-on: ${{ matrix.runner }} + strategy: + matrix: + include: + - arch: amd64 + runner: ubuntu-latest + - arch: arm64 + runner: ubuntu-24.04-arm + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ needs.tag.outputs.tag }} + + - name: Install Snapcraft and LXD + run: | + sudo snap install snapcraft --classic + sudo snap install lxd + sudo lxd init --auto + sudo iptables -P FORWARD ACCEPT + sudo usermod -aG lxd $USER + + - name: Build snap + run: sg lxd -c 'snapcraft pack --use-lxd --build-for=${{ matrix.arch }}' + + - name: Upload snap to candidate channel + env: + SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} + run: snapcraft upload --release=candidate *.snap diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 429397f5..b9c4efa2 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -2,9 +2,10 @@ name: Security on: push: - branches: [master] + branches: [master, release/v1] pull_request: - branches: [master] + branches: [master, release/v1] + merge_group: schedule: - cron: "0 6 * * 1" diff --git a/.goreleaser.rc.yml b/.goreleaser.rc.yml new file mode 100644 index 00000000..89c8c0f3 --- /dev/null +++ b/.goreleaser.rc.yml @@ -0,0 +1,77 @@ +# GoReleaser configuration for release-candidate (pre-release) builds. +# Like .goreleaser.yml but skips Homebrew, WinGet and Nix tap publishing — +# those taps should only ever point at a stable release. RC artifacts are +# attached to a GitHub pre-release; Snapcraft is pushed to the `candidate` +# channel by the workflow. Driven by a real `vX.Y.Z-rcN` tag (not --snapshot). +version: 2 + +before: + hooks: + - go mod tidy + +builds: + - id: matcha + main: . + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + flags: + - -trimpath + ldflags: + - -s -w -buildid= -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} + env: + - CGO_ENABLED=1 + - >- + {{- if eq .Os "darwin" }}SDKROOT={{ .Env.SDK_PATH }}{{- end }} + - >- + {{- if eq .Os "darwin" }}MACOSX_DEPLOYMENT_TARGET=11.0{{- end }} + - >- + {{- if eq .Os "darwin" }}CGO_CFLAGS=-isysroot {{ .Env.SDK_PATH }} -mmacosx-version-min=11.0{{- end }} + - >- + {{- if eq .Os "darwin" }}CGO_LDFLAGS=-isysroot {{ .Env.SDK_PATH }} + {{- else if eq .Os "linux" }}CGO_LDFLAGS=-L{{ .Env.PCSC_DIR }}/lib/{{ .Arch }} + {{- end }} + - >- + {{- if eq .Os "darwin" }} + {{- if eq .Arch "amd64"}}CC=clang -arch x86_64 -isysroot {{ .Env.SDK_PATH }}{{- end }} + {{- if eq .Arch "arm64"}}CC=clang -arch arm64 -isysroot {{ .Env.SDK_PATH }}{{- end }} + {{- else if eq .Os "linux" }} + {{- if eq .Arch "amd64" }}CC=zig cc -target x86_64-linux-musl -lc{{- end }} + {{- if eq .Arch "arm64"}}CC=zig cc -target aarch64-linux-musl -lc{{- end }} + {{- else if eq .Os "windows" }} + {{- if eq .Arch "amd64" }}CC=zig cc -target x86_64-windows-gnu -lc{{- end }} + {{- if eq .Arch "arm64"}}CC=zig cc -target aarch64-windows-gnu -lc{{- end }} + {{- end }} + +archives: + - formats: [tar.gz] + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + format_overrides: + - goos: windows + formats: ["zip"] + files: + - LICENSE + - README.md + +checksum: + name_template: "checksums.txt" + +changelog: + use: github-native + +# Force pre-release regardless of tag parsing, and let GoReleaser create the +# GitHub release with the rc artifacts attached. +release: + draft: false + prerelease: true + name_template: "{{ .Tag }}" + footer: | + --- + > [!WARNING] + > This is a **release candidate** for v1 — not a final release. Expect bugs and report them. + + - **Snapcraft:** `snap install matcha --candidate` From 971704bab599b49ba118c83ff7b841d8daeb18a8 Mon Sep 17 00:00:00 2001 From: Drew Smirnoff Date: Mon, 1 Jun 2026 23:25:56 +0400 Subject: [PATCH 03/15] chore: use go-keybind (#1397) ## What? Uses [`go-keybind`](https://github.com/floatpane/go-keybind). ## Why? Easier to maintain/expand Signed-off-by: drew (cherry picked from commit 1890665a3ea81b9f9dd3921cdb9bc902a7091671) --- config/keybinds.go | 147 +++++++++++++++++---------------------------- go.mod | 1 + go.sum | 2 + 3 files changed, 58 insertions(+), 92 deletions(-) diff --git a/config/keybinds.go b/config/keybinds.go index 7f7c54b1..bb53d139 100644 --- a/config/keybinds.go +++ b/config/keybinds.go @@ -3,12 +3,11 @@ package config import ( _ "embed" "encoding/json" - "fmt" - "os" - "path/filepath" + + keybind "github.com/floatpane/go-keybind" ) -const keyDelete = "delete" +const keyDelete = "delete" // used in ValidateKeybinds action map keys //go:embed default_keybinds.json var defaultKeybindsJSON []byte @@ -94,27 +93,9 @@ func defaultKeybinds() KeybindsConfig { // LoadKeybindsFromDir reads keybinds.json from cfgDir, writing defaults if // the file does not exist, then updates the package-level Keybinds var. func LoadKeybindsFromDir(cfgDir string) error { - path := filepath.Join(cfgDir, "keybinds.json") - - data, err := os.ReadFile(path) + kb, err := keybind.Load(cfgDir, "keybinds.json", defaultKeybinds()) if err != nil { - if !os.IsNotExist(err) { - return fmt.Errorf("keybinds: read %s: %w", path, err) - } - // File missing — write defaults. - if err := os.MkdirAll(cfgDir, 0700); err != nil { - return fmt.Errorf("keybinds: mkdir %s: %w", cfgDir, err) - } - if err := os.WriteFile(path, defaultKeybindsJSON, 0600); err != nil { - return fmt.Errorf("keybinds: write defaults to %s: %w", path, err) - } - Keybinds = defaultKeybinds() - return nil - } - - kb := defaultKeybinds() - if err := json.Unmarshal(data, &kb); err != nil { - return fmt.Errorf("keybinds: parse %s: %w", path, err) + return err } Keybinds = kb return nil @@ -124,73 +105,55 @@ func LoadKeybindsFromDir(cfgDir string) error { // actions within the same area are mapped to the same key. Cross-area // duplicates are intentional (e.g. "d" = delete in both inbox and email view). func ValidateKeybinds(kb KeybindsConfig) []string { - var conflicts []string - - check := func(area string, bindings map[string]string) { - seen := make(map[string]string) // key → action name - for action, key := range bindings { - if key == "" { - continue - } - if prev, ok := seen[key]; ok { - conflicts = append(conflicts, - fmt.Sprintf("conflict in %s: key %q used for both %q and %q", area, key, prev, action)) - } else { - seen[key] = action - } - } - } - - check("global", map[string]string{ - "quit": kb.Global.Quit, - "cancel": kb.Global.Cancel, - "nav_up": kb.Global.NavUp, - "nav_down": kb.Global.NavDown, + return keybind.Validate(map[string]map[string]string{ + "global": { + "quit": kb.Global.Quit, + "cancel": kb.Global.Cancel, + "nav_up": kb.Global.NavUp, + "nav_down": kb.Global.NavDown, + }, + "inbox": { + "visual_mode": kb.Inbox.VisualMode, + "toggle_threaded": kb.Inbox.ToggleThreaded, + keyDelete: kb.Inbox.Delete, + "archive": kb.Inbox.Archive, + "refresh": kb.Inbox.Refresh, + "search": kb.Inbox.Search, + "filter": kb.Inbox.Filter, + "open": kb.Inbox.Open, + "next_tab": kb.Inbox.NextTab, + "prev_tab": kb.Inbox.PrevTab, + }, + "email": { + "reply": kb.Email.Reply, + "forward": kb.Email.Forward, + keyDelete: kb.Email.Delete, + "archive": kb.Email.Archive, + "toggle_images": kb.Email.ToggleImages, + "rsvp_accept": kb.Email.RsvpAccept, + "rsvp_decline": kb.Email.RsvpDecline, + "rsvp_tentative": kb.Email.RsvpTentative, + "focus_attachments": kb.Email.FocusAttachments, + }, + "composer": { + "external_editor": kb.Composer.ExternalEditor, + "next_field": kb.Composer.NextField, + "prev_field": kb.Composer.PrevField, + keyDelete: kb.Composer.Delete, + // spell_* bindings intentionally excluded — spell_accept reusing + // "tab" with next_field and spell_dismiss reusing "esc" with cancel + // are deliberate: the spellcheck popup intercepts before those handlers. + }, + "folder": { + "next_folder": kb.Folder.NextFolder, + "prev_folder": kb.Folder.PrevFolder, + "move": kb.Folder.Move, + "focus_preview": kb.Folder.FocusPreview, + "focus_inbox": kb.Folder.FocusInbox, + }, + "drafts": { + "open": kb.Drafts.Open, + keyDelete: kb.Drafts.Delete, + }, }) - check("inbox", map[string]string{ - "visual_mode": kb.Inbox.VisualMode, - "toggle_threaded": kb.Inbox.ToggleThreaded, - keyDelete: kb.Inbox.Delete, - "archive": kb.Inbox.Archive, - "refresh": kb.Inbox.Refresh, - "search": kb.Inbox.Search, - "filter": kb.Inbox.Filter, - "open": kb.Inbox.Open, - "next_tab": kb.Inbox.NextTab, - "prev_tab": kb.Inbox.PrevTab, - }) - check("email", map[string]string{ - "reply": kb.Email.Reply, - "forward": kb.Email.Forward, - keyDelete: kb.Email.Delete, - "archive": kb.Email.Archive, - "toggle_images": kb.Email.ToggleImages, - "rsvp_accept": kb.Email.RsvpAccept, - "rsvp_decline": kb.Email.RsvpDecline, - "rsvp_tentative": kb.Email.RsvpTentative, - "focus_attachments": kb.Email.FocusAttachments, - }) - check("composer", map[string]string{ - "external_editor": kb.Composer.ExternalEditor, - "next_field": kb.Composer.NextField, - "prev_field": kb.Composer.PrevField, - keyDelete: kb.Composer.Delete, - // spell_* bindings intentionally excluded from this conflict - // check — spell_accept reusing "tab" with next_field, and - // spell_dismiss reusing "esc" with cancel, are deliberate: the - // spellcheck popup intercepts before those handlers fire. - }) - check("folder", map[string]string{ - "next_folder": kb.Folder.NextFolder, - "prev_folder": kb.Folder.PrevFolder, - "move": kb.Folder.Move, - "focus_preview": kb.Folder.FocusPreview, - "focus_inbox": kb.Folder.FocusInbox, - }) - check("drafts", map[string]string{ - "open": kb.Drafts.Open, - keyDelete: kb.Drafts.Delete, - }) - - return conflicts } diff --git a/go.mod b/go.mod index b6d37350..6f6adc47 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 github.com/floatpane/bubble-overlay v0.0.1 github.com/floatpane/go-icalendar v0.0.1 + github.com/floatpane/go-keybind v0.0.1 github.com/floatpane/go-openpgp-card-hl v0.0.1 github.com/floatpane/go-secretbox v0.1.0 github.com/floatpane/go-uds-jsonrpc v0.0.1 diff --git a/go.sum b/go.sum index b533e757..0115e47b 100644 --- a/go.sum +++ b/go.sum @@ -72,6 +72,8 @@ github.com/floatpane/bubble-overlay v0.0.1 h1:5xU8cNigDPYegvgGMfOG23fIDXhrqXPvLT github.com/floatpane/bubble-overlay v0.0.1/go.mod h1:Csi1byxb9L8EAb8X13XdWF5aX5YiBD5C9WEWACyGa8A= github.com/floatpane/go-icalendar v0.0.1 h1:lF9NhEI4TobX8valDakAFfCnBhM2GDITWMVymhXzD8c= github.com/floatpane/go-icalendar v0.0.1/go.mod h1:LSy9G+LwUZtfNIAjLlEVRXkuc2A+hq6+pVCIFOiEAyE= +github.com/floatpane/go-keybind v0.0.1 h1:UrzPQ4ldR9sKQt/efpUfcs6gH8Mwy2NO5vwFTVf0dy0= +github.com/floatpane/go-keybind v0.0.1/go.mod h1:B8l43ypYOcjknyaHgU0EXUAbrUvfnV2HOzYSzyqcPJI= github.com/floatpane/go-openpgp-card-hl v0.0.1 h1:1DYmzwGDb8eneZxbc/xtwjXeFY8DFL3eYnUooMT0L0w= github.com/floatpane/go-openpgp-card-hl v0.0.1/go.mod h1:Mrx+ukCnpEpMAxyB0p8Ch2gu78Q3Ir40BxBybb2jirw= github.com/floatpane/go-secretbox v0.1.0 h1:xNryazmCP0oR/yVxIkHRc5bcV56YrbisY+bMl8BBfwU= From 35e99bb143fe2a269d72e6350f9baabf51e90590 Mon Sep 17 00:00:00 2001 From: Mohamed Mahmoud Date: Mon, 1 Jun 2026 22:30:30 +0300 Subject: [PATCH 04/15] test: implement encryption tests (#1399) ## What? Implements a complete test suite for the `encryption.go`. ## Why? The previous implementation had no test coverage for the `encryption.go`. Closes #886 (cherry picked from commit e4987f35468dccb6644ed38715cdee1183d72084) --- config/encryption_test.go | 178 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 config/encryption_test.go diff --git a/config/encryption_test.go b/config/encryption_test.go new file mode 100644 index 00000000..a9e8a5eb --- /dev/null +++ b/config/encryption_test.go @@ -0,0 +1,178 @@ +package config + +import ( + "bytes" + "strings" + "testing" +) + +func TestEncryptDecrypt_RoundTrip(t *testing.T) { + salt := make([]byte, 32) + + plaintext := []byte("hello") + key := DeriveKey("password", salt) + + ciphertext, err := Encrypt(plaintext, key) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + got, err := Decrypt(ciphertext, key) + if err != nil { + t.Fatalf("Decrypt: %v", err) + } + + if !bytes.Equal(got, plaintext) { + t.Errorf("mismatch: got %q, want %q", got, plaintext) + } +} + +func TestEncryptDecrypt_EmptyPlaintext(t *testing.T) { + salt := make([]byte, 32) + key := DeriveKey("password", salt) + + ciphertext, err := Encrypt([]byte{}, key) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + got, err := Decrypt(ciphertext, key) + if err != nil { + t.Fatalf("Decrypt: %v", err) + } + + if len(got) != 0 { + t.Errorf("expected empty plaintext, got %q", got) + } +} + +func TestEncryptDecrypt_LongPassword(t *testing.T) { + salt := make([]byte, 32) + + plaintext := []byte("hello") + key := DeriveKey(strings.Repeat("x", 10000), salt) + + ciphertext, err := Encrypt(plaintext, key) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + got, err := Decrypt(ciphertext, key) + if err != nil { + t.Fatalf("Decrypt: %v", err) + } + + if !bytes.Equal(got, plaintext) { + t.Errorf("mismatch: got %q, want %q", got, plaintext) + } +} + +func TestEncryptDecrypt_LongPlaintext(t *testing.T) { + salt := make([]byte, 32) + + plaintext := bytes.Repeat([]byte("hello"), 1<<20) + key := DeriveKey("password", salt) + + ciphertext, err := Encrypt(plaintext, key) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + got, err := Decrypt(ciphertext, key) + if err != nil { + t.Fatalf("Decrypt: %v", err) + } + + if !bytes.Equal(got, plaintext) { + t.Error("long plaintext round-trip mismatch") + } +} + +func TestEncrypt_NonceIsRandom(t *testing.T) { + salt := make([]byte, 32) + + plaintext := []byte("hello") + key := DeriveKey("password", salt) + + c1, err := Encrypt(plaintext, key) + if err != nil { + t.Fatalf("Encrypt 1: %v", err) + } + c2, err := Encrypt(plaintext, key) + if err != nil { + t.Fatalf("Encrypt 2: %v", err) + } + + if bytes.Equal(c1, c2) { + t.Error("two Encrypt calls produced identical ciphertext") + } +} + +func TestDecrypt_WrongKey(t *testing.T) { + s1 := make([]byte, 32) + s2 := bytes.Repeat([]byte{1}, 32) + + k1 := DeriveKey("p1", s1) + k2 := DeriveKey("p2", s2) + + ciphertext, err := Encrypt([]byte("hello"), k1) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + if _, err := Decrypt(ciphertext, k2); err == nil { + t.Error("Decrypt with wrong key should return an error") + } +} + +func TestDecrypt_CorruptedCiphertext(t *testing.T) { + salt := make([]byte, 32) + + plaintext := []byte("hello") + key := DeriveKey("password", salt) + + ciphertext, err := Encrypt(plaintext, key) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + ciphertext[0] ^= 0xff + + if _, err := Decrypt(ciphertext, key); err == nil { + t.Error("Decrypt with corrupted ciphertext should return an error") + } +} + +func TestDeriveKey_Deterministic(t *testing.T) { + salt := make([]byte, 32) + + k1 := DeriveKey("password", salt) + k2 := DeriveKey("password", salt) + + if !bytes.Equal(k1, k2) { + t.Error("DeriveKey should be deterministic for the same input") + } +} + +func TestDeriveKey_DifferentPasswords(t *testing.T) { + salt := make([]byte, 32) + + k1 := DeriveKey("password1", salt) + k2 := DeriveKey("password2", salt) + + if bytes.Equal(k1, k2) { + t.Error("different passwords should produce different keys") + } +} + +func TestDeriveKey_DifferentSalts(t *testing.T) { + s1 := make([]byte, 32) + s2 := bytes.Repeat([]byte{1}, 32) + + k1 := DeriveKey("password", s1) + k2 := DeriveKey("password", s2) + + if bytes.Equal(k1, k2) { + t.Error("different salts should produce different keys") + } +} From ad624347ec0cfbc004f123f196d1a9a655e17141 Mon Sep 17 00:00:00 2001 From: FromSi Date: Tue, 2 Jun 2026 00:34:51 +0500 Subject: [PATCH 05/15] fix(badge): dedupe unread count (#1398) ## What? Deduplicate unread badge counting across `emailsByAcct` and `folderEmails` by tracking seen emails with `AccountID + UID`. Added a regression test for the case where the same unread email exists in both stores. image ## Why? Closes #1107 `syncUnreadBadge` counted unread emails from both stores independently, but the stores can contain the same fetched messages. This could make the macOS unread badge show roughly double the real unread count. image (cherry picked from commit cbd11372c1fa1520d46b4592989949f9ce6c53ee) --- main.go | 43 +++++++++++++++++++++++++++++++------------ main_test.go | 18 ++++++++++++++++++ 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/main.go b/main.go index 406397a5..07db2972 100644 --- a/main.go +++ b/main.go @@ -242,27 +242,46 @@ func waitForLogEntry(ch <-chan logging.Entry) tea.Cmd { } } -func (m *mainModel) syncUnreadBadge() { - if runtime.GOOS != goosDarwin { - return - } +func unreadBadgeCount(emailsByAcct, folderEmails map[string][]fetcher.Email) int { count := 0 + seen := make(map[string]struct{}) + + countUnread := func(e fetcher.Email) { + if e.IsRead { + return + } + key := fmt.Sprintf("%s:%d", e.AccountID, e.UID) + if _, ok := seen[key]; ok { + return + } + seen[key] = struct{}{} + count++ + } + // Count unread across all accounts (cached/loaded emails) - for _, emails := range m.emailsByAcct { + for _, emails := range emailsByAcct { for _, e := range emails { - if !e.IsRead { - count++ - } + countUnread(e) } } // Also check folderEmails for unread status - for _, emails := range m.folderEmails { + for _, emails := range folderEmails { for _, e := range emails { - if !e.IsRead { - count++ - } + countUnread(e) } } + return count +} + +func (m *mainModel) syncUnreadBadge() { + if runtime.GOOS != goosDarwin && loglevel.Get() < loglevel.LevelDebug { + return + } + count := unreadBadgeCount(m.emailsByAcct, m.folderEmails) + loglevel.Debugf("unread badge count: %d", count) + if runtime.GOOS != goosDarwin { + return + } _ = macos.SetBadge(count) } diff --git a/main_test.go b/main_test.go index 6484635c..a7ab1826 100644 --- a/main_test.go +++ b/main_test.go @@ -5,6 +5,8 @@ import ( "strings" "testing" "unicode/utf8" + + "github.com/floatpane/matcha/fetcher" ) func TestSanitizeFilenameTruncatesCJKOnUTF8Boundary(t *testing.T) { @@ -58,3 +60,19 @@ func TestParseGlobalFlagsDoesNotConsumeSubcommandFlags(t *testing.T) { t.Fatalf("args = %q, want %q", got, "matcha send --logs") } } + +func TestUnreadBadgeCountDeduplicatesOverlappingStores(t *testing.T) { + email := fetcher.Email{UID: 42, AccountID: "acct-a"} + got := unreadBadgeCount( + map[string][]fetcher.Email{ + "acct-a": {email}, + }, + map[string][]fetcher.Email{ + folderInbox: {email}, + }, + ) + + if got != 1 { + t.Fatalf("unreadBadgeCount() = %d, want 1", got) + } +} From bbf27a6a16cadbc3930cda0aeb810790dc7b02bc Mon Sep 17 00:00:00 2001 From: Floatpane Bot Date: Tue, 2 Jun 2026 01:16:15 +0400 Subject: [PATCH 06/15] chore(deps): charm.land/bubbletea/v2 ^ v2.0.7 (#1405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What? This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [charm.land/bubbletea/v2](https://redirect.github.com/charmbracelet/bubbletea) | `v2.0.6` → `v2.0.7` | ![age](https://developer.mend.io/api/mc/badges/age/go/charm.land%2fbubbletea%2fv2/v2.0.7?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/charm.land%2fbubbletea%2fv2/v2.0.6/v2.0.7?slim=true) | --- ### Release Notes
charmbracelet/bubbletea (charm.land/bubbletea/v2) ### [`v2.0.7`](https://redirect.github.com/charmbracelet/bubbletea/releases/tag/v2.0.7) [Compare Source](https://redirect.github.com/charmbracelet/bubbletea/compare/v2.0.6...v2.0.7) ### A few lil’ stability patches Hi! This is a patch release with a few solid improvements around stability and correctness. - [@​lrstanley](https://redirect.github.com/lrstanley), one of our faves, fixed a race condition around mice in the Cursed Renderer - [@​lawrence3699](https://redirect.github.com/lawrence3699) fixed a panic that could happen when input's not available - We fixed a correctness issue with regard to mouse releases when Kitty Keyboard was active (thanks, [@​mitchellh](https://redirect.github.com/mitchellh)) Thanks for using Bubble Tea, and if you see anything awry please do let us know! —Charm 👋 #### Changelog ##### Fixed - [`c60f0c5`](https://redirect.github.com/charmbracelet/bubbletea/commit/c60f0c53042238305ec13b486326588f12aea0ec): fix: prevent data race with cursedRenderer.onMouse ([#​1691](https://redirect.github.com/charmbracelet/bubbletea/issues/1691)) ([@​lrstanley](https://redirect.github.com/lrstanley)) - [`074596e`](https://redirect.github.com/charmbracelet/bubbletea/commit/074596e14e2f5ca5e3986ee72e7c08f1569c4178): fix: skip input reader restore when input is disabled ([#​1680](https://redirect.github.com/charmbracelet/bubbletea/issues/1680)) ([@​lawrence3699](https://redirect.github.com/lawrence3699)) - [`878d7df`](https://redirect.github.com/charmbracelet/bubbletea/commit/878d7df2f2b02f3ca8db177fa8553834bc35ea7c): fix(deps): bump ultraviolet for kitty keyboard fix ([@​meowgorithm](https://redirect.github.com/meowgorithm)) *** The Charm logo Thoughts? Questions? We love hearing from you. Feel free to reach out on [X](https://x.com/charmcli), [Discord](https://charm.land/discord), [Slack](https://charm.land/slack), [The Fediverse](https://mastodon.social/@​charmcli), [Bluesky](https://bsky.app/profile/charm.land).
## Why? Automated dependency update via Renovate. --- ### Configuration 📅 **Schedule**: (UTC) - Branch creation - At any time (no schedule defined) - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- --- - [ ] If you want to rebase/retry this PR, check this box --- This PR has been generated by [Mend Renovate](https://redirect.github.com/renovatebot/renovate). (cherry picked from commit daab110ca06ae63ad7ad13f5440e0a4904fc9fea) --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 6f6adc47..7b41b7a5 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.26.3 require ( charm.land/bubbles/v2 v2.1.0 - charm.land/bubbletea/v2 v2.0.6 + charm.land/bubbletea/v2 v2.0.7 charm.land/lipgloss/v2 v2.0.3 git.sr.ht/~rockorager/go-jmap v0.5.3 github.com/ProtonMail/go-crypto v1.4.1 @@ -44,7 +44,7 @@ require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect - github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 // 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/go.sum b/go.sum index 0115e47b..815c984f 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g= charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY= -charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo= -charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g= +charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0= +charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs= charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU= charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA= cunicu.li/go-iso7816 v0.8.8 h1:Srk2XLxWvS5GI1Hu5XJfrmW1R0mf3ghJnipo/aKJiZM= @@ -30,8 +30,8 @@ github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd3 github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= -github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 h1:Q9fO0y1Zo5KB/5Vu8JZoLGm1N3RzF9bNj3Ao3xoR+Ac= -github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM= +github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 h1:FpSYhY28ucg9ZRr+2wj67FAQ0Ey5yiK0072PmRDJNek= +github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654/go.mod h1:hFpumms29Smx3LStRfku8vcCTBe1Kq8aCXtHUJa3mjY= github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= From 972b6324c1b5d43785c825943418a108047a6c37 Mon Sep 17 00:00:00 2001 From: Floatpane Bot Date: Tue, 2 Jun 2026 01:16:38 +0400 Subject: [PATCH 07/15] chore(deps): react ^ 19.2.7 (#1406) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What? This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [react](https://react.dev/) ([source](https://redirect.github.com/facebook/react/tree/HEAD/packages/react)) | [`19.2.6` → `19.2.7`](https://renovatebot.com/diffs/npm/react/19.2.6/19.2.7) | ![age](https://developer.mend.io/api/mc/badges/age/npm/react/19.2.7?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/react/19.2.6/19.2.7?slim=true) | | [react-dom](https://react.dev/) ([source](https://redirect.github.com/facebook/react/tree/HEAD/packages/react-dom)) | [`19.2.6` → `19.2.7`](https://renovatebot.com/diffs/npm/react-dom/19.2.6/19.2.7) | ![age](https://developer.mend.io/api/mc/badges/age/npm/react-dom/19.2.7?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/react-dom/19.2.6/19.2.7?slim=true) | --- ### Release Notes
facebook/react (react) ### [`v19.2.7`](https://redirect.github.com/facebook/react/releases/tag/v19.2.7): 19.2.7 (June 1st, 2026) [Compare Source](https://redirect.github.com/facebook/react/compare/v19.2.6...v19.2.7) ##### React Server Components - Fixed missing `FormData` entries in Server Actions which regressed in 19.2.6 ([#​36566](https://redirect.github.com/facebook/react/pull/36566) by [@​unstubbable](https://redirect.github.com/unstubbable))
## Why? Automated dependency update via Renovate. --- ### Configuration 📅 **Schedule**: (UTC) - Branch creation - At any time (no schedule defined) - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about these updates again. --- --- - [ ] If you want to rebase/retry this PR, check this box --- This PR has been generated by [Mend Renovate](https://redirect.github.com/renovatebot/renovate). (cherry picked from commit 4ee8a9bf716f56b0ac7fcbf37cabaee1151e6303) --- docs/package-lock.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/package-lock.json b/docs/package-lock.json index 1a5fdefd..ebbf5e10 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -15338,24 +15338,24 @@ } }, "node_modules/react": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", - "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", - "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.6" + "react": "^19.2.7" } }, "node_modules/react-fast-compare": { From d99b5c63206add1f5b8c315a8cf0faad2caf61ba Mon Sep 17 00:00:00 2001 From: Floatpane Bot Date: Mon, 1 Jun 2026 23:35:35 +0400 Subject: [PATCH 08/15] chore: update flake.lock (#1401) ## What? Updates `flake.lock` to the latest revisions of all flake inputs (`nixpkgs`, `flake-utils`, etc.). ## Why? Keeps Nix inputs current so contributors and CI build against fresh `nixpkgs`. Picks up upstream security and toolchain fixes. Generated automatically by the flake-lock update workflow on changes to `go.sum`. (cherry picked from commit 1295b58d2d43ee0099d2d0bbe670fed8311f28ea) --- flake.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flake.lock b/flake.lock index a2b31c14..97ef3d0f 100644 --- a/flake.lock +++ b/flake.lock @@ -59,11 +59,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1780030872, - "narHash": "sha256-u6WU/yd/o8iYQrHX3RAwO1hYa3LkoSL+WNQD0rJfJZQ=", + "lastModified": 1780246643, + "narHash": "sha256-4T1KWX7xWGQMs9hNZ24IOY3aYOi8D6+5WtkNRBSttB8=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "e9a7635a57597d9754eccebdfc7045e6c8600e6b", + "rev": "3109eaae18e09d0b8aef23dc2579e7d94b8d4b4e", "type": "github" }, "original": { From b3a1794b154b55b1fb2d5adce282263634933b04 Mon Sep 17 00:00:00 2001 From: Floatpane Bot Date: Tue, 2 Jun 2026 01:29:40 +0400 Subject: [PATCH 09/15] chore: sync gomod2nix.toml (#1407) ## What? Regenerates `gomod2nix.toml` to reflect the current `go.mod` / `go.sum`. ## Why? Keeps the Nix build in sync with Go module changes. Without this, `nix build` fails when new or upgraded Go deps are missing from `gomod2nix.toml`. Generated automatically by the gomod2nix sync workflow. (cherry picked from commit 617e1aaaac9615aa158b196dad5ed7fba1263a01) --- gomod2nix.toml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/gomod2nix.toml b/gomod2nix.toml index 8e48f833..d497e816 100644 --- a/gomod2nix.toml +++ b/gomod2nix.toml @@ -6,8 +6,8 @@ schema = 3 hash = 'sha256-2OmqpBrl+taOJzAhVM6OReLmoYRxZOXx9JqFNjQjsPA=' [mod.'charm.land/bubbletea/v2'] - version = 'v2.0.6' - hash = 'sha256-1jxXmcnI4peUE0Xs3HGe57pIhRONx235aAaeqm2r434=' + version = 'v2.0.7' + hash = 'sha256-YOkvKUahFMMytHXr3AAABDifBtHt29930obiu/kx1vA=' [mod.'charm.land/lipgloss/v2'] version = 'v2.0.3' @@ -54,8 +54,8 @@ schema = 3 hash = 'sha256-y+QDUxGOKhugEMQLRUTZYT2C+wKqYHnMLJ44jbh7+JA=' [mod.'github.com/charmbracelet/ultraviolet'] - version = 'v0.0.0-20260416155717-489999b90468' - hash = 'sha256-HAex/0iEd42Wk1t+AR0O8J+F2ZAYU2sTw9ea0EfmKEU=' + version = 'v0.0.0-20260525132238-948f4557a654' + hash = 'sha256-V0e1U50upnr+gWvao41MTiLHAbd5wAlt4GD8hiAVZ38=' [mod.'github.com/charmbracelet/x/ansi'] version = 'v0.11.7' From 64e96a8d0ab438ab61abd9e10d6e392ff9289435 Mon Sep 17 00:00:00 2001 From: Floatpane Bot Date: Mon, 1 Jun 2026 23:36:12 +0400 Subject: [PATCH 10/15] chore: sync gomod2nix.toml (#1402) ## What? Regenerates `gomod2nix.toml` to reflect the current `go.mod` / `go.sum`. ## Why? Keeps the Nix build in sync with Go module changes. Without this, `nix build` fails when new or upgraded Go deps are missing from `gomod2nix.toml`. Generated automatically by the gomod2nix sync workflow. (cherry picked from commit f97aca4d9597e2e8b8712f0a6298254b501f05e7) --- gomod2nix.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/gomod2nix.toml b/gomod2nix.toml index d497e816..f813ee84 100644 --- a/gomod2nix.toml +++ b/gomod2nix.toml @@ -121,6 +121,10 @@ schema = 3 version = 'v0.0.1' hash = 'sha256-pWL1yAiNKYsecSyas7jUtyW+FC/Exnb74e/XVsRlu5U=' + [mod.'github.com/floatpane/go-keybind'] + version = 'v0.0.1' + hash = 'sha256-hXPLDYyK0YQ2uclQKt4MM607gHblh1t9BQkPkZy1B1Y=' + [mod.'github.com/floatpane/go-openpgp-card-hl'] version = 'v0.0.1' hash = 'sha256-qssT4qCMoe9dgFISyCLhthlPe36dBJNnGU1bHjbCpK4=' From 5a1b8db63bbefd9afb1b7b59008e9031027eb455 Mon Sep 17 00:00:00 2001 From: Drew Smirnoff Date: Wed, 3 Jun 2026 12:17:31 +0400 Subject: [PATCH 11/15] feat(gitmail): initial (#1420) ## What? Adds a git-mail apply workflow to matcha: take a `git format-patch` / `git send-email` message and apply it to a local working tree, without shelling out to git. ## Why? Part of #1411 (not fully done) --------- Signed-off-by: drew --- cli/gitmail_apply.go | 95 +++++++++++++++++++++++++++++++++ docs/docs/Features/CLI.md | 86 ++++++++++++++++++++++++++++++ gitmail/gitmail.go | 107 ++++++++++++++++++++++++++++++++++++++ gitmail/gitmail_test.go | 97 ++++++++++++++++++++++++++++++++++ go.mod | 2 + go.sum | 4 ++ main.go | 9 ++++ 7 files changed, 400 insertions(+) create mode 100644 cli/gitmail_apply.go create mode 100644 gitmail/gitmail.go create mode 100644 gitmail/gitmail_test.go diff --git a/cli/gitmail_apply.go b/cli/gitmail_apply.go new file mode 100644 index 00000000..240fde28 --- /dev/null +++ b/cli/gitmail_apply.go @@ -0,0 +1,95 @@ +package cli + +import ( + "flag" + "fmt" + "io" + "os" + + "github.com/floatpane/matcha/gitmail" +) + +// RunApply handles the "matcha apply" subcommand: apply a format-patch email +// (from a file or stdin) to a local git working tree. +func RunApply(args []string) error { + fs := flag.NewFlagSet("apply", flag.ExitOnError) + repo := fs.String("repo", ".", "path to the working tree to apply into") + reverse := fs.Bool("reverse", false, "unapply the patch instead of applying it") + check := fs.Bool("check", false, "validate only; do not write any files") + series := fs.Bool("series", false, "treat the input as an mbox and apply the whole series") + help := fs.Bool("h", false, "show help") + + if err := fs.Parse(args); err != nil { + return err + } + + if *help { + fmt.Println("Usage: matcha apply [flags] [patch-file]") + fmt.Println("") + fmt.Println("Apply a patch received by email (git format-patch / send-email)") + fmt.Println("to a local working tree. Reads the patch from the given file, or") + fmt.Println("from stdin when no file is given. Never runs git.") + fmt.Println("") + fmt.Println("Flags:") + fs.PrintDefaults() + fmt.Println("") + fmt.Println("Examples:") + fmt.Println(" matcha apply fix.patch --repo ~/src/proj") + fmt.Println(" git format-patch -1 --stdout | matcha apply --repo .") + fmt.Println(" matcha apply --check series.mbox --series # dry-run a series") + fmt.Println(" matcha apply --reverse fix.patch # undo it") + return nil + } + + raw, err := readPatchInput(fs.Arg(0)) + if err != nil { + return err + } + + opts := gitmail.Options{Reverse: *reverse, DryRun: *check} + + if *series { + summaries, err := gitmail.ApplySeries(raw, *repo, opts) + printSummaries(summaries, *check) + return err + } + + summary, err := gitmail.Apply(raw, *repo, opts) + if err != nil { + return err + } + printSummaries([]*gitmail.Summary{summary}, *check) + return nil +} + +// readPatchInput reads the patch from path, or from stdin when path is empty. +func readPatchInput(path string) ([]byte, error) { + if path == "" || path == "-" { + return io.ReadAll(os.Stdin) + } + return os.ReadFile(path) +} + +func printSummaries(summaries []*gitmail.Summary, dryRun bool) { + verb := "applied" + if dryRun { + verb = "would apply" + } + for _, s := range summaries { + if s == nil { + continue + } + if s.CoverLetter { + fmt.Printf("cover letter: %s (nothing to apply)\n", s.Subject) + continue + } + fmt.Printf("%s: %s — %s\n", verb, s.Subject, s.Author) + for _, f := range s.Files { + if f.OldPath != "" && f.OldPath != f.Path { + fmt.Printf(" %-8s %s -> %s\n", f.Status, f.OldPath, f.Path) + } else { + fmt.Printf(" %-8s %s\n", f.Status, f.Path) + } + } + } +} diff --git a/docs/docs/Features/CLI.md b/docs/docs/Features/CLI.md index 387404dc..89289460 100644 --- a/docs/docs/Features/CLI.md +++ b/docs/docs/Features/CLI.md @@ -88,6 +88,92 @@ matcha send --from work@company.com --to someone@example.com --subject "Hi" --bo | `0` | Email sent successfully | | `1` | Error (missing flags, bad config, send failure) | +## matcha apply + +Apply a patch you received by email — the output of `git format-patch` / +`git send-email` — to a local working tree. This is matcha's "git-mail" +workflow: review a patch in your inbox, then apply it without leaving the +terminal. + +```bash +matcha apply [patch-file] [flags] +``` + +The patch is read from `patch-file`, or from **stdin** when no file is given +(or when the file is `-`). Matcha **never runs git** — it parses the email and +writes the file changes directly, confined to the target directory. + +### Flags + +| Flag | Description | +|------|-------------| +| `--repo` | Working tree to apply into (default: current directory) | +| `--check` | Validate only — report what would change, write nothing (dry run) | +| `--reverse` | Unapply the patch instead of applying it | +| `--series` | Treat the input as an mbox and apply the whole patch series in order | +| `-h` | Show help | + +### Examples + +**Apply a saved patch to a project:** + +```bash +matcha apply fix.patch --repo ~/src/myproject +``` + +**Pipe a patch straight from git:** + +```bash +git format-patch -1 --stdout | matcha apply --repo . +``` + +**Dry-run before committing to it:** + +```bash +matcha apply --check fix.patch --repo ~/src/myproject +``` + +**Apply a whole series from an mbox:** + +```bash +matcha apply series.mbox --series --repo ~/src/myproject +``` + +**Undo a patch you applied:** + +```bash +matcha apply --reverse fix.patch --repo ~/src/myproject +``` + +### How it behaves + +- **Transactional (per patch).** Every hunk is matched in memory before + anything is written. If a hunk does not apply, the file is left untouched and + the command exits non-zero — you never get a half-applied file. +- **Offset-tolerant, context-exact.** A patch still applies when surrounding + edits have shifted the target lines, but the context itself must match — + matcha will not silently patch the wrong place. +- **Path-confined.** Patches whose paths try to escape `--repo` (via `../` or + an absolute path) are rejected before any file is touched. +- **Series caveat.** `--series` is transactional per patch, not across the + whole series: if patch 3 of 5 conflicts, patches 1–2 are already written. + Use `--check --series` first to validate the whole set. + +Matcha does **not** create a git commit, move `HEAD`, or touch the index — it +only edits files. Commit the result yourself once you are happy with it. + +> Under the hood, `matcha apply` uses the standalone +> [`go-mailpatch`](https://github.com/floatpane/go-mailpatch) (parsing) and +> [`go-patchapply`](https://github.com/floatpane/go-patchapply) (applying) +> libraries, extracted from matcha. + +### Exit Codes + +| Code | Meaning | +|------|---------| +| `0` | Patch applied (or, with `--check`, would apply) cleanly | +| `1` | Error (parse failure, hunk conflict, unsafe path, missing/existing file) | + ## matcha marketplace Open the interactive plugin marketplace in the terminal. Fetches the plugin registry from GitHub and displays a browsable list of available plugins. diff --git a/gitmail/gitmail.go b/gitmail/gitmail.go new file mode 100644 index 00000000..aea5edbb --- /dev/null +++ b/gitmail/gitmail.go @@ -0,0 +1,107 @@ +// Package gitmail applies patches received as email to a local git working +// tree. It is matcha's "git-mail" feature: a message produced by +// `git format-patch` / `git send-email` can be applied to a checkout without +// shelling out to git, using floatpane's parser and applier libraries. +// +// - github.com/floatpane/go-mailpatch parses the RFC 5322 message into commit +// metadata and a structured diff. +// - github.com/floatpane/go-patchapply writes the diff to a directory, +// confined to that directory and applied transactionally. +package gitmail + +import ( + "bytes" + "fmt" + + mailpatch "github.com/floatpane/go-mailpatch" + patchapply "github.com/floatpane/go-patchapply" +) + +// Options controls how a patch is applied. +type Options struct { + // Reverse unapplies the patch instead of applying it. + Reverse bool + // DryRun validates the patch against the tree but writes nothing. + DryRun bool +} + +// Summary describes the result of applying one patch message. +type Summary struct { + // Subject is the patch subject with its "[PATCH n/m]" prefix stripped. + Subject string + // Author is the commit author ("Name "). + Author string + // Series is the position within a series, when the subject carried it. + Series mailpatch.SeriesInfo + // CoverLetter is true when the message is a "0/n" cover letter (nothing to + // apply); Files is then empty. + CoverLetter bool + // Files lists what was created, updated, removed, or renamed. + Files []patchapply.FileResult +} + +func (o Options) applyOpts() *patchapply.Options { + return &patchapply.Options{Reverse: o.Reverse, DryRun: o.DryRun} +} + +func summarize(p *mailpatch.Patch, files []patchapply.FileResult) *Summary { + return &Summary{ + Subject: p.Subject, + Author: p.From, + Series: p.Series, + CoverLetter: p.IsCoverLetter(), + Files: files, + } +} + +// Apply parses a single format-patch email (raw RFC 5322 bytes) and applies it +// to the working tree rooted at repoDir. A cover letter (a "0/n" message with +// no diff) applies cleanly as a no-op. +func Apply(raw []byte, repoDir string, opts Options) (*Summary, error) { + p, err := mailpatch.ParseBytes(raw) + if err != nil { + return nil, fmt.Errorf("parse patch: %w", err) + } + fsys := patchapply.NewDirFS(repoDir) + res, err := patchapply.ApplyPatch(fsys, p, opts.applyOpts()) + if err != nil { + return nil, fmt.Errorf("apply %q: %w", p.Subject, err) + } + return summarize(p, res.Files), nil +} + +// ApplySeries applies every patch in an mbox to repoDir in series order. The +// cover letter, if present, is summarized but applies nothing. +// +// It is not transactional across patches: if patch 3 of 5 conflicts, patches 1 +// and 2 are already written. Pass Options.DryRun first to check the whole +// series, or reverse the applied prefix yourself on failure. +func ApplySeries(raw []byte, repoDir string, opts Options) ([]*Summary, error) { + series, err := mailpatch.ParseSeries(bytes.NewReader(raw)) + if err != nil { + return nil, fmt.Errorf("parse series: %w", err) + } + + fsys := patchapply.NewDirFS(repoDir) + var summaries []*Summary + + if series.Cover != nil { + summaries = append(summaries, summarize(series.Cover, nil)) + } + for _, p := range series.Patches { + res, err := patchapply.ApplyPatch(fsys, p, opts.applyOpts()) + if err != nil { + return summaries, fmt.Errorf("apply [%d/%d] %q: %w", + p.Series.Index, p.Series.Total, p.Subject, err) + } + summaries = append(summaries, summarize(p, res.Files)) + } + return summaries, nil +} + +// IsPatch reports whether raw looks like an applicable patch email: it parses +// and carries a diff. Use it to decide whether to offer "apply" on a message. +func IsPatch(raw []byte) bool { + p, err := mailpatch.ParseBytes(raw) + return err == nil && p.HasDiff() +} diff --git a/gitmail/gitmail_test.go b/gitmail/gitmail_test.go new file mode 100644 index 00000000..e02dbaed --- /dev/null +++ b/gitmail/gitmail_test.go @@ -0,0 +1,97 @@ +package gitmail_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/floatpane/matcha/gitmail" +) + +const patchEmail = `From 9f8e7d6c Mon Sep 17 00:00:00 2001 +From: Ada Lovelace +Date: Mon, 2 Jun 2025 11:30:00 +0000 +Subject: [PATCH 1/1] greet: change the world + +--- + greet.txt | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + +diff --git a/greet.txt b/greet.txt +index 111..222 100644 +--- a/greet.txt ++++ b/greet.txt +@@ -1,3 +1,3 @@ + hello +-world ++there + bye +-- +2.45.1 +` + +func TestApply(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "greet.txt"), []byte("hello\nworld\nbye\n"), 0o644); err != nil { + t.Fatal(err) + } + + s, err := gitmail.Apply([]byte(patchEmail), dir, gitmail.Options{}) + if err != nil { + t.Fatalf("Apply: %v", err) + } + if s.Subject != "greet: change the world" { + t.Errorf("Subject = %q", s.Subject) + } + if s.Series.Index != 1 || s.Series.Total != 1 { + t.Errorf("Series = %+v", s.Series) + } + if len(s.Files) != 1 || s.Files[0].Path != "greet.txt" { + t.Fatalf("Files = %+v", s.Files) + } + + got, _ := os.ReadFile(filepath.Join(dir, "greet.txt")) + if string(got) != "hello\nthere\nbye\n" { + t.Errorf("content = %q", got) + } +} + +func TestDryRunWritesNothing(t *testing.T) { + dir := t.TempDir() + orig := []byte("hello\nworld\nbye\n") + os.WriteFile(filepath.Join(dir, "greet.txt"), orig, 0o644) + + if _, err := gitmail.Apply([]byte(patchEmail), dir, gitmail.Options{DryRun: true}); err != nil { + t.Fatalf("dry run: %v", err) + } + got, _ := os.ReadFile(filepath.Join(dir, "greet.txt")) + if string(got) != string(orig) { + t.Errorf("dry run wrote to disk: %q", got) + } +} + +func TestReverse(t *testing.T) { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "greet.txt"), []byte("hello\nworld\nbye\n"), 0o644) + + if _, err := gitmail.Apply([]byte(patchEmail), dir, gitmail.Options{}); err != nil { + t.Fatal(err) + } + if _, err := gitmail.Apply([]byte(patchEmail), dir, gitmail.Options{Reverse: true}); err != nil { + t.Fatalf("reverse: %v", err) + } + got, _ := os.ReadFile(filepath.Join(dir, "greet.txt")) + if string(got) != "hello\nworld\nbye\n" { + t.Errorf("reverse did not restore: %q", got) + } +} + +func TestIsPatch(t *testing.T) { + if !gitmail.IsPatch([]byte(patchEmail)) { + t.Error("IsPatch = false for a real patch") + } + plain := "Subject: hi\n\njust a normal email, no diff\n" + if gitmail.IsPatch([]byte(plain)) { + t.Error("IsPatch = true for a non-patch") + } +} diff --git a/go.mod b/go.mod index 7b41b7a5..e2a4f551 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,9 @@ require ( github.com/floatpane/bubble-overlay v0.0.1 github.com/floatpane/go-icalendar v0.0.1 github.com/floatpane/go-keybind v0.0.1 + github.com/floatpane/go-mailpatch v0.0.2 github.com/floatpane/go-openpgp-card-hl v0.0.1 + github.com/floatpane/go-patchapply v0.0.1 github.com/floatpane/go-secretbox v0.1.0 github.com/floatpane/go-uds-jsonrpc v0.0.1 github.com/floatpane/jwz-go v0.0.1 diff --git a/go.sum b/go.sum index 815c984f..f7af87ed 100644 --- a/go.sum +++ b/go.sum @@ -74,8 +74,12 @@ github.com/floatpane/go-icalendar v0.0.1 h1:lF9NhEI4TobX8valDakAFfCnBhM2GDITWMVy github.com/floatpane/go-icalendar v0.0.1/go.mod h1:LSy9G+LwUZtfNIAjLlEVRXkuc2A+hq6+pVCIFOiEAyE= github.com/floatpane/go-keybind v0.0.1 h1:UrzPQ4ldR9sKQt/efpUfcs6gH8Mwy2NO5vwFTVf0dy0= github.com/floatpane/go-keybind v0.0.1/go.mod h1:B8l43ypYOcjknyaHgU0EXUAbrUvfnV2HOzYSzyqcPJI= +github.com/floatpane/go-mailpatch v0.0.2 h1:ZkV44HiK+8Qx+7TIGdtoetfVuURKZcFWIBmmcrJTwTk= +github.com/floatpane/go-mailpatch v0.0.2/go.mod h1:mbnMyEWq+x+AH49ysRnSjKN8ZgbbxfYRX2mCQw3n82c= github.com/floatpane/go-openpgp-card-hl v0.0.1 h1:1DYmzwGDb8eneZxbc/xtwjXeFY8DFL3eYnUooMT0L0w= github.com/floatpane/go-openpgp-card-hl v0.0.1/go.mod h1:Mrx+ukCnpEpMAxyB0p8Ch2gu78Q3Ir40BxBybb2jirw= +github.com/floatpane/go-patchapply v0.0.1 h1:VtCBe1HfG645axYzHqIGTLZgmmM9Tkoyd7XRJ74y6Ek= +github.com/floatpane/go-patchapply v0.0.1/go.mod h1:YekZ5hy6gosCoYujvbKuB+cOvy1+lPPC0S/nTpThGQ4= github.com/floatpane/go-secretbox v0.1.0 h1:xNryazmCP0oR/yVxIkHRc5bcV56YrbisY+bMl8BBfwU= github.com/floatpane/go-secretbox v0.1.0/go.mod h1:uNVTLb1jty9ZT5HIp3zdFIB3K4V3r2V38QaI+bWMVao= github.com/floatpane/go-uds-jsonrpc v0.0.1 h1:/sBlCXVAP9SyLWLj0wlFI07dX/SfXeUM67B4tRwK2QA= diff --git a/main.go b/main.go index 07db2972..9e99fada 100644 --- a/main.go +++ b/main.go @@ -4026,6 +4026,15 @@ func main() { //nolint:gocyclo exit(0) } + // Apply patch CLI subcommand: matcha apply [patch-file] --repo + if len(os.Args) > 1 && os.Args[1] == "apply" { + if err := matchaCli.RunApply(os.Args[2:]); err != nil { + fmt.Fprintf(os.Stderr, "apply failed: %v\n", err) + exit(1) + } + exit(0) + } + // Install plugin CLI subcommand: matcha install if len(os.Args) > 1 && os.Args[1] == "install" { if err := matchaCli.RunInstall(os.Args[2:]); err != nil { From 134fb69b5a2439c97db33c7b3ccce2d17f393bc0 Mon Sep 17 00:00:00 2001 From: Drew Smirnoff Date: Wed, 3 Jun 2026 17:55:38 +0400 Subject: [PATCH 12/15] docs: remove gitads (#1428) Signed-off-by: drew (cherry picked from commit e9adba8ad950221ea7f8e3b848e3381d6e0f21b1) --- README.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/README.md b/README.md index 976218d6..93175e8c 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,3 @@ - -
--- @@ -99,10 +97,6 @@ Thank you to our sponsors for supporting Matcha's development! [David H. Colmenares](https://github.com/hipomenes) | Elliot Hornes | Robert M. | James L. | Chris D. -[![Sponsored by GitAds](https://gitads.dev/v1/ad-serve?source=floatpane/matcha@github)](https://gitads.dev/v1/ad-track?source=floatpane/matcha@github) - -_Clicking this add helps us fund our expenses!_ -
**[Report Bug](https://github.com/floatpane/matcha/issues/new?template=bug_report.md)** · **[Request Feature](https://github.com/floatpane/matcha/issues/new?template=feature_request.md)** · **[Contributing Guidelines](https://github.com/floatpane/matcha/blob/master/CONTRIBUTING.md)** From 1353da71796d893120def26294eff2d8929d7737 Mon Sep 17 00:00:00 2001 From: Floatpane Bot Date: Wed, 3 Jun 2026 18:50:21 +0400 Subject: [PATCH 13/15] chore(deps): go ^ 1.26.4 (#1430) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What? This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [go](https://redirect.github.com/actions/go-versions) | uses-with | patch | `1.26.3` → `1.26.4` | --- ### Release Notes
actions/go-versions (go) ### [`v1.26.4`](https://redirect.github.com/actions/go-versions/releases/tag/1.26.4-26891772857): 1.26.4 [Compare Source](https://redirect.github.com/actions/go-versions/compare/1.26.3-25533533231...1.26.4-26891772857) Go 1.26.4
## Why? Automated dependency update via Renovate. --- ### Configuration 📅 **Schedule**: (UTC) - Branch creation - At any time (no schedule defined) - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- --- - [ ] If you want to rebase/retry this PR, check this box --- This PR has been generated by [Mend Renovate](https://redirect.github.com/renovatebot/renovate). (cherry picked from commit 5840b30debf665e9c959758f966ecd3f7d16eae1) --- .github/workflows/benchmarks.yml | 2 +- .github/workflows/bot-build.yml | 2 +- .github/workflows/ci.yml | 8 ++++---- .github/workflows/integration.yml | 4 ++-- .github/workflows/nightly.yml | 2 +- .github/workflows/release-rc.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/security.yml | 6 +++--- 8 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index ebc22b3e..83490970 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -22,7 +22,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: "1.26.3" + go-version: "1.26.4" - name: Install system dependencies run: sudo apt-get update && sudo apt-get install -y libpcsclite-dev diff --git a/.github/workflows/bot-build.yml b/.github/workflows/bot-build.yml index 67b35cf6..1ab5b930 100644 --- a/.github/workflows/bot-build.yml +++ b/.github/workflows/bot-build.yml @@ -77,7 +77,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: "1.26.3" + go-version: "1.26.4" - name: Set up Zig uses: goto-bus-stop/setup-zig@v2 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cdbd10ef..3409de19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: "1.26.3" + go-version: "1.26.4" - name: Install system dependencies if: runner.os == 'Linux' @@ -42,7 +42,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: "1.26.3" + go-version: "1.26.4" - name: Install system dependencies run: sudo apt-get update && sudo apt-get install -y libpcsclite-dev @@ -74,7 +74,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: "1.26.3" + go-version: "1.26.4" - name: Check go.mod and go.sum are tidy run: | @@ -230,7 +230,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: "1.26.3" + go-version: "1.26.4" - name: Install GoReleaser uses: goreleaser/goreleaser-action@v7 diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 5958b5bd..191ea88a 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -35,7 +35,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: "1.26.3" + go-version: "1.26.4" - name: Install system dependencies run: sudo apt-get update && sudo apt-get install -y libpcsclite-dev @@ -95,7 +95,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: "1.26.3" + go-version: "1.26.4" - name: Install system dependencies run: sudo apt-get update && sudo apt-get install -y libpcsclite-dev diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 55c848ce..b80afe8e 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -21,7 +21,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: "1.26.3" + go-version: "1.26.4" - name: Set up Zig uses: goto-bus-stop/setup-zig@v2 diff --git a/.github/workflows/release-rc.yml b/.github/workflows/release-rc.yml index acc739eb..54869c5a 100644 --- a/.github/workflows/release-rc.yml +++ b/.github/workflows/release-rc.yml @@ -87,7 +87,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: "1.26.3" + go-version: "1.26.4" - name: Set up Zig uses: goto-bus-stop/setup-zig@v2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aa5cf8b8..b61ca2f1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: "1.26.3" + go-version: "1.26.4" - name: Set up Zig uses: goto-bus-stop/setup-zig@v2 diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index b9c4efa2..6942434d 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -24,7 +24,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: "1.26.3" + go-version: "1.26.4" - name: Install system dependencies run: sudo apt-get update && sudo apt-get install -y libpcsclite-dev @@ -43,7 +43,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: "1.26.3" + go-version: "1.26.4" - name: Install system dependencies run: sudo apt-get update && sudo apt-get install -y libpcsclite-dev @@ -109,7 +109,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: "1.26.3" + go-version: "1.26.4" - name: Install system dependencies run: sudo apt-get update && sudo apt-get install -y libpcsclite-dev From e56927a5c84d2f2fda1ba3314f4155e0f6c1ed1e Mon Sep 17 00:00:00 2001 From: Floatpane Bot Date: Wed, 3 Jun 2026 19:02:48 +0400 Subject: [PATCH 14/15] chore(deps): actions/checkout ^ (#1429) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What? This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [actions/checkout](https://redirect.github.com/actions/checkout) ([changelog](https://redirect.github.com/actions/checkout/compare/de0fac2e4500dabe0009e67214ff5f5447ce83dd..df4cb1c069e1874edd31b4311f1884172cec0e10)) | action | digest | `de0fac2` → `df4cb1c` | ## Why? Automated dependency update via Renovate. --- ### Configuration 📅 **Schedule**: (UTC) - Branch creation - At any time (no schedule defined) - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- --- - [ ] If you want to rebase/retry this PR, check this box --- This PR has been generated by [Mend Renovate](https://redirect.github.com/renovatebot/renovate). Co-authored-by: Drew Smirnoff (cherry picked from commit ab0d447c18e82a715e7437be10623f4ceaa28685) --- .github/workflows/sync-gomod2nix.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sync-gomod2nix.yml b/.github/workflows/sync-gomod2nix.yml index 36a000de..ce812bcd 100644 --- a/.github/workflows/sync-gomod2nix.yml +++ b/.github/workflows/sync-gomod2nix.yml @@ -16,7 +16,7 @@ jobs: sync: runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Install Nix uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31 From 906a9b440daf545b20e4b3486459af1c5f8ea334 Mon Sep 17 00:00:00 2001 From: Floatpane Bot Date: Wed, 3 Jun 2026 20:03:28 +0400 Subject: [PATCH 15/15] chore(deps): go ^ 1.26.4 (#1419) Co-authored-by: Drew Smirnoff (cherry picked from commit e06a2fee1241b1c04a47627274e6d57915c753ab) --- .github/workflows/integration.yml | 3 +-- go.mod | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 191ea88a..e7984227 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -69,14 +69,13 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - go: ["1.26.3"] steps: - uses: actions/checkout@v6 - name: Set up Go uses: actions/setup-go@v6 with: - go-version: ${{ matrix.go }} + go-version: "1.26.4" - name: Install system deps (linux) if: runner.os == 'Linux' diff --git a/go.mod b/go.mod index e2a4f551..ee440513 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/floatpane/matcha -go 1.26.3 +go 1.26.4 require ( charm.land/bubbles/v2 v2.1.0