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
+9 -4
View File
@@ -18,7 +18,8 @@ type KeyMap struct {
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.