feat: show attachment file sizes (#1252)

FromSi created

## 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

Change summary

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(-)

Detailed changes

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{

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
+}

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)
 	}

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) {

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)
 			}
 		}
 

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)
+}