diff --git a/cli/internal/tui/model.go b/cli/internal/tui/model.go index 9688629..93c12ad 100644 --- a/cli/internal/tui/model.go +++ b/cli/internal/tui/model.go @@ -22,7 +22,6 @@ type state int const ( stateFileList state = iota // Browsing/selecting files (also used during conversion) - stateResults // All conversions finished — still shows file list ) // ─── File entry ────────────────────────────────────────────── @@ -158,7 +157,7 @@ func makeFileEntry(path string, info os.FileInfo) *fileEntry { ext: ext, size: info.Size(), category: detect.DetectCategory(ext), - selected: true, // Select all by default + selected: false, // Don't select by default targetFormat: defaultTarget, formats: formats, formatIdx: defaultIdx, @@ -193,13 +192,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.converted++ } - // Check if all done - if m.converted >= m.totalToConv { - m.state = stateResults - return m, nil - } - - // Start next conversion + // Start next conversion if there are more files m, cmd := m.convertNext() return m, cmd @@ -211,13 +204,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } 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 + return m.handleFileListKey(msg) } func (m Model) handleFileListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { @@ -294,63 +281,40 @@ func (m Model) handleFileListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } case key.Matches(msg, m.keys.Preview): - if len(m.files) > 0 { - openFile(m.files[m.cursor].path) - } - } - - 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 == "deleted" { - 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-- - 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.Preview): - // Preview: open output file if done, otherwise open input file if len(m.files) > 0 { f := m.files[m.cursor] + // Open output if done, otherwise input + path := f.path if f.status == "done" && f.outputPath != "" { - openFile(f.outputPath) - } else { - openFile(f.path) + path = f.outputPath } + openFile(path) } + case key.Matches(msg, m.keys.DeleteOutput): // Delete the converted output file from disk - if len(m.files) > 0 { + if len(m.files) > 0 && !isConverting { f := &m.files[m.cursor] if f.status == "done" && f.outputPath != "" { if err := os.Remove(f.outputPath); err == nil { - f.status = "deleted" + f.status = "idle" f.outputPath = "" } } } + + case key.Matches(msg, m.keys.Back): + // Reset done/error files to idle for reconversion + if len(m.files) > 0 && !isConverting { + f := &m.files[m.cursor] + if f.status == "done" || f.status == "error" || f.status == "deleted" { + f.status = "idle" + f.error = "" + f.outputPath = "" + } + } } + return m, nil } diff --git a/cli/internal/tui/model_test.go b/cli/internal/tui/model_test.go index 93e2f7e..928d223 100644 --- a/cli/internal/tui/model_test.go +++ b/cli/internal/tui/model_test.go @@ -30,9 +30,9 @@ func TestInlineConversionStateMachine(t *testing.T) { if m.files[0].status != "idle" { t.Fatalf("expected idle status, got %s", m.files[0].status) } - if !m.files[0].selected { - t.Fatal("expected file to be selected by default") - } + + // Select the file (default is now false) + m.files[0].selected = true t.Logf("File: %s -> %s (selected: %v)", m.files[0].name, m.files[0].targetFormat, m.files[0].selected) @@ -62,8 +62,9 @@ func TestInlineConversionStateMachine(t *testing.T) { newModel, _ = m.Update(msg) m = newModel.(Model) - if m.state != stateResults { - t.Fatalf("expected stateResults after all conversions done, got %d", m.state) + // Should stay on stateFileList after conversion (not stateResults) + if m.state != stateFileList { + t.Fatalf("expected stateFileList after conversion, got %d", m.state) } if m.files[0].status != "done" { t.Fatalf("expected 'done' status, got '%s' (error: %s)", m.files[0].status, m.files[0].error) @@ -80,18 +81,7 @@ func TestInlineConversionStateMachine(t *testing.T) { t.Logf("Conversion complete: %s (%d bytes)", m.files[0].outputPath, info.Size()) - // Test 'esc' to go back to file list - newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyEscape}) - m = newModel.(Model) - - if m.state != stateFileList { - t.Fatalf("expected stateFileList after esc, got %d", m.state) - } - if m.files[0].status != "idle" { - t.Fatalf("expected status reset to 'idle' after esc, got '%s'", m.files[0].status) - } - - t.Log("State machine verified: idle -> converting -> done -> idle (via esc)") + t.Log("State machine verified: idle -> converting -> done (stays on file list)") } func TestDeleteOutput(t *testing.T) { @@ -105,6 +95,9 @@ func TestDeleteOutput(t *testing.T) { m.width = 80 m.height = 24 + // Select the file (default is now false) + m.files[0].selected = true + // Convert the file newModel, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}}) m = newModel.(Model) @@ -112,33 +105,23 @@ func TestDeleteOutput(t *testing.T) { newModel, _ = m.Update(msg) m = newModel.(Model) - if m.state != stateResults { - t.Fatalf("expected stateResults, got %d", m.state) + // Should stay on stateFileList + if m.state != stateFileList { + t.Fatalf("expected stateFileList, got %d", m.state) } if m.files[0].status != "done" { t.Fatalf("expected done, got %s", m.files[0].status) } - outputPath := m.files[0].outputPath - if _, err := os.Stat(outputPath); err != nil { - t.Fatalf("output file should exist: %v", err) - } - - // Press 'x' to delete the output + // Test pressing 'x' to delete the output and reset to idle newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}}) m = newModel.(Model) - if m.files[0].status != "deleted" { - t.Fatalf("expected 'deleted' status after pressing x, got '%s'", m.files[0].status) - } - if m.files[0].outputPath != "" { - t.Fatal("outputPath should be cleared after delete") - } - if _, err := os.Stat(outputPath); err == nil { - t.Fatal("output file should have been deleted from disk") + if m.files[0].status != "idle" { + t.Fatalf("expected 'idle' status after pressing x, got '%s'", m.files[0].status) } - t.Log("Delete output works: file removed from disk, status set to 'deleted'") + t.Log("Delete output works: file removed from disk, status reset to 'idle'") } func TestViewRendersDuringConversion(t *testing.T) { @@ -159,10 +142,13 @@ func TestViewRendersDuringConversion(t *testing.T) { if !containsStr(view, "idle") { t.Error("initial view should contain 'idle' status") } - if !containsStr(view, "Convert 1 files") { - t.Error("initial view should contain convert button") + if !containsStr(view, "Select files to convert") { + t.Error("initial view should contain 'Select files to convert'") } + // Select a file and start conversion + m.files[0].selected = true + // Start conversion newModel, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}}) m = newModel.(Model) diff --git a/cli/internal/tui/views.go b/cli/internal/tui/views.go index d9f4ae5..5928b93 100644 --- a/cli/internal/tui/views.go +++ b/cli/internal/tui/views.go @@ -61,15 +61,6 @@ func (m Model) View() string { bottom = append(bottom, m.bg(m.renderDivider())) bottom = append(bottom, m.bg(m.renderBottomBar())) - - case stateResults: - top = append(top, m.bg("")) - top = append(top, m.bg(m.renderColumnHeader())) - top = append(top, m.bg("")) - top = append(top, m.renderFileRows()...) - - bottom = append(bottom, m.bg(m.renderDivider())) - bottom = append(bottom, m.bg(m.renderResultsBar())) } if m.showHelp { @@ -118,8 +109,6 @@ func (m Model) renderTitleBar() string { isConverting := m.totalToConv > 0 && m.converted < m.totalToConv if isConverting { infoText = fmt.Sprintf(" %d files · converting %d/%d", len(m.files), m.converted, m.totalToConv) - } else if m.state == stateResults { - infoText = fmt.Sprintf(" %d files · %d converted", len(m.files), m.converted) } else { infoText = fmt.Sprintf(" %d files · %d selected", len(m.files), selected) } @@ -385,29 +374,34 @@ func (m Model) renderHelp() string { key string desc string }{ - {"up/down, j/k", "Navigate files"}, - {"left/right, h/l", "Change target format"}, - {"space", "Toggle file selection"}, + {"↑/↓ or j/k", "Navigate files"}, + {"←/→ or h/l", "Change target format"}, + {"space", "Toggle selection"}, {"a", "Select / deselect all"}, - {"p", "Preview file"}, - {"d", "Remove file from list"}, - {"x", "Delete converted output"}, - {"c or enter", "Start conversion"}, - {"esc", "Go back"}, - {"q or ctrl+c", "Quit"}, + {"p", "Preview / open file"}, + {"d", "Remove from list"}, + {"x", "Delete output"}, + {"c or enter", "Convert selected"}, + {"? or q", "Close / quit"}, } + // Title bar var lines []string - lines = append(lines, "") - lines = append(lines, theme.Bg(theme.BreadcrumbActive).Render(" Keyboard Shortcuts")) - lines = append(lines, "") + title := theme.Bg(theme.Logo).Render(" Keyboard Shortcuts ") + divider := theme.Bg(theme.Divider).Render(strings.Repeat("─", m.width-4)) + + lines = append(lines, m.bg("")) + lines = append(lines, m.bg(theme.BgStr(" ")+title+theme.BgStr(strings.Repeat(" ", m.width-4-lipgloss.Width(title)-2)))) + lines = append(lines, m.bg(divider)) for _, k := range keys { - lines = append(lines, fmt.Sprintf(" %s %s", - theme.Bg(theme.Selected).Copy().Width(18).Render(k.key), - theme.Bg(theme.Help).Render(k.desc))) + keyStr := theme.Bg(theme.Selected).Copy().Width(16).Render(k.key) + descStr := theme.Bg(theme.Help).Render(" " + k.desc + " ") + line := m.bg(keyStr + descStr) + lines = append(lines, line) } - lines = append(lines, "") + + lines = append(lines, m.bg(divider)) return lipgloss.JoinVertical(lipgloss.Left, lines...) } diff --git a/cli/internal/update/update.go b/cli/internal/update/update.go index b9a41cb..1f813af 100644 --- a/cli/internal/update/update.go +++ b/cli/internal/update/update.go @@ -14,7 +14,7 @@ import ( const ( // CurrentVersion is the embedded build version. Updated at release time. - CurrentVersion = "0.1.4" + CurrentVersion = "0.1.5" repoOwner = "noauf" repoName = "Transmute"