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:
noah
2026-03-10 10:35:41 +01:00
parent 062af2630f
commit cf489c4f02
6 changed files with 152 additions and 133 deletions
+23 -13
View File
@@ -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"),
),
}
}
+48 -2
View File
@@ -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
+47
View File
@@ -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 {
+7 -3
View File
@@ -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"},