Detailed changes
@@ -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{
@@ -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
+}
@@ -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)
}
@@ -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) {
@@ -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)
}
}
@@ -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)
+}