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:
+23
-18
@@ -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
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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"},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
Reference in New Issue
Block a user