From 841853592167c8bea202a09dd23d548d1491c288 Mon Sep 17 00:00:00 2001 From: FromSi Date: Fri, 8 May 2026 16:14:08 +0500 Subject: [PATCH] feat: show attachment file sizes (#1252) ## What? Display attachment file sizes in the composer attachment list, e.g. `image.jpg (1.2 MB)`. Also wired file size formatting through the existing i18n number formatter: - added `GetNumberFormatter()` to the i18n manager - added a short TUI helper `tfs(...)` - reused it in both composer and file picker - removed the old file picker-local file size formatter - added tests for attachment display ## Why? Composer previously showed only attachment filenames, so users had no quick way to check file size before sending. The formatter was touched because there was already an unused locale-aware `i18n.NumberFormatter.FormatFileSize`. Instead of keeping a composer-specific helper or reusing a file picker-private function, this change makes file size formatting a shared i18n-backed path. That keeps formatting consistent across TUI components and supports locale-specific number formatting, such as `1.2 MB` vs `1,2 MB`. Closes #550 --- i18n/formatter.go | 6 +++--- i18n/manager.go | 34 ++++++++++++++++++++++++++++++---- tui/composer.go | 31 ++++++++++++++++++++++++++++--- tui/composer_test.go | 24 ++++++++++++++++++++++++ tui/filepicker.go | 29 ++++++++++++++--------------- tui/i18n_helper.go | 6 ++++++ 6 files changed, 105 insertions(+), 25 deletions(-) diff --git a/i18n/formatter.go b/i18n/formatter.go index 98e10e190465b1e40d378559cfadcc94fb16c498..1edc85c8b61cdb5dd03114281b7b0e0cd2377e4a 100644 --- a/i18n/formatter.go +++ b/i18n/formatter.go @@ -13,9 +13,9 @@ type NumberFormatter struct { // NewNumberFormatter creates a formatter for a locale. func NewNumberFormatter(locale *Locale) *NumberFormatter { - tag := locale.Tag - if tag == language.Und { - tag = language.English + tag := language.English + if locale != nil && locale.Tag != language.Und { + tag = locale.Tag } return &NumberFormatter{ diff --git a/i18n/manager.go b/i18n/manager.go index 9780deebdf8c685d4b0c4bbf1720881575f62a81..b667b9e6124f4a895ee26478dcfb9d11fd1fc3f4 100644 --- a/i18n/manager.go +++ b/i18n/manager.go @@ -15,6 +15,7 @@ type Manager struct { bundle *Bundle currentLang string localizers map[string]*Localizer + formatters map[string]*NumberFormatter cache *Cache mu sync.RWMutex } @@ -41,11 +42,13 @@ func Init(defaultLang string) error { bundle: bundle, currentLang: defaultLang, localizers: make(map[string]*Localizer), + formatters: make(map[string]*NumberFormatter), cache: NewCache(), } // Create default localizer globalManager.localizers[defaultLang] = NewLocalizer(defaultLang, bundle) + globalManager.formatters[defaultLang] = NewNumberFormatter(globalManager.localizers[defaultLang].Locale()) }) return initErr @@ -81,6 +84,11 @@ func (m *Manager) SetLanguage(lang string) error { m.localizers[lang] = NewLocalizer(lang, m.bundle) } + // Create formatter if not exists + if _, ok := m.formatters[lang]; !ok { + m.formatters[lang] = NewNumberFormatter(m.getLocaleLocked(lang)) + } + m.currentLang = lang m.cache.Clear() // Clear cache when switching languages @@ -152,12 +160,21 @@ func (m *Manager) GetLocale() *Locale { m.mu.RLock() defer m.mu.RUnlock() - if localizer, ok := m.localizers[m.currentLang]; ok { - return localizer.Locale() + return m.getLocaleLocked(m.currentLang) +} + +// GetNumberFormatter returns the number formatter for the current language. +func (m *Manager) GetNumberFormatter() *NumberFormatter { + m.mu.Lock() + defer m.mu.Unlock() + + if formatter, ok := m.formatters[m.currentLang]; ok { + return formatter } - locale, _ := ParseLocale(m.currentLang) - return locale + formatter := NewNumberFormatter(m.getLocaleLocked(m.currentLang)) + m.formatters[m.currentLang] = formatter + return formatter } // ClearCache clears all translation caches. @@ -171,3 +188,12 @@ func (m *Manager) ClearCache() { localizer.ClearCache() } } + +func (m *Manager) getLocaleLocked(lang string) *Locale { + if localizer, ok := m.localizers[lang]; ok { + return localizer.Locale() + } + + locale, _ := ParseLocale(lang) + return locale +} diff --git a/tui/composer.go b/tui/composer.go index 285c9eaf5bad2be44100bdb2d2a768b38bebed9e..8614cb1d79a90740d8719d1c7fa0947af63b59c2 100644 --- a/tui/composer.go +++ b/tui/composer.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "os" "path/filepath" "strings" @@ -54,6 +55,7 @@ type Composer struct { bodyInput textarea.Model signatureInput textarea.Model attachmentPaths []string + attachmentNames map[string]string encryptSMIME bool width int height int @@ -95,8 +97,9 @@ type Composer struct { // NewComposer initializes a new composer model. func NewComposer(from, to, subject, body string, hideTips bool) *Composer { m := &Composer{ - draftID: uuid.New().String(), - hideTips: hideTips, + draftID: uuid.New().String(), + hideTips: hideTips, + attachmentNames: make(map[string]string), } tiStyles := ThemedTextInputStyles() @@ -230,6 +233,22 @@ func (m *Composer) getSelectedAccount() *config.Account { return nil } +func formatAttachmentName(path string) string { + name := filepath.Base(path) + info, err := os.Stat(path) + if err != nil || info.IsDir() { + return name + } + return fmt.Sprintf("%s (%s)", name, tfs(info.Size())) +} + +func (m *Composer) attachmentDisplayName(path string) string { + if name, ok := m.attachmentNames[path]; ok { + return name + } + return filepath.Base(path) +} + func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd var cmd tea.Cmd @@ -277,6 +296,7 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if !exists { m.attachmentPaths = append(m.attachmentPaths, newPath) + m.attachmentNames[newPath] = formatAttachmentName(newPath) } } return m, nil @@ -452,6 +472,7 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "backspace", "delete", "d": if m.focusIndex == focusAttachment && len(m.attachmentPaths) > 0 { + delete(m.attachmentNames, m.attachmentPaths[len(m.attachmentPaths)-1]) m.attachmentPaths = m.attachmentPaths[:len(m.attachmentPaths)-1] return m, nil } @@ -607,7 +628,7 @@ func (m *Composer) View() tea.View { } else { var names []string for _, p := range m.attachmentPaths { - names = append(names, filepath.Base(p)) + names = append(names, m.attachmentDisplayName(p)) } attachmentText := strings.Join(names, ", ") if m.focusIndex == focusAttachment { @@ -946,6 +967,10 @@ func NewComposerFromDraft(draft config.Draft, accounts []config.Account, hideTip m.bccInput.SetValue(draft.Bcc) m.draftID = draft.ID m.attachmentPaths = draft.AttachmentPaths + m.attachmentNames = make(map[string]string, len(m.attachmentPaths)) + for _, path := range m.attachmentPaths { + m.attachmentNames[path] = formatAttachmentName(path) + } if m.isCatchAllAccount() && draft.FromOverride != "" { m.fromInput.SetValue(draft.FromOverride) } diff --git a/tui/composer_test.go b/tui/composer_test.go index f2922ee1912fca12ab1b5369171615135787b240..d08c51e64230b7c9fb32d73576340429d8ff1079 100644 --- a/tui/composer_test.go +++ b/tui/composer_test.go @@ -1,6 +1,8 @@ package tui import ( + "os" + "path/filepath" "testing" tea "charm.land/bubbletea/v2" @@ -225,6 +227,28 @@ func TestComposerUpdate(t *testing.T) { }) } +func TestFormatAttachmentNameIncludesSize(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "image.jpg") + if err := os.WriteFile(path, make([]byte, 1258291), 0600); err != nil { + t.Fatal(err) + } + + got := formatAttachmentName(path) + want := "image.jpg (1.2 MB)" + if got != want { + t.Fatalf("formatAttachmentName() = %q, want %q", got, want) + } +} + +func TestFormatAttachmentNameMissingFile(t *testing.T) { + got := formatAttachmentName("/missing/image.jpg") + want := "image.jpg" + if got != want { + t.Fatalf("formatAttachmentName() = %q, want %q", got, want) + } +} + // TestComposerGetFromAddress verifies the from address formatting. func TestComposerGetFromAddress(t *testing.T) { t.Run("With name", func(t *testing.T) { diff --git a/tui/filepicker.go b/tui/filepicker.go index 44ee88d084a3c83e4b4b14ae959e7673b93dc324..f401c525121e1d6351bd7d0e5316da3f5ea8f2f6 100644 --- a/tui/filepicker.go +++ b/tui/filepicker.go @@ -23,6 +23,7 @@ type FilePicker struct { cursor int currentPath string items []fs.DirEntry + itemSizes map[string]string width int height int showHidden bool @@ -39,6 +40,7 @@ func NewFilePicker(startPath string) *FilePicker { fp := &FilePicker{ currentPath: startPath, + itemSizes: make(map[string]string), pathInput: pi, } fp.readDir() @@ -49,6 +51,7 @@ func (m *FilePicker) readDir() { files, err := os.ReadDir(m.currentPath) if err != nil { m.items = []fs.DirEntry{} + m.itemSizes = make(map[string]string) return } if !m.showHidden { @@ -61,6 +64,15 @@ func (m *FilePicker) readDir() { files = filtered } m.items = files + m.itemSizes = make(map[string]string, len(files)) + for _, f := range files { + if f.IsDir() { + continue + } + if info, err := f.Info(); err == nil { + m.itemSizes[filepath.Join(m.currentPath, f.Name())] = tfs(info.Size()) + } + } m.cursor = 0 } @@ -167,19 +179,6 @@ func (m *FilePicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } -func formatFileSize(size int64) string { - switch { - case size < 1024: - return fmt.Sprintf("%dB", size) - case size < 1024*1024: - return fmt.Sprintf("%.1fK", float64(size)/1024) - case size < 1024*1024*1024: - return fmt.Sprintf("%.1fM", float64(size)/(1024*1024)) - default: - return fmt.Sprintf("%.1fG", float64(size)/(1024*1024*1024)) - } -} - func (m *FilePicker) View() tea.View { var b strings.Builder @@ -225,8 +224,8 @@ func (m *FilePicker) View() tea.View { if item.IsDir() { itemName = directoryStyle.Render(itemName + "/") } else { - if info, err := item.Info(); err == nil { - sizeStr = fileSizeStyle.Render(" " + formatFileSize(info.Size())) + if size, ok := m.itemSizes[filepath.Join(m.currentPath, item.Name())]; ok { + sizeStr = fileSizeStyle.Render(" " + size) } } diff --git a/tui/i18n_helper.go b/tui/i18n_helper.go index 4d81021f1e64a53b3d8266840d65d3dab7fc8c58..91efcc4617e9a8aac68f3be1f0e09683a6cd37c4 100644 --- a/tui/i18n_helper.go +++ b/tui/i18n_helper.go @@ -19,3 +19,9 @@ func tn(key string, count int, data map[string]interface{}) string { func tpl(key string, data map[string]interface{}) string { return i18n.GetManager().Tpl(key, data) } + +// tfs formats a file size using the active UI locale. +// Example: tfs(1258291) -> "1.2 MB" in English. +func tfs(bytes int64) string { + return i18n.GetManager().GetNumberFormatter().FormatFileSize(bytes) +}