6254c0431e
- Conversion now happens inline on the file list — status column updates from idle → converting... → done/error in place (no separate screen) - Fixed value-receiver bug in convertNext() that prevented 'converting' status from being displayed - Added cwebp fallback for PNG/JPEG → WebP (ffmpeg webp encoder often missing on macOS) - Format selector arrows hidden during conversion/results states - Simplified to 2 states: stateFileList + stateResults - Added tests for conversion state machine and view rendering
407 lines
9.3 KiB
Go
407 lines
9.3 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/bubbles/key"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
"github.com/noauf/transmute-cli/internal/converter"
|
|
"github.com/noauf/transmute-cli/internal/detect"
|
|
)
|
|
|
|
// ─── State machine ───────────────────────────────────────────
|
|
|
|
type state int
|
|
|
|
const (
|
|
stateFileList state = iota // Browsing/selecting files (also used during conversion)
|
|
stateResults // All conversions finished — still shows file list
|
|
)
|
|
|
|
// ─── File entry ──────────────────────────────────────────────
|
|
|
|
type fileEntry struct {
|
|
path string
|
|
name string
|
|
ext string
|
|
size int64
|
|
category detect.FileCategory
|
|
selected bool
|
|
targetFormat string
|
|
formats []string
|
|
formatIdx int
|
|
status string // "idle", "converting", "done", "error"
|
|
error string
|
|
outputPath string
|
|
}
|
|
|
|
// ─── Messages ────────────────────────────────────────────────
|
|
|
|
type conversionDoneMsg struct {
|
|
index int
|
|
result converter.Result
|
|
}
|
|
|
|
type conversionStartMsg struct {
|
|
index int
|
|
}
|
|
|
|
type tickMsg time.Time
|
|
|
|
// ─── Model ───────────────────────────────────────────────────
|
|
|
|
type Model struct {
|
|
files []fileEntry
|
|
cursor int
|
|
state state
|
|
keys KeyMap
|
|
width int
|
|
height int
|
|
outputDir string
|
|
showHelp bool
|
|
scroll int // scroll offset for file list
|
|
|
|
// Progress tracking
|
|
converting int
|
|
converted int
|
|
totalToConv int
|
|
startTime time.Time
|
|
}
|
|
|
|
// New creates a new TUI model from a list of file paths and an output directory.
|
|
func New(paths []string, outputDir string) Model {
|
|
var files []fileEntry
|
|
|
|
for _, p := range paths {
|
|
info, err := os.Stat(p)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if info.IsDir() {
|
|
// Expand directory
|
|
dirFiles := expandDir(p)
|
|
files = append(files, dirFiles...)
|
|
} else {
|
|
entry := makeFileEntry(p, info)
|
|
if entry != nil {
|
|
files = append(files, *entry)
|
|
}
|
|
}
|
|
}
|
|
|
|
return Model{
|
|
files: files,
|
|
cursor: 0,
|
|
state: stateFileList,
|
|
keys: DefaultKeyMap(),
|
|
showHelp: false,
|
|
outputDir: outputDir,
|
|
}
|
|
}
|
|
|
|
func expandDir(dir string) []fileEntry {
|
|
var files []fileEntry
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return files
|
|
}
|
|
for _, e := range entries {
|
|
if e.IsDir() || strings.HasPrefix(e.Name(), ".") {
|
|
continue
|
|
}
|
|
p := filepath.Join(dir, e.Name())
|
|
info, err := e.Info()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
entry := makeFileEntry(p, info)
|
|
if entry != nil {
|
|
files = append(files, *entry)
|
|
}
|
|
}
|
|
return files
|
|
}
|
|
|
|
func makeFileEntry(path string, info os.FileInfo) *fileEntry {
|
|
ext := strings.TrimPrefix(filepath.Ext(path), ".")
|
|
ext = strings.ToLower(ext)
|
|
|
|
if !detect.IsSupported(ext) {
|
|
return nil
|
|
}
|
|
|
|
formats := detect.GetAvailableFormats(ext)
|
|
if len(formats) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Use the smart default target (matches web app defaults)
|
|
defaultTarget := detect.GetDefaultTarget(ext)
|
|
defaultIdx := 0
|
|
for i, f := range formats {
|
|
if f == defaultTarget {
|
|
defaultIdx = i
|
|
break
|
|
}
|
|
}
|
|
|
|
return &fileEntry{
|
|
path: path,
|
|
name: info.Name(),
|
|
ext: ext,
|
|
size: info.Size(),
|
|
category: detect.DetectCategory(ext),
|
|
selected: true, // Select all by default
|
|
targetFormat: defaultTarget,
|
|
formats: formats,
|
|
formatIdx: defaultIdx,
|
|
status: "idle",
|
|
}
|
|
}
|
|
|
|
// ─── Init ────────────────────────────────────────────────────
|
|
|
|
func (m Model) Init() tea.Cmd {
|
|
return nil
|
|
}
|
|
|
|
// ─── Update ──────────────────────────────────────────────────
|
|
|
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
m.height = msg.Height
|
|
return m, nil
|
|
|
|
case conversionDoneMsg:
|
|
if msg.index >= 0 && msg.index < len(m.files) {
|
|
if msg.result.Err != nil {
|
|
m.files[msg.index].status = "error"
|
|
m.files[msg.index].error = msg.result.Err.Error()
|
|
} else {
|
|
m.files[msg.index].status = "done"
|
|
m.files[msg.index].outputPath = msg.result.OutputPath
|
|
}
|
|
m.converted++
|
|
}
|
|
|
|
// Check if all done
|
|
if m.converted >= m.totalToConv {
|
|
m.state = stateResults
|
|
return m, nil
|
|
}
|
|
|
|
// Start next conversion
|
|
m, cmd := m.convertNext()
|
|
return m, cmd
|
|
|
|
case tea.KeyMsg:
|
|
return m.handleKey(msg)
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch m.state {
|
|
case stateFileList:
|
|
return m.handleFileListKey(msg)
|
|
case stateResults:
|
|
return m.handleResultsKey(msg)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m Model) handleFileListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
isConverting := m.totalToConv > 0 && m.converted < m.totalToConv
|
|
|
|
switch {
|
|
case key.Matches(msg, m.keys.Quit):
|
|
return m, tea.Quit
|
|
|
|
case key.Matches(msg, m.keys.Up):
|
|
if m.cursor > 0 {
|
|
m.cursor--
|
|
m.ensureVisible()
|
|
}
|
|
|
|
case key.Matches(msg, m.keys.Down):
|
|
if m.cursor < len(m.files)-1 {
|
|
m.cursor++
|
|
m.ensureVisible()
|
|
}
|
|
|
|
case key.Matches(msg, m.keys.Help):
|
|
m.showHelp = !m.showHelp
|
|
|
|
// Everything below is blocked while converting
|
|
case key.Matches(msg, m.keys.Space):
|
|
if !isConverting && len(m.files) > 0 {
|
|
m.files[m.cursor].selected = !m.files[m.cursor].selected
|
|
}
|
|
|
|
case key.Matches(msg, m.keys.Left):
|
|
if !isConverting && len(m.files) > 0 {
|
|
f := &m.files[m.cursor]
|
|
if f.formatIdx > 0 {
|
|
f.formatIdx--
|
|
f.targetFormat = f.formats[f.formatIdx]
|
|
}
|
|
}
|
|
|
|
case key.Matches(msg, m.keys.Right), key.Matches(msg, m.keys.Tab):
|
|
if !isConverting && len(m.files) > 0 {
|
|
f := &m.files[m.cursor]
|
|
if f.formatIdx < len(f.formats)-1 {
|
|
f.formatIdx++
|
|
f.targetFormat = f.formats[f.formatIdx]
|
|
}
|
|
}
|
|
|
|
case key.Matches(msg, m.keys.SelectAll):
|
|
if !isConverting {
|
|
allSelected := true
|
|
for _, f := range m.files {
|
|
if !f.selected {
|
|
allSelected = false
|
|
break
|
|
}
|
|
}
|
|
for i := range m.files {
|
|
m.files[i].selected = !allSelected
|
|
}
|
|
}
|
|
|
|
case key.Matches(msg, m.keys.Delete):
|
|
if !isConverting && len(m.files) > 0 {
|
|
m.files = append(m.files[:m.cursor], m.files[m.cursor+1:]...)
|
|
if m.cursor >= len(m.files) && m.cursor > 0 {
|
|
m.cursor--
|
|
}
|
|
}
|
|
|
|
case key.Matches(msg, m.keys.Convert), key.Matches(msg, m.keys.Enter):
|
|
if !isConverting {
|
|
return m.startConversion()
|
|
}
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (m Model) handleResultsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch {
|
|
case key.Matches(msg, m.keys.Quit), key.Matches(msg, m.keys.Enter):
|
|
return m, tea.Quit
|
|
case key.Matches(msg, m.keys.Back):
|
|
// Go back to file list to convert more
|
|
m.state = stateFileList
|
|
for i := range m.files {
|
|
if m.files[i].status == "done" || m.files[i].status == "error" {
|
|
m.files[i].status = "idle"
|
|
m.files[i].error = ""
|
|
m.files[i].outputPath = ""
|
|
}
|
|
}
|
|
m.converting = 0
|
|
m.converted = 0
|
|
m.totalToConv = 0
|
|
case key.Matches(msg, m.keys.Up):
|
|
if m.cursor > 0 {
|
|
m.cursor--
|
|
}
|
|
case key.Matches(msg, m.keys.Down):
|
|
if m.cursor < len(m.files)-1 {
|
|
m.cursor++
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// ─── Conversion logic ────────────────────────────────────────
|
|
|
|
func (m Model) startConversion() (Model, tea.Cmd) {
|
|
// Count selected files
|
|
count := 0
|
|
for _, f := range m.files {
|
|
if f.selected {
|
|
count++
|
|
}
|
|
}
|
|
if count == 0 {
|
|
return m, nil
|
|
}
|
|
|
|
// Stay on stateFileList — status column updates inline
|
|
m.totalToConv = count
|
|
m.converted = 0
|
|
m.converting = 0
|
|
m.startTime = time.Now()
|
|
|
|
m, cmd := m.convertNext()
|
|
return m, cmd
|
|
}
|
|
|
|
func (m Model) convertNext() (Model, tea.Cmd) {
|
|
// Find next file to convert
|
|
for i := range m.files {
|
|
if m.files[i].selected && m.files[i].status == "idle" {
|
|
m.files[i].status = "converting"
|
|
idx := i
|
|
path := m.files[i].path
|
|
target := m.files[i].targetFormat
|
|
outDir := m.outputDir
|
|
|
|
return m, func() tea.Msg {
|
|
result := converter.Convert(path, target, outDir)
|
|
return conversionDoneMsg{index: idx, result: result}
|
|
}
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *Model) ensureVisible() {
|
|
maxVisible := m.maxVisibleFiles()
|
|
if m.cursor < m.scroll {
|
|
m.scroll = m.cursor
|
|
}
|
|
if m.cursor >= m.scroll+maxVisible {
|
|
m.scroll = m.cursor - maxVisible + 1
|
|
}
|
|
}
|
|
|
|
func (m Model) maxVisibleFiles() int {
|
|
available := m.height - 12 // Reserve space for header, footer, borders
|
|
if available < 3 {
|
|
return 3
|
|
}
|
|
return available
|
|
}
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────
|
|
|
|
func formatSize(bytes int64) string {
|
|
const (
|
|
KB = 1024
|
|
MB = KB * 1024
|
|
GB = MB * 1024
|
|
)
|
|
switch {
|
|
case bytes >= GB:
|
|
return fmt.Sprintf("%.1f GB", float64(bytes)/float64(GB))
|
|
case bytes >= MB:
|
|
return fmt.Sprintf("%.1f MB", float64(bytes)/float64(MB))
|
|
case bytes >= KB:
|
|
return fmt.Sprintf("%.1f KB", float64(bytes)/float64(KB))
|
|
default:
|
|
return fmt.Sprintf("%d B", bytes)
|
|
}
|
|
}
|