feat: v0.1.5 - no default selection, stay on file list after convert, improved help menu
- Files no longer selected by default (user must select manually) - After conversion, stays on file list (no more stateResults/esc to reconvert) - x key resets done file to idle (delete output + reconvert in one) - p key opens output if done, otherwise input file - Help menu now has cream background, title bar, and divider to blend in smoothly - Removed stateResults entirely - simplified to single state
This commit is contained in:
+22
-58
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
+21
-27
@@ -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...)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user