feat: add preview/delete-output to TUI, replace how-it-works with tutorial gif
CLI: - p key opens file with system viewer (input in file list, output in results) - x key deletes converted output file from disk in results state - New 'deleted' status shown in red in the status column - Updated help overlay and bottom bar keybindings Web: - Replace three-step timeline in 'How it works' with Tuturial.gif - GIF shown in browser-style window frame matching site design
This commit is contained in:
+23
-13
@@ -4,19 +4,21 @@ import "github.com/charmbracelet/bubbles/key"
|
||||
|
||||
// KeyMap defines key bindings for the TUI.
|
||||
type KeyMap struct {
|
||||
Up key.Binding
|
||||
Down key.Binding
|
||||
Left key.Binding
|
||||
Right key.Binding
|
||||
Enter key.Binding
|
||||
Space key.Binding
|
||||
Tab key.Binding
|
||||
Delete key.Binding
|
||||
SelectAll key.Binding
|
||||
Convert key.Binding
|
||||
Quit key.Binding
|
||||
Help key.Binding
|
||||
Back key.Binding
|
||||
Up key.Binding
|
||||
Down key.Binding
|
||||
Left key.Binding
|
||||
Right key.Binding
|
||||
Enter key.Binding
|
||||
Space key.Binding
|
||||
Tab key.Binding
|
||||
Delete key.Binding
|
||||
SelectAll key.Binding
|
||||
Convert key.Binding
|
||||
Quit key.Binding
|
||||
Help key.Binding
|
||||
Back key.Binding
|
||||
Preview key.Binding
|
||||
DeleteOutput key.Binding
|
||||
}
|
||||
|
||||
// DefaultKeyMap returns the default key bindings.
|
||||
@@ -74,5 +76,13 @@ func DefaultKeyMap() KeyMap {
|
||||
key.WithKeys("esc"),
|
||||
key.WithHelp("esc", "back"),
|
||||
),
|
||||
Preview: key.NewBinding(
|
||||
key.WithKeys("p"),
|
||||
key.WithHelp("p", "preview file"),
|
||||
),
|
||||
DeleteOutput: key.NewBinding(
|
||||
key.WithKeys("x"),
|
||||
key.WithHelp("x", "delete output"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ package tui
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -35,7 +37,7 @@ type fileEntry struct {
|
||||
targetFormat string
|
||||
formats []string
|
||||
formatIdx int
|
||||
status string // "idle", "converting", "done", "error"
|
||||
status string // "idle", "converting", "done", "error", "deleted"
|
||||
error string
|
||||
outputPath string
|
||||
}
|
||||
@@ -290,6 +292,11 @@ func (m Model) handleFileListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
if !isConverting {
|
||||
return m.startConversion()
|
||||
}
|
||||
|
||||
case key.Matches(msg, m.keys.Preview):
|
||||
if len(m.files) > 0 {
|
||||
openFile(m.files[m.cursor].path)
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
@@ -303,7 +310,7 @@ func (m Model) handleResultsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
// 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" {
|
||||
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 = ""
|
||||
@@ -315,10 +322,33 @@ func (m Model) handleResultsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
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]
|
||||
if f.status == "done" && f.outputPath != "" {
|
||||
openFile(f.outputPath)
|
||||
} else {
|
||||
openFile(f.path)
|
||||
}
|
||||
}
|
||||
case key.Matches(msg, m.keys.DeleteOutput):
|
||||
// Delete the converted output file from disk
|
||||
if len(m.files) > 0 {
|
||||
f := &m.files[m.cursor]
|
||||
if f.status == "done" && f.outputPath != "" {
|
||||
if err := os.Remove(f.outputPath); err == nil {
|
||||
f.status = "deleted"
|
||||
f.outputPath = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
@@ -387,6 +417,22 @@ func (m Model) maxVisibleFiles() int {
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────
|
||||
|
||||
// openFile opens a file with the system default viewer.
|
||||
func openFile(path string) {
|
||||
var cmd *exec.Cmd
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
cmd = exec.Command("open", path)
|
||||
case "linux":
|
||||
cmd = exec.Command("xdg-open", path)
|
||||
case "windows":
|
||||
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", path)
|
||||
default:
|
||||
return
|
||||
}
|
||||
cmd.Start() //nolint:errcheck
|
||||
}
|
||||
|
||||
func formatSize(bytes int64) string {
|
||||
const (
|
||||
KB = 1024
|
||||
|
||||
@@ -94,6 +94,53 @@ func TestInlineConversionStateMachine(t *testing.T) {
|
||||
t.Log("State machine verified: idle -> converting -> done -> idle (via esc)")
|
||||
}
|
||||
|
||||
func TestDeleteOutput(t *testing.T) {
|
||||
testFile := filepath.Join("..", "..", "..", "public", "logo.png")
|
||||
if _, err := os.Stat(testFile); err != nil {
|
||||
t.Skipf("test file not found: %s", testFile)
|
||||
}
|
||||
|
||||
outDir := t.TempDir()
|
||||
m := New([]string{testFile}, outDir)
|
||||
m.width = 80
|
||||
m.height = 24
|
||||
|
||||
// Convert the file
|
||||
newModel, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}})
|
||||
m = newModel.(Model)
|
||||
msg := cmd()
|
||||
newModel, _ = m.Update(msg)
|
||||
m = newModel.(Model)
|
||||
|
||||
if m.state != stateResults {
|
||||
t.Fatalf("expected stateResults, 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
|
||||
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")
|
||||
}
|
||||
|
||||
t.Log("Delete output works: file removed from disk, status set to 'deleted'")
|
||||
}
|
||||
|
||||
func TestViewRendersDuringConversion(t *testing.T) {
|
||||
testFile := filepath.Join("..", "..", "..", "public", "logo.png")
|
||||
if _, err := os.Stat(testFile); err != nil {
|
||||
|
||||
@@ -256,6 +256,8 @@ func (m Model) renderFileRow(idx int) string {
|
||||
statusStr = bgS(theme.StatusDone).Render("done")
|
||||
case "error":
|
||||
statusStr = bgS(theme.StatusError).Render("error")
|
||||
case "deleted":
|
||||
statusStr = bgS(theme.StatusError).Render("deleted")
|
||||
}
|
||||
statusCell := bgS(lipgloss.NewStyle()).Copy().Width(colStatus).Align(lipgloss.Center).Render(statusStr)
|
||||
|
||||
@@ -326,8 +328,8 @@ func (m Model) renderBottomBar() string {
|
||||
|
||||
// Adaptive keybindings: full or compact based on available space
|
||||
leftW := lipgloss.Width(left)
|
||||
fullHelp := "up/down navigate left/right format space select a all q quit"
|
||||
shortHelp := "↑↓ nav ←→ fmt space sel a all q quit"
|
||||
fullHelp := "up/down navigate left/right format space select p preview a all q quit"
|
||||
shortHelp := "↑↓ nav ←→ fmt spc sel p preview q quit"
|
||||
|
||||
helpText := fullHelp
|
||||
rightW := len(helpText) + 4 // 2 padding each side
|
||||
@@ -367,7 +369,7 @@ func (m Model) renderResultsBar() string {
|
||||
}
|
||||
left += theme.Bg(theme.Help).Render(fmt.Sprintf(" %s", elapsed))
|
||||
|
||||
right := theme.Bg(theme.Help).Render("enter quit esc convert more") + theme.BgStr(" ")
|
||||
right := theme.Bg(theme.Help).Render("p preview x delete enter quit esc convert more") + theme.BgStr(" ")
|
||||
|
||||
gap := m.width - lipgloss.Width(left) - lipgloss.Width(right)
|
||||
if gap < 1 {
|
||||
@@ -387,7 +389,9 @@ func (m Model) renderHelp() string {
|
||||
{"left/right, h/l", "Change target format"},
|
||||
{"space", "Toggle file 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"},
|
||||
|
||||
Reference in New Issue
Block a user