diff --git a/cli/internal/tui/keys.go b/cli/internal/tui/keys.go index 675d264..9b47075 100644 --- a/cli/internal/tui/keys.go +++ b/cli/internal/tui/keys.go @@ -4,21 +4,22 @@ 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 - Preview key.Binding - DeleteOutput 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 + Reset key.Binding + Refresh key.Binding } // DefaultKeyMap returns the default key bindings. @@ -80,9 +81,13 @@ func DefaultKeyMap() KeyMap { key.WithKeys("p"), key.WithHelp("p", "preview file"), ), - DeleteOutput: key.NewBinding( - key.WithKeys("x"), - key.WithHelp("x", "delete output"), + Reset: key.NewBinding( + key.WithKeys("r"), + key.WithHelp("r", "reset to idle"), + ), + Refresh: key.NewBinding( + key.WithKeys("f"), + key.WithHelp("f", "refresh files"), ), } } diff --git a/cli/internal/tui/model.go b/cli/internal/tui/model.go index 93c12ad..fe705df 100644 --- a/cli/internal/tui/model.go +++ b/cli/internal/tui/model.go @@ -54,6 +54,8 @@ type conversionStartMsg struct { type tickMsg time.Time +type refreshFilesMsg struct{} + // ─── Model ─────────────────────────────────────────────────── type Model struct { @@ -72,6 +74,10 @@ type Model struct { converted int totalToConv int 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. @@ -168,7 +174,10 @@ func makeFileEntry(path string, info os.FileInfo) *fileEntry { // ─── Init ──────────────────────────────────────────────────── 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 ────────────────────────────────────────────────── @@ -198,6 +207,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: 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 @@ -291,20 +308,8 @@ func (m Model) handleFileListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { openFile(path) } - case key.Matches(msg, m.keys.DeleteOutput): - // Delete the converted output file from disk - 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 + case key.Matches(msg, m.keys.Reset): + // 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.status == "error" || f.status == "deleted" { @@ -313,6 +318,10 @@ func (m Model) handleFileListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { f.outputPath = "" } } + + case key.Matches(msg, m.keys.Refresh): + // Manually refresh the file list + m = m.checkForNewFiles() } return m, nil @@ -414,3 +423,46 @@ func formatSize(bytes int64) string { 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 +} diff --git a/cli/internal/tui/model_test.go b/cli/internal/tui/model_test.go index 928d223..9c75022 100644 --- a/cli/internal/tui/model_test.go +++ b/cli/internal/tui/model_test.go @@ -113,15 +113,15 @@ func TestDeleteOutput(t *testing.T) { t.Fatalf("expected done, got %s", m.files[0].status) } - // Test pressing 'x' to delete the output and reset to idle - newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}}) + // Test pressing 'r' to reset the done file back to idle + newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}) m = newModel.(Model) 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) { diff --git a/cli/internal/tui/views.go b/cli/internal/tui/views.go index 5928b93..1d7fa1b 100644 --- a/cli/internal/tui/views.go +++ b/cli/internal/tui/views.go @@ -317,8 +317,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 p preview a all q quit" - shortHelp := "↑↓ nav ←→ fmt spc sel p preview 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 prev r rst d del q quit" helpText := fullHelp rightW := len(helpText) + 4 // 2 padding each side @@ -379,8 +379,9 @@ func (m Model) renderHelp() string { {"space", "Toggle selection"}, {"a", "Select / deselect all"}, {"p", "Preview / open file"}, + {"r", "Reset to idle"}, {"d", "Remove from list"}, - {"x", "Delete output"}, + {"f", "Refresh files"}, {"c or enter", "Convert selected"}, {"? or q", "Close / quit"}, } diff --git a/cli/internal/update/update.go b/cli/internal/update/update.go index 1f813af..dcf16d2 100644 --- a/cli/internal/update/update.go +++ b/cli/internal/update/update.go @@ -14,7 +14,7 @@ import ( const ( // CurrentVersion is the embedded build version. Updated at release time. - CurrentVersion = "0.1.5" + CurrentVersion = "0.1.6" repoOwner = "noauf" repoName = "Transmute" diff --git a/cli/transmute-darwin-arm64.tar.gz b/cli/transmute-darwin-arm64.tar.gz index de2720f..0a22138 100644 Binary files a/cli/transmute-darwin-arm64.tar.gz and b/cli/transmute-darwin-arm64.tar.gz differ