feat: v0.1.6 - auto-refresh file list, separate reset key

- Auto-refresh: file list now checks directories every 2 seconds for new files
- r key: resets done/error/deleted file back to idle for reconversion
- f key: manually refresh the file list
- d key: removes file from list only (unchanged)
- Auto-refresh works for directories that files were loaded from
- Updated help menu and bottom bar with new keybindings
This commit is contained in:
noah
2026-03-11 10:46:31 +01:00
parent 82a4123f84
commit 31cfd03e42
6 changed files with 99 additions and 41 deletions
+23 -18
View File
@@ -4,21 +4,22 @@ import "github.com/charmbracelet/bubbles/key"
// KeyMap defines key bindings for the TUI. // KeyMap defines key bindings for the TUI.
type KeyMap struct { type KeyMap struct {
Up key.Binding Up key.Binding
Down key.Binding Down key.Binding
Left key.Binding Left key.Binding
Right key.Binding Right key.Binding
Enter key.Binding Enter key.Binding
Space key.Binding Space key.Binding
Tab key.Binding Tab key.Binding
Delete key.Binding Delete key.Binding
SelectAll key.Binding SelectAll key.Binding
Convert key.Binding Convert key.Binding
Quit key.Binding Quit key.Binding
Help key.Binding Help key.Binding
Back key.Binding Back key.Binding
Preview key.Binding Preview key.Binding
DeleteOutput key.Binding Reset key.Binding
Refresh key.Binding
} }
// DefaultKeyMap returns the default key bindings. // DefaultKeyMap returns the default key bindings.
@@ -80,9 +81,13 @@ func DefaultKeyMap() KeyMap {
key.WithKeys("p"), key.WithKeys("p"),
key.WithHelp("p", "preview file"), key.WithHelp("p", "preview file"),
), ),
DeleteOutput: key.NewBinding( Reset: key.NewBinding(
key.WithKeys("x"), key.WithKeys("r"),
key.WithHelp("x", "delete output"), key.WithHelp("r", "reset to idle"),
),
Refresh: key.NewBinding(
key.WithKeys("f"),
key.WithHelp("f", "refresh files"),
), ),
} }
} }
+67 -15
View File
@@ -54,6 +54,8 @@ type conversionStartMsg struct {
type tickMsg time.Time type tickMsg time.Time
type refreshFilesMsg struct{}
// ─── Model ─────────────────────────────────────────────────── // ─── Model ───────────────────────────────────────────────────
type Model struct { type Model struct {
@@ -72,6 +74,10 @@ type Model struct {
converted int converted int
totalToConv int totalToConv int
startTime time.Time startTime time.Time
// File watching
watchDirs map[string]int64 // dir path -> last known mod time
lastRefresh time.Time
} }
// New creates a new TUI model from a list of file paths and an output directory. // New creates a new TUI model from a list of file paths and an output directory.
@@ -168,7 +174,10 @@ func makeFileEntry(path string, info os.FileInfo) *fileEntry {
// ─── Init ──────────────────────────────────────────────────── // ─── Init ────────────────────────────────────────────────────
func (m Model) Init() tea.Cmd { func (m Model) Init() tea.Cmd {
return nil // Start a ticker to watch for file changes every 2 seconds
return tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
return refreshFilesMsg{}
})
} }
// ─── Update ────────────────────────────────────────────────── // ─── Update ──────────────────────────────────────────────────
@@ -198,6 +207,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyMsg: case tea.KeyMsg:
return m.handleKey(msg) return m.handleKey(msg)
case refreshFilesMsg:
// Check directories for new files
m = m.checkForNewFiles()
// Continue watching
return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
return refreshFilesMsg{}
})
} }
return m, nil return m, nil
@@ -291,20 +308,8 @@ func (m Model) handleFileListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
openFile(path) openFile(path)
} }
case key.Matches(msg, m.keys.DeleteOutput): case key.Matches(msg, m.keys.Reset):
// Delete the converted output file from disk // Reset done/error/deleted file back to idle for reconversion
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 = "idle"
f.outputPath = ""
}
}
}
case key.Matches(msg, m.keys.Back):
// Reset done/error files to idle for reconversion
if len(m.files) > 0 && !isConverting { if len(m.files) > 0 && !isConverting {
f := &m.files[m.cursor] f := &m.files[m.cursor]
if f.status == "done" || f.status == "error" || f.status == "deleted" { if f.status == "done" || f.status == "error" || f.status == "deleted" {
@@ -313,6 +318,10 @@ func (m Model) handleFileListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
f.outputPath = "" f.outputPath = ""
} }
} }
case key.Matches(msg, m.keys.Refresh):
// Manually refresh the file list
m = m.checkForNewFiles()
} }
return m, nil return m, nil
@@ -414,3 +423,46 @@ func formatSize(bytes int64) string {
return fmt.Sprintf("%d B", bytes) return fmt.Sprintf("%d B", bytes)
} }
} }
// checkForNewFiles scans directories for new files and adds them to the list.
func (m Model) checkForNewFiles() Model {
dirs := make(map[string]bool)
for _, f := range m.files {
dir := filepath.Dir(f.path)
dirs[dir] = true
}
for dir := range dirs {
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, e := range entries {
if e.IsDir() || strings.HasPrefix(e.Name(), ".") {
continue
}
path := filepath.Join(dir, e.Name())
// Check if already in list
exists := false
for _, f := range m.files {
if f.path == path {
exists = true
break
}
}
if !exists {
info, err := e.Info()
if err != nil {
continue
}
entry := makeFileEntry(path, info)
if entry != nil {
m.files = append(m.files, *entry)
}
}
}
}
return m
}
+4 -4
View File
@@ -113,15 +113,15 @@ func TestDeleteOutput(t *testing.T) {
t.Fatalf("expected done, got %s", m.files[0].status) t.Fatalf("expected done, got %s", m.files[0].status)
} }
// Test pressing 'x' to delete the output and reset to idle // Test pressing 'r' to reset the done file back to idle
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}}) newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}})
m = newModel.(Model) m = newModel.(Model)
if m.files[0].status != "idle" { if m.files[0].status != "idle" {
t.Fatalf("expected 'idle' status after pressing x, got '%s'", m.files[0].status) t.Fatalf("expected 'idle' status after pressing r, got '%s'", m.files[0].status)
} }
t.Log("Delete output works: file removed from disk, status reset to 'idle'") t.Log("Reset works: status reset from done back to 'idle'")
} }
func TestViewRendersDuringConversion(t *testing.T) { func TestViewRendersDuringConversion(t *testing.T) {
+4 -3
View File
@@ -317,8 +317,8 @@ func (m Model) renderBottomBar() string {
// Adaptive keybindings: full or compact based on available space // Adaptive keybindings: full or compact based on available space
leftW := lipgloss.Width(left) leftW := lipgloss.Width(left)
fullHelp := "up/down navigate left/right format space select p preview a all q quit" fullHelp := "↑↓ nav ←→ fmt space sel p preview r reset d delete f refresh c convert q quit"
shortHelp := "↑↓ nav ←→ fmt spc sel p preview q quit" shortHelp := "↑↓ nav ←→ fmt spc sel p prev r rst d del q quit"
helpText := fullHelp helpText := fullHelp
rightW := len(helpText) + 4 // 2 padding each side rightW := len(helpText) + 4 // 2 padding each side
@@ -379,8 +379,9 @@ func (m Model) renderHelp() string {
{"space", "Toggle selection"}, {"space", "Toggle selection"},
{"a", "Select / deselect all"}, {"a", "Select / deselect all"},
{"p", "Preview / open file"}, {"p", "Preview / open file"},
{"r", "Reset to idle"},
{"d", "Remove from list"}, {"d", "Remove from list"},
{"x", "Delete output"}, {"f", "Refresh files"},
{"c or enter", "Convert selected"}, {"c or enter", "Convert selected"},
{"? or q", "Close / quit"}, {"? or q", "Close / quit"},
} }
+1 -1
View File
@@ -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.5" CurrentVersion = "0.1.6"
repoOwner = "noauf" repoOwner = "noauf"
repoName = "Transmute" repoName = "Transmute"
Binary file not shown.