diff --git a/cli/internal/tui/keys.go b/cli/internal/tui/keys.go index 626c6f9..675d264 100644 --- a/cli/internal/tui/keys.go +++ b/cli/internal/tui/keys.go @@ -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"), + ), } } diff --git a/cli/internal/tui/model.go b/cli/internal/tui/model.go index 5be89b8..9688629 100644 --- a/cli/internal/tui/model.go +++ b/cli/internal/tui/model.go @@ -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 diff --git a/cli/internal/tui/model_test.go b/cli/internal/tui/model_test.go index f8d5ea3..93e2f7e 100644 --- a/cli/internal/tui/model_test.go +++ b/cli/internal/tui/model_test.go @@ -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 { diff --git a/cli/internal/tui/views.go b/cli/internal/tui/views.go index 4956496..d9f4ae5 100644 --- a/cli/internal/tui/views.go +++ b/cli/internal/tui/views.go @@ -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"}, diff --git a/public/Tuturial.gif b/public/Tuturial.gif new file mode 100644 index 0000000..15914e1 Binary files /dev/null and b/public/Tuturial.gif differ diff --git a/src/app/page.tsx b/src/app/page.tsx index 4d7867a..759af8e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -884,126 +884,38 @@ export default function LandingPage() {

Three steps. That's it.

+

+ Drop files, pick a format, download. See it in action. +

- {/* Timeline layout */} -
- {/* Connecting line — desktop only */} -
- -
- {/* Step 1 — Drop */} - - {/* Visual scene */} -
- {/* Background shape */} -
-
- {/* File stack */} -
-
-
-
- - - -
-
-
+ +
+ {/* Browser-style title bar */} +
+
+
+
+
- {/* Number + text */} -
- 1 +
+ transmute.ing
-

Drop your files

-

- Drag and drop anything {'\u2014'} images, docs, audio, video, data. We handle 70+ formats. -

- - - {/* Step 2 — Pick */} - - {/* Visual scene */} -
-
-
- {/* Format picker mini-UI */} -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/* Number + text */} -
- 2 -
-

Pick a format

-

- Smart suggestions based on your file type. Or choose any compatible output format. -

- - - {/* Step 3 — Download */} - - {/* Visual scene */} -
-
-
- {/* Checkmark + download visual */} -
-
- - - -
- {/* Tiny sparkles */} -
-
-
-
-
- {/* Number + text */} -
- 3 -
-

Download

-

- Converted instantly in your browser. Hit download {'\u2014'} done. Files never leave your machine. -

- +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Tutorial showing how to convert files with Transmute
-
+
{/* ──── PRIVACY ──── */}