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