primeiro commit
This commit is contained in:
@@ -0,0 +1,563 @@
|
||||
// Package ui implements the terminal interface of the bulk file renamer.
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
|
||||
"aehenamer/internal/files"
|
||||
"aehenamer/internal/ops"
|
||||
)
|
||||
|
||||
// pageMain is the name of the page holding the main layout. Dialogs are added
|
||||
// as further pages on top of it.
|
||||
const pageMain = "main"
|
||||
|
||||
// App is the interactive bulk file renamer.
|
||||
type App struct {
|
||||
app *tview.Application
|
||||
pages *tview.Pages
|
||||
|
||||
dirInput *tview.InputField
|
||||
filePane *tview.Table
|
||||
opPane *tview.Table
|
||||
addOp *tview.DropDown
|
||||
applyBtn *tview.Button
|
||||
status *tview.TextView
|
||||
|
||||
// focusRing is the order in which Tab moves between the main widgets.
|
||||
focusRing []tview.Primitive
|
||||
|
||||
dir string
|
||||
loadErr string
|
||||
names []string
|
||||
entries []files.Entry
|
||||
conflicts map[int]string
|
||||
|
||||
operations []ops.Operation
|
||||
|
||||
// opRows is how many operations the right pane showed when it was last
|
||||
// drawn. An empty pane has no meaningful selection to preserve.
|
||||
opRows int
|
||||
|
||||
// screen, when set, replaces the terminal the interface draws on, and
|
||||
// ready is closed once the widgets exist. Only tests use them.
|
||||
screen tcell.Screen
|
||||
ready chan struct{}
|
||||
|
||||
// dialogFocus remembers which widget had the focus before a floating
|
||||
// window was opened, keyed by page name.
|
||||
dialogFocus map[string]tview.Primitive
|
||||
|
||||
// pending is the operation currently being edited in a dialog. It is shown
|
||||
// in the preview before the user confirms it; pendingIndex is the position
|
||||
// it will take in the list, or -1 when it is a new operation.
|
||||
pending ops.Operation
|
||||
pendingIndex int
|
||||
}
|
||||
|
||||
// New returns an application that starts out browsing dir.
|
||||
func New(dir string) *App {
|
||||
return &App{
|
||||
dir: dir,
|
||||
pendingIndex: -1,
|
||||
dialogFocus: make(map[string]tview.Primitive),
|
||||
ready: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Run builds the interface and runs the event loop until the user quits or ctx
|
||||
// is cancelled.
|
||||
func (a *App) Run(ctx context.Context) error {
|
||||
a.build(ctx)
|
||||
if a.screen != nil {
|
||||
a.app.SetScreen(a.screen)
|
||||
}
|
||||
|
||||
stopped := make(chan struct{})
|
||||
defer close(stopped)
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
a.app.Stop()
|
||||
case <-stopped:
|
||||
}
|
||||
}()
|
||||
|
||||
a.reload(ctx)
|
||||
a.refresh(ctx)
|
||||
close(a.ready)
|
||||
|
||||
if err := a.app.Run(); err != nil {
|
||||
return fmt.Errorf("running the interface: %w", err)
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// build creates every widget and wires the handlers together.
|
||||
func (a *App) build(ctx context.Context) {
|
||||
a.app = tview.NewApplication().EnableMouse(true).EnablePaste(true)
|
||||
a.pages = tview.NewPages()
|
||||
|
||||
a.buildDirInput(ctx)
|
||||
a.buildFilePane(ctx)
|
||||
a.buildOpPane(ctx)
|
||||
a.buildControls(ctx)
|
||||
a.status = tview.NewTextView().SetDynamicColors(true)
|
||||
|
||||
right := tview.NewFlex().SetDirection(tview.FlexRow).
|
||||
AddItem(a.opPane, 0, 1, false).
|
||||
AddItem(a.controls(), 3, 0, false)
|
||||
|
||||
main := tview.NewFlex().
|
||||
AddItem(a.filePane, 0, 2, false).
|
||||
AddItem(right, 0, 1, false)
|
||||
|
||||
layout := tview.NewFlex().SetDirection(tview.FlexRow).
|
||||
AddItem(a.dirInput, 3, 0, true).
|
||||
AddItem(main, 0, 1, false).
|
||||
AddItem(a.status, 1, 0, false)
|
||||
|
||||
a.pages.AddPage(pageMain, layout, true, true)
|
||||
a.focusRing = []tview.Primitive{a.dirInput, a.filePane, a.opPane, a.addOp, a.applyBtn}
|
||||
|
||||
a.app.SetInputCapture(a.globalKeys(ctx))
|
||||
a.app.SetRoot(a.pages, true).SetFocus(a.dirInput)
|
||||
}
|
||||
|
||||
// controls returns the row holding the "add operation" drop-down and the
|
||||
// apply button, below the operation list.
|
||||
func (a *App) controls() tview.Primitive {
|
||||
row := tview.NewFlex().
|
||||
AddItem(a.addOp, 0, 1, false).
|
||||
AddItem(a.applyBtn, 14, 0, false)
|
||||
row.SetBorder(true)
|
||||
return row
|
||||
}
|
||||
|
||||
// buildDirInput creates the folder picker at the top of the screen. The file
|
||||
// list follows along as the path is typed, as soon as it names a directory.
|
||||
func (a *App) buildDirInput(ctx context.Context) {
|
||||
a.dirInput = tview.NewInputField().
|
||||
SetLabel(" Folder: ").
|
||||
SetText(a.dir).
|
||||
SetFieldWidth(0)
|
||||
a.dirInput.SetBorder(true).SetTitle(" Working folder ")
|
||||
|
||||
a.dirInput.SetAutocompleteFunc(func(current string) []string {
|
||||
return subdirectories(current)
|
||||
})
|
||||
a.dirInput.SetChangedFunc(func(text string) {
|
||||
if info, err := os.Stat(text); err == nil && info.IsDir() {
|
||||
a.dir = text
|
||||
a.reload(ctx)
|
||||
a.refresh(ctx)
|
||||
}
|
||||
})
|
||||
a.dirInput.SetDoneFunc(func(key tcell.Key) {
|
||||
if key != tcell.KeyEnter {
|
||||
return
|
||||
}
|
||||
a.dir = a.dirInput.GetText()
|
||||
a.reload(ctx)
|
||||
a.refresh(ctx)
|
||||
if a.loadErr == "" {
|
||||
a.app.SetFocus(a.filePane)
|
||||
}
|
||||
})
|
||||
a.dirInput.SetFocusFunc(func() {
|
||||
a.setStatus("[white]Type a path (Tab completes) · [yellow]Enter[white] loads it · [yellow]Tab[white] next pane · [yellow]F1[white] help")
|
||||
})
|
||||
}
|
||||
|
||||
// buildFilePane creates the left pane listing current and proposed names.
|
||||
func (a *App) buildFilePane(ctx context.Context) {
|
||||
a.filePane = tview.NewTable().
|
||||
SetFixed(1, 0).
|
||||
SetSelectable(true, false)
|
||||
a.filePane.SetBorder(true).SetTitle(" Files ")
|
||||
|
||||
a.filePane.SetFocusFunc(func() {
|
||||
a.setStatus("[white]↑↓ select · [yellow]x[white]/[yellow]Del[white] drop file from the list · [yellow]Ctrl-R[white] reload folder · [yellow]Tab[white] next pane")
|
||||
a.showRowProblem()
|
||||
})
|
||||
a.filePane.SetSelectionChangedFunc(func(row, column int) {
|
||||
if a.filePane.HasFocus() {
|
||||
a.showRowProblem()
|
||||
}
|
||||
})
|
||||
a.filePane.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
switch {
|
||||
case event.Key() == tcell.KeyDelete, event.Rune() == 'x':
|
||||
a.removeSelectedFile(ctx)
|
||||
return nil
|
||||
case event.Key() == tcell.KeyCtrlR:
|
||||
a.reload(ctx)
|
||||
a.refresh(ctx)
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
}
|
||||
|
||||
// buildOpPane creates the right pane listing the operations to apply.
|
||||
func (a *App) buildOpPane(ctx context.Context) {
|
||||
a.opPane = tview.NewTable().SetSelectable(true, false)
|
||||
a.opPane.SetBorder(true).SetTitle(" Operations ")
|
||||
|
||||
a.opPane.SetFocusFunc(func() {
|
||||
a.setStatus("[white]↑↓ select · [yellow]Enter[white]/[yellow]e[white] edit · [yellow]x[white]/[yellow]Del[white] remove · [yellow]u[white]/[yellow]d[white] move up/down · [yellow]a[white] add")
|
||||
})
|
||||
a.opPane.SetSelectedFunc(func(row, column int) {
|
||||
a.editOperation(ctx, row)
|
||||
})
|
||||
a.opPane.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
row, _ := a.opPane.GetSelection()
|
||||
switch {
|
||||
case event.Key() == tcell.KeyDelete, event.Rune() == 'x':
|
||||
a.removeOperation(ctx, row)
|
||||
case event.Rune() == 'e':
|
||||
a.editOperation(ctx, row)
|
||||
case event.Rune() == 'u':
|
||||
a.moveOperation(ctx, row, -1)
|
||||
case event.Rune() == 'd':
|
||||
a.moveOperation(ctx, row, 1)
|
||||
case event.Rune() == 'a':
|
||||
a.app.SetFocus(a.addOp)
|
||||
default:
|
||||
return event
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// buildControls creates the "add operation" drop-down and the apply button.
|
||||
func (a *App) buildControls(ctx context.Context) {
|
||||
labels := make([]string, len(ops.Kinds))
|
||||
for i, kind := range ops.Kinds {
|
||||
labels[i] = string(kind)
|
||||
}
|
||||
|
||||
a.addOp = tview.NewDropDown().
|
||||
SetLabel(" Add operation ").
|
||||
SetOptions(labels, nil)
|
||||
a.addOp.SetSelectedFunc(func(text string, index int) {
|
||||
if index < 0 || index >= len(ops.Kinds) {
|
||||
return
|
||||
}
|
||||
kind := ops.Kinds[index]
|
||||
// Clear the choice so that the same operation can be picked again.
|
||||
a.addOp.SetCurrentOption(-1)
|
||||
a.openOperationDialog(ctx, kind, ops.DefaultConfig(kind), -1)
|
||||
})
|
||||
a.addOp.SetFocusFunc(func() {
|
||||
a.setStatus("[white]Press [yellow]Enter[white] or click to open the list of operations · [yellow]↑↓[white] choose · [yellow]Esc[white] cancel")
|
||||
})
|
||||
|
||||
a.applyBtn = tview.NewButton("Apply rename").SetSelectedFunc(func() {
|
||||
a.confirmApply(ctx)
|
||||
})
|
||||
a.applyBtn.SetFocusFunc(func() {
|
||||
a.setStatus("[white]Press [yellow]Enter[white] to rename the listed files on disk · [yellow]Tab[white] next pane")
|
||||
})
|
||||
}
|
||||
|
||||
// globalKeys returns the application wide key handler.
|
||||
func (a *App) globalKeys(ctx context.Context) func(*tcell.EventKey) *tcell.EventKey {
|
||||
return func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if name, _ := a.pages.GetFrontPage(); name != pageMain {
|
||||
// A dialog is open; it handles its own keys.
|
||||
return event
|
||||
}
|
||||
switch event.Key() {
|
||||
case tcell.KeyCtrlQ:
|
||||
a.app.Stop()
|
||||
return nil
|
||||
case tcell.KeyF1:
|
||||
a.showHelp()
|
||||
return nil
|
||||
case tcell.KeyCtrlR:
|
||||
a.reload(ctx)
|
||||
a.refresh(ctx)
|
||||
return nil
|
||||
case tcell.KeyTab:
|
||||
a.closeOperationMenu()
|
||||
a.cycleFocus(1)
|
||||
return nil
|
||||
case tcell.KeyBacktab:
|
||||
a.closeOperationMenu()
|
||||
a.cycleFocus(-1)
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
}
|
||||
}
|
||||
|
||||
// closeOperationMenu closes the drop-down's embedded list before focus moves
|
||||
// elsewhere. Otherwise the list remains internally open after a global Tab
|
||||
// shortcut pulls focus away from it.
|
||||
func (a *App) closeOperationMenu() {
|
||||
if !a.addOp.IsOpen() {
|
||||
return
|
||||
}
|
||||
handler := a.addOp.InputHandler()
|
||||
if handler == nil {
|
||||
return
|
||||
}
|
||||
handler(tcell.NewEventKey(tcell.KeyEscape, 0, tcell.ModNone), func(p tview.Primitive) {
|
||||
a.app.SetFocus(p)
|
||||
})
|
||||
}
|
||||
|
||||
// cycleFocus moves the focus by delta positions around the focus ring.
|
||||
func (a *App) cycleFocus(delta int) {
|
||||
current := a.app.GetFocus()
|
||||
for i, p := range a.focusRing {
|
||||
if p != current {
|
||||
continue
|
||||
}
|
||||
next := (i + delta + len(a.focusRing)) % len(a.focusRing)
|
||||
a.app.SetFocus(a.focusRing[next])
|
||||
return
|
||||
}
|
||||
a.app.SetFocus(a.focusRing[0])
|
||||
}
|
||||
|
||||
// reload reads the working folder again, dropping any files the user had
|
||||
// removed from the list.
|
||||
func (a *App) reload(ctx context.Context) {
|
||||
names, err := files.List(ctx, a.dir)
|
||||
if err != nil {
|
||||
a.loadErr = err.Error()
|
||||
a.names = nil
|
||||
return
|
||||
}
|
||||
a.loadErr = ""
|
||||
a.names = names
|
||||
}
|
||||
|
||||
// effectiveOps returns the operation list including the operation currently
|
||||
// being edited, so that a dialog can be previewed before it is confirmed.
|
||||
func (a *App) effectiveOps() []ops.Operation {
|
||||
if a.pending == nil {
|
||||
return a.operations
|
||||
}
|
||||
list := make([]ops.Operation, len(a.operations))
|
||||
copy(list, a.operations)
|
||||
if a.pendingIndex >= 0 && a.pendingIndex < len(list) {
|
||||
list[a.pendingIndex] = a.pending
|
||||
return list
|
||||
}
|
||||
return append(list, a.pending)
|
||||
}
|
||||
|
||||
// refresh recomputes the proposed names and redraws both panes.
|
||||
func (a *App) refresh(ctx context.Context) {
|
||||
proposed := ops.Preview(a.names, a.effectiveOps())
|
||||
a.entries = make([]files.Entry, len(a.names))
|
||||
for i, name := range a.names {
|
||||
a.entries[i] = files.Entry{Original: name, Proposed: proposed[i]}
|
||||
}
|
||||
a.conflicts = files.Conflicts(ctx, a.dir, a.entries)
|
||||
|
||||
a.drawFiles(ctx)
|
||||
a.drawOperations(ctx)
|
||||
}
|
||||
|
||||
// drawFiles fills the left pane with the current and proposed names.
|
||||
func (a *App) drawFiles(ctx context.Context) {
|
||||
selected, _ := a.filePane.GetSelection()
|
||||
a.filePane.Clear()
|
||||
a.filePane.SetSelectable(len(a.entries) > 0, false)
|
||||
|
||||
header := func(column int, text string, expansion int) {
|
||||
a.filePane.SetCell(0, column, tview.NewTableCell(text).
|
||||
SetTextColor(tcell.ColorYellow).
|
||||
SetSelectable(false).
|
||||
SetExpansion(expansion))
|
||||
}
|
||||
header(0, " Current name", 1)
|
||||
header(1, " New name", 1)
|
||||
header(2, "", 0)
|
||||
|
||||
for i, entry := range a.entries {
|
||||
row := i + 1
|
||||
index := i
|
||||
|
||||
a.filePane.SetCell(row, 0, tview.NewTableCell(" "+tview.Escape(entry.Original)).SetExpansion(1))
|
||||
|
||||
proposed := tview.NewTableCell(" " + tview.Escape(entry.Proposed)).SetExpansion(1)
|
||||
switch {
|
||||
case a.conflicts[index] != "":
|
||||
proposed.SetTextColor(tcell.ColorRed)
|
||||
case entry.Changed():
|
||||
proposed.SetTextColor(tcell.ColorGreen)
|
||||
default:
|
||||
proposed.SetTextColor(tcell.ColorGray)
|
||||
}
|
||||
a.filePane.SetCell(row, 1, proposed)
|
||||
|
||||
remove := tview.NewTableCell(" ✕ ").
|
||||
SetTextColor(tcell.ColorRed).
|
||||
SetAlign(tview.AlignCenter)
|
||||
remove.Clicked = func() bool {
|
||||
a.removeFile(ctx, index)
|
||||
return true
|
||||
}
|
||||
a.filePane.SetCell(row, 2, remove)
|
||||
}
|
||||
|
||||
title := fmt.Sprintf(" Files (%d) — %s ", len(a.entries), tview.Escape(a.dir))
|
||||
if a.loadErr != "" {
|
||||
title = " Files — folder cannot be read "
|
||||
}
|
||||
a.filePane.SetTitle(title)
|
||||
|
||||
last := len(a.entries)
|
||||
if last == 0 {
|
||||
a.filePane.Select(0, 0)
|
||||
return
|
||||
}
|
||||
a.filePane.Select(clamp(selected, 1, last), 0)
|
||||
}
|
||||
|
||||
// drawOperations fills the right pane with the operation list.
|
||||
func (a *App) drawOperations(ctx context.Context) {
|
||||
selected := 0
|
||||
if a.opRows > 0 {
|
||||
selected, _ = a.opPane.GetSelection()
|
||||
}
|
||||
a.opRows = len(a.operations)
|
||||
a.opPane.Clear()
|
||||
a.opPane.SetSelectable(len(a.operations) > 0, false)
|
||||
|
||||
if len(a.operations) == 0 {
|
||||
a.opPane.SetCell(0, 0, tview.NewTableCell(" No operations yet — use “Add operation” below ").
|
||||
SetTextColor(tcell.ColorGray).
|
||||
SetSelectable(false).
|
||||
SetExpansion(1))
|
||||
a.opPane.Select(0, 0)
|
||||
return
|
||||
}
|
||||
|
||||
for i, op := range a.operations {
|
||||
index := i
|
||||
a.opPane.SetCell(i, 0, tview.NewTableCell(fmt.Sprintf(" %d.", i+1)).
|
||||
SetTextColor(tcell.ColorYellow))
|
||||
a.opPane.SetCell(i, 1, tview.NewTableCell(tview.Escape(op.Summary())).SetExpansion(1))
|
||||
|
||||
remove := tview.NewTableCell(" [Remove] ").SetTextColor(tcell.ColorRed)
|
||||
remove.Clicked = func() bool {
|
||||
a.removeOperation(ctx, index)
|
||||
return true
|
||||
}
|
||||
a.opPane.SetCell(i, 2, remove)
|
||||
}
|
||||
|
||||
a.opPane.Select(clamp(selected, 0, len(a.operations)-1), 0)
|
||||
}
|
||||
|
||||
// clamp confines n to the inclusive range [low, high].
|
||||
func clamp(n, low, high int) int {
|
||||
if n < low {
|
||||
return low
|
||||
}
|
||||
if n > high {
|
||||
return high
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// removeSelectedFile drops the file under the cursor from the list.
|
||||
func (a *App) removeSelectedFile(ctx context.Context) {
|
||||
row, _ := a.filePane.GetSelection()
|
||||
a.removeFile(ctx, row-1)
|
||||
}
|
||||
|
||||
// removeFile drops the file at index from the list. The file itself is left
|
||||
// untouched on disk.
|
||||
func (a *App) removeFile(ctx context.Context, index int) {
|
||||
if index < 0 || index >= len(a.names) {
|
||||
return
|
||||
}
|
||||
name := a.names[index]
|
||||
a.names = append(a.names[:index:index], a.names[index+1:]...)
|
||||
a.refresh(ctx)
|
||||
a.setStatus("[white]Removed [yellow]%s[white] from the list · [yellow]Ctrl-R[white] brings it back", tview.Escape(name))
|
||||
}
|
||||
|
||||
// removeOperation deletes the operation at index.
|
||||
func (a *App) removeOperation(ctx context.Context, index int) {
|
||||
if index < 0 || index >= len(a.operations) {
|
||||
return
|
||||
}
|
||||
summary := a.operations[index].Summary()
|
||||
a.operations = append(a.operations[:index:index], a.operations[index+1:]...)
|
||||
a.refresh(ctx)
|
||||
a.setStatus("[white]Removed operation: %s", tview.Escape(summary))
|
||||
}
|
||||
|
||||
// moveOperation moves the operation at index by delta places, changing the
|
||||
// order in which operations are applied.
|
||||
func (a *App) moveOperation(ctx context.Context, index, delta int) {
|
||||
target := index + delta
|
||||
if index < 0 || index >= len(a.operations) || target < 0 || target >= len(a.operations) {
|
||||
return
|
||||
}
|
||||
a.operations[index], a.operations[target] = a.operations[target], a.operations[index]
|
||||
a.refresh(ctx)
|
||||
a.opPane.Select(target, 0)
|
||||
}
|
||||
|
||||
// showRowProblem explains in the status bar why the selected file cannot be
|
||||
// renamed, if it cannot.
|
||||
func (a *App) showRowProblem() {
|
||||
row, _ := a.filePane.GetSelection()
|
||||
if reason, bad := a.conflicts[row-1]; bad {
|
||||
a.setStatus("[red]%s[white] — fix the operations or drop the file with [yellow]x", reason)
|
||||
}
|
||||
}
|
||||
|
||||
// setStatus writes a message to the status bar. The text may contain tview
|
||||
// colour tags.
|
||||
func (a *App) setStatus(format string, args ...any) {
|
||||
a.status.SetText(" " + fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// subdirectories returns directory paths that continue the partially typed
|
||||
// path, for the folder input's autocompletion.
|
||||
func subdirectories(current string) []string {
|
||||
if current == "" {
|
||||
return nil
|
||||
}
|
||||
dir, prefix := filepath.Split(current)
|
||||
search := dir
|
||||
if search == "" {
|
||||
search = "."
|
||||
}
|
||||
entries, err := os.ReadDir(search)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
const maxEntries = 25
|
||||
var out []string
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() || !strings.HasPrefix(entry.Name(), prefix) {
|
||||
continue
|
||||
}
|
||||
out = append(out, dir+entry.Name())
|
||||
if len(out) == maxEntries {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(out) == 1 && out[0] == current {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
|
||||
"aehenamer/internal/files"
|
||||
"aehenamer/internal/ops"
|
||||
)
|
||||
|
||||
// waitTimeout bounds how long a test waits for the interface to catch up.
|
||||
const waitTimeout = 5 * time.Second
|
||||
|
||||
// startApp runs the application on a simulation screen and returns helpers to
|
||||
// drive it. The application is stopped when the test ends.
|
||||
func startApp(t *testing.T, dir string) (*App, tcell.SimulationScreen, func(func())) {
|
||||
t.Helper()
|
||||
|
||||
screen := tcell.NewSimulationScreen("UTF-8")
|
||||
screen.SetSize(140, 30)
|
||||
|
||||
app := New(dir)
|
||||
app.screen = screen
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- app.Run(ctx) }()
|
||||
<-app.ready
|
||||
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("Run() = %v, want nil or context.Canceled", err)
|
||||
}
|
||||
case <-time.After(waitTimeout):
|
||||
t.Error("Run() did not return after the context was cancelled")
|
||||
}
|
||||
})
|
||||
|
||||
// onLoop runs fn on the event loop goroutine, so that tests can touch the
|
||||
// application state without racing the interface.
|
||||
onLoop := func(fn func()) {
|
||||
t.Helper()
|
||||
finished := make(chan struct{})
|
||||
app.app.QueueUpdateDraw(func() {
|
||||
defer close(finished)
|
||||
fn()
|
||||
})
|
||||
select {
|
||||
case <-finished:
|
||||
case <-time.After(waitTimeout):
|
||||
t.Fatal("the event loop did not run the queued function")
|
||||
}
|
||||
}
|
||||
return app, screen, onLoop
|
||||
}
|
||||
|
||||
// waitForState polls cond on the event loop until it holds, so that tests can
|
||||
// wait for injected keys to be processed without racing the interface.
|
||||
func waitForState(t *testing.T, onLoop func(func()), what string, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(waitTimeout)
|
||||
for time.Now().Before(deadline) {
|
||||
held := false
|
||||
onLoop(func() { held = cond() })
|
||||
if held {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("timed out waiting for %s", what)
|
||||
}
|
||||
|
||||
// screenText returns everything currently drawn on the screen, one line per
|
||||
// screen row.
|
||||
func screenText(screen tcell.SimulationScreen) string {
|
||||
cells, width, height := screen.GetContents()
|
||||
var b strings.Builder
|
||||
for row := range height {
|
||||
for column := range width {
|
||||
runes := cells[row*width+column].Runes
|
||||
if len(runes) == 0 {
|
||||
b.WriteRune(' ')
|
||||
continue
|
||||
}
|
||||
b.WriteRune(runes[0])
|
||||
}
|
||||
b.WriteRune('\n')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// waitForText fails the test unless want shows up on the screen.
|
||||
func waitForText(t *testing.T, screen tcell.SimulationScreen, onLoop func(func()), want string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(waitTimeout)
|
||||
for time.Now().Before(deadline) {
|
||||
var contents string
|
||||
onLoop(func() { contents = screenText(screen) })
|
||||
if strings.Contains(contents, want) {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
var contents string
|
||||
onLoop(func() { contents = screenText(screen) })
|
||||
t.Fatalf("%q never appeared on screen. Screen was:\n%s", want, contents)
|
||||
}
|
||||
|
||||
// makeDir creates a temporary directory holding the given files.
|
||||
func makeDir(t *testing.T, names ...string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
for _, name := range names {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0o600); err != nil {
|
||||
t.Fatalf("creating %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestAppListsTheFolderOnStart(t *testing.T) {
|
||||
dir := makeDir(t, "one.txt", "two.txt")
|
||||
_, screen, onLoop := startApp(t, dir)
|
||||
|
||||
waitForText(t, screen, onLoop, "one.txt")
|
||||
waitForText(t, screen, onLoop, "two.txt")
|
||||
waitForText(t, screen, onLoop, "No operations yet")
|
||||
}
|
||||
|
||||
func TestAppDisplaysColorTagsInUserTextLiterally(t *testing.T) {
|
||||
dir := makeDir(t, "report[red].txt")
|
||||
app, screen, onLoop := startApp(t, dir)
|
||||
ctx := context.Background()
|
||||
|
||||
waitForText(t, screen, onLoop, "report[red].txt")
|
||||
|
||||
onLoop(func() {
|
||||
cfg := ops.DefaultConfig(ops.KindReplace)
|
||||
cfg.Target = "[red]"
|
||||
cfg.Replacement = "[blue]"
|
||||
op, err := ops.New(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("ops.New: %v", err)
|
||||
}
|
||||
app.operations = append(app.operations, op)
|
||||
app.refresh(ctx)
|
||||
})
|
||||
|
||||
waitForText(t, screen, onLoop, `Replace "[red]" with`)
|
||||
waitForText(t, screen, onLoop, "report[blue].txt")
|
||||
}
|
||||
|
||||
func TestAppPreviewsAndAppliesAnOperation(t *testing.T) {
|
||||
dir := makeDir(t, "one.txt", "two.txt")
|
||||
app, screen, onLoop := startApp(t, dir)
|
||||
ctx := context.Background()
|
||||
|
||||
// Open the dialog the way the "Add operation" drop-down does.
|
||||
onLoop(func() {
|
||||
app.openOperationDialog(ctx, ops.KindUpper, ops.DefaultConfig(ops.KindUpper), -1)
|
||||
})
|
||||
waitForText(t, screen, onLoop, "Add operation")
|
||||
|
||||
// The preview is live: the new names show up before the dialog is closed.
|
||||
waitForText(t, screen, onLoop, "ONE.txt")
|
||||
|
||||
// Tab from the checkbox to the OK button and confirm.
|
||||
screen.InjectKey(tcell.KeyTab, 0, tcell.ModNone)
|
||||
screen.InjectKey(tcell.KeyEnter, 0, tcell.ModNone)
|
||||
waitForState(t, onLoop, "the operation to be added", func() bool {
|
||||
return len(app.operations) == 1
|
||||
})
|
||||
|
||||
onLoop(func() {
|
||||
if app.pending != nil {
|
||||
t.Error("the preview operation outlived the dialog")
|
||||
}
|
||||
if got := app.entries[0].Proposed; got != "ONE.txt" {
|
||||
t.Errorf("proposed name = %q, want %q", got, "ONE.txt")
|
||||
}
|
||||
})
|
||||
|
||||
// Apply the rename and confirm the dialog.
|
||||
onLoop(func() { app.confirmApply(ctx) })
|
||||
waitForText(t, screen, onLoop, "Rename 2 file(s)")
|
||||
screen.InjectKey(tcell.KeyEnter, 0, tcell.ModNone)
|
||||
waitForState(t, onLoop, "the rename to finish", func() bool {
|
||||
return len(app.names) == 2 && app.names[0] == "ONE.txt"
|
||||
})
|
||||
|
||||
got, err := files.List(ctx, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("listing the folder: %v", err)
|
||||
}
|
||||
if want := []string{"ONE.txt", "TWO.txt"}; !slices.Equal(got, want) {
|
||||
t.Errorf("folder holds %v, want %v", got, want)
|
||||
}
|
||||
|
||||
onLoop(func() {
|
||||
if len(app.operations) != 0 {
|
||||
t.Errorf("operations = %d, want the list to be cleared after a rename", len(app.operations))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAppCancelledDialogLeavesNoOperation(t *testing.T) {
|
||||
dir := makeDir(t, "one.txt")
|
||||
app, screen, onLoop := startApp(t, dir)
|
||||
ctx := context.Background()
|
||||
|
||||
onLoop(func() {
|
||||
app.openOperationDialog(ctx, ops.KindUpper, ops.DefaultConfig(ops.KindUpper), -1)
|
||||
})
|
||||
waitForText(t, screen, onLoop, "ONE.txt")
|
||||
|
||||
screen.InjectKey(tcell.KeyEscape, 0, tcell.ModNone)
|
||||
waitForState(t, onLoop, "the dialog to close", func() bool {
|
||||
name, _ := app.pages.GetFrontPage()
|
||||
return name == pageMain
|
||||
})
|
||||
|
||||
onLoop(func() {
|
||||
if len(app.operations) != 0 {
|
||||
t.Errorf("operations = %d, want 0", len(app.operations))
|
||||
}
|
||||
if got := app.entries[0].Proposed; got != "one.txt" {
|
||||
t.Errorf("proposed name = %q, want the preview to be undone", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAppRemovesFilesAndOperations(t *testing.T) {
|
||||
dir := makeDir(t, "one.txt", "two.txt")
|
||||
app, screen, onLoop := startApp(t, dir)
|
||||
ctx := context.Background()
|
||||
|
||||
// Drop the first file with the keyboard.
|
||||
onLoop(func() { app.app.SetFocus(app.filePane) })
|
||||
screen.InjectKey(tcell.KeyRune, 'x', tcell.ModNone)
|
||||
waitForState(t, onLoop, "the file to leave the list", func() bool {
|
||||
return slices.Equal(app.names, []string{"two.txt"})
|
||||
})
|
||||
|
||||
// Reloading brings it back.
|
||||
screen.InjectKey(tcell.KeyCtrlR, 0, tcell.ModNone)
|
||||
waitForState(t, onLoop, "the folder to be reloaded", func() bool {
|
||||
return slices.Equal(app.names, []string{"one.txt", "two.txt"})
|
||||
})
|
||||
|
||||
// Add two operations, then remove the first one with the keyboard.
|
||||
onLoop(func() {
|
||||
for _, kind := range []ops.Kind{ops.KindUpper, ops.KindLower} {
|
||||
op, err := ops.New(ops.DefaultConfig(kind))
|
||||
if err != nil {
|
||||
t.Fatalf("ops.New(%q): %v", kind, err)
|
||||
}
|
||||
app.operations = append(app.operations, op)
|
||||
}
|
||||
app.refresh(ctx)
|
||||
app.app.SetFocus(app.opPane)
|
||||
})
|
||||
waitForText(t, screen, onLoop, "To lowercase")
|
||||
|
||||
screen.InjectKey(tcell.KeyRune, 'x', tcell.ModNone)
|
||||
waitForState(t, onLoop, "the operation to be removed", func() bool {
|
||||
return len(app.operations) == 1
|
||||
})
|
||||
onLoop(func() {
|
||||
if got := app.operations[0].Config().Kind; got != ops.KindLower {
|
||||
t.Errorf("remaining operation = %q, want %q", got, ops.KindLower)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestEmptyPanesRemainResponsiveAfterNavigation(t *testing.T) {
|
||||
dir := makeDir(t, "only.txt")
|
||||
app, screen, onLoop := startApp(t, dir)
|
||||
|
||||
onLoop(func() { app.app.SetFocus(app.filePane) })
|
||||
screen.InjectKey(tcell.KeyDelete, 0, tcell.ModNone)
|
||||
waitForState(t, onLoop, "the last file to leave the list", func() bool {
|
||||
return len(app.names) == 0
|
||||
})
|
||||
|
||||
// tview searches for the next selectable cell on navigation. A table made
|
||||
// only of non-selectable header/placeholder cells must itself be marked
|
||||
// non-selectable, or this search can loop forever.
|
||||
screen.InjectKey(tcell.KeyUp, 0, tcell.ModNone)
|
||||
screen.InjectKey(tcell.KeyDown, 0, tcell.ModNone)
|
||||
screen.InjectKey(tcell.KeyTab, 0, tcell.ModNone)
|
||||
waitForState(t, onLoop, "the event loop to remain responsive", func() bool {
|
||||
return app.app.GetFocus() == app.opPane
|
||||
})
|
||||
|
||||
// The empty operation pane has the same protection.
|
||||
screen.InjectKey(tcell.KeyUp, 0, tcell.ModNone)
|
||||
screen.InjectKey(tcell.KeyTab, 0, tcell.ModNone)
|
||||
waitForState(t, onLoop, "focus to leave the empty operation pane", func() bool {
|
||||
return app.app.GetFocus() == app.addOp
|
||||
})
|
||||
}
|
||||
|
||||
func TestTabClosesTheOperationMenuBeforeChangingPanes(t *testing.T) {
|
||||
dir := makeDir(t, "one.txt")
|
||||
app, screen, onLoop := startApp(t, dir)
|
||||
|
||||
onLoop(func() { app.app.SetFocus(app.addOp) })
|
||||
screen.InjectKey(tcell.KeyEnter, 0, tcell.ModNone)
|
||||
waitForState(t, onLoop, "the operation menu to open", func() bool {
|
||||
return app.addOp.IsOpen()
|
||||
})
|
||||
|
||||
screen.InjectKey(tcell.KeyTab, 0, tcell.ModNone)
|
||||
waitForState(t, onLoop, "the operation menu to close and focus to move", func() bool {
|
||||
return !app.addOp.IsOpen() && app.app.GetFocus() == app.applyBtn
|
||||
})
|
||||
}
|
||||
|
||||
func TestAppMovesOperations(t *testing.T) {
|
||||
dir := makeDir(t, "one.txt")
|
||||
app, _, onLoop := startApp(t, dir)
|
||||
ctx := context.Background()
|
||||
|
||||
onLoop(func() {
|
||||
for _, kind := range []ops.Kind{ops.KindUpper, ops.KindLower} {
|
||||
op, err := ops.New(ops.DefaultConfig(kind))
|
||||
if err != nil {
|
||||
t.Fatalf("ops.New(%q): %v", kind, err)
|
||||
}
|
||||
app.operations = append(app.operations, op)
|
||||
}
|
||||
app.moveOperation(ctx, 1, -1)
|
||||
|
||||
if got := app.operations[0].Config().Kind; got != ops.KindLower {
|
||||
t.Errorf("first operation = %q, want %q", got, ops.KindLower)
|
||||
}
|
||||
// Moving past either end does nothing.
|
||||
app.moveOperation(ctx, 0, -1)
|
||||
app.moveOperation(ctx, 1, 1)
|
||||
if got := app.operations[0].Config().Kind; got != ops.KindLower {
|
||||
t.Errorf("first operation = %q, want %q", got, ops.KindLower)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAppReportsConflicts(t *testing.T) {
|
||||
dir := makeDir(t, "one.txt", "ONE.txt")
|
||||
app, screen, onLoop := startApp(t, dir)
|
||||
ctx := context.Background()
|
||||
|
||||
onLoop(func() {
|
||||
op, err := ops.New(ops.DefaultConfig(ops.KindUpper))
|
||||
if err != nil {
|
||||
t.Fatalf("ops.New: %v", err)
|
||||
}
|
||||
app.operations = append(app.operations, op)
|
||||
app.refresh(ctx)
|
||||
|
||||
if len(app.conflicts) != 2 {
|
||||
t.Errorf("conflicts = %v, want both files flagged", app.conflicts)
|
||||
}
|
||||
app.confirmApply(ctx)
|
||||
})
|
||||
waitForText(t, screen, onLoop, "Cannot rename")
|
||||
|
||||
got, err := files.List(ctx, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("listing the folder: %v", err)
|
||||
}
|
||||
if want := []string{"ONE.txt", "one.txt"}; !slices.Equal(got, want) {
|
||||
t.Errorf("folder holds %v, want the untouched %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppFollowsTheFolderInput(t *testing.T) {
|
||||
dir := makeDir(t, "one.txt")
|
||||
other := makeDir(t, "elsewhere.txt")
|
||||
app, screen, onLoop := startApp(t, dir)
|
||||
|
||||
onLoop(func() { app.dirInput.SetText(other) })
|
||||
waitForText(t, screen, onLoop, "elsewhere.txt")
|
||||
waitForState(t, onLoop, "the folder to change", func() bool {
|
||||
return app.dir == other && slices.Equal(app.names, []string{"elsewhere.txt"})
|
||||
})
|
||||
}
|
||||
|
||||
func TestSubdirectories(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, name := range []string{"alpha", "beta"} {
|
||||
if err := os.Mkdir(filepath.Join(dir, name), 0o750); err != nil {
|
||||
t.Fatalf("creating %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "afile"), nil, 0o600); err != nil {
|
||||
t.Fatalf("creating afile: %v", err)
|
||||
}
|
||||
|
||||
got := subdirectories(filepath.Join(dir, "a"))
|
||||
if want := []string{filepath.Join(dir, "alpha")}; !slices.Equal(got, want) {
|
||||
t.Errorf("subdirectories() = %v, want %v (files must be skipped)", got, want)
|
||||
}
|
||||
if got := subdirectories(""); got != nil {
|
||||
t.Errorf("subdirectories(\"\") = %v, want nil", got)
|
||||
}
|
||||
if got := subdirectories(filepath.Join(dir, "nothing", "here")); got != nil {
|
||||
t.Errorf("subdirectories() of a missing folder = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptIndex(t *testing.T) {
|
||||
tests := []struct {
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
{text: "", want: true},
|
||||
{text: "-", want: true},
|
||||
{text: "12", want: true},
|
||||
{text: "-3", want: true},
|
||||
{text: "t", want: true},
|
||||
{text: "to-l", want: true},
|
||||
{text: ops.ToLast, want: true},
|
||||
{text: "to-lastly", want: false},
|
||||
{text: "x", want: false},
|
||||
{text: "1a", want: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.text, func(t *testing.T) {
|
||||
if got := acceptIndex(tt.text, 0); got != tt.want {
|
||||
t.Errorf("acceptIndex(%q) = %v, want %v", tt.text, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
|
||||
"aehenamer/internal/files"
|
||||
"aehenamer/internal/ops"
|
||||
)
|
||||
|
||||
// Names of the floating windows shown on top of the main layout.
|
||||
const (
|
||||
pageOperation = "operation"
|
||||
pageConfirm = "confirm"
|
||||
pageMessage = "message"
|
||||
pageHelp = "help"
|
||||
)
|
||||
|
||||
// fieldWidth values used by the operation dialogs.
|
||||
const (
|
||||
textWidth = 28
|
||||
numberWidth = 10
|
||||
)
|
||||
|
||||
// editOperation opens the editor for the operation shown on the given row of
|
||||
// the operation pane.
|
||||
func (a *App) editOperation(ctx context.Context, row int) {
|
||||
if row < 0 || row >= len(a.operations) {
|
||||
return
|
||||
}
|
||||
cfg := a.operations[row].Config()
|
||||
a.openOperationDialog(ctx, cfg.Kind, cfg, row)
|
||||
}
|
||||
|
||||
// openOperationDialog shows the floating window in which the values of an
|
||||
// operation are entered. index is the position of the operation being edited,
|
||||
// or -1 when a new one is being added. The file list previews the operation
|
||||
// while the dialog is open.
|
||||
func (a *App) openOperationDialog(ctx context.Context, kind ops.Kind, cfg ops.Config, index int) {
|
||||
cfg.Kind = kind
|
||||
form := tview.NewForm()
|
||||
|
||||
preview := func() { a.setPending(ctx, cfg, index) }
|
||||
|
||||
text := func(label, value string, set func(string)) {
|
||||
form.AddInputField(label, value, textWidth, nil, func(entered string) {
|
||||
set(entered)
|
||||
preview()
|
||||
})
|
||||
}
|
||||
number := func(label, value string, accept func(string, rune) bool, set func(string)) {
|
||||
form.AddInputField(label, value, numberWidth, accept, func(entered string) {
|
||||
set(entered)
|
||||
preview()
|
||||
})
|
||||
}
|
||||
check := func(label string, value bool, set func(bool)) {
|
||||
form.AddCheckbox(label, value, func(checked bool) {
|
||||
set(checked)
|
||||
preview()
|
||||
})
|
||||
}
|
||||
|
||||
switch kind {
|
||||
case ops.KindReplace, ops.KindRemove:
|
||||
target := "Replace target"
|
||||
if kind == ops.KindRemove {
|
||||
target = "Remove target"
|
||||
}
|
||||
text(target, cfg.Target, func(v string) { cfg.Target = v })
|
||||
if kind == ops.KindReplace {
|
||||
text("Replacement", cfg.Replacement, func(v string) { cfg.Replacement = v })
|
||||
}
|
||||
number("Limit (0 = all)", cfg.Limit, acceptInt, func(v string) { cfg.Limit = v })
|
||||
check("From start", cfg.FromStart, func(v bool) { cfg.FromStart = v })
|
||||
check("Case sensitive", cfg.CaseSensitive, func(v bool) { cfg.CaseSensitive = v })
|
||||
check(strings.ToLower(string(kind))+" target is regex", cfg.Regex, func(v bool) { cfg.Regex = v })
|
||||
check("Preserve extension", cfg.PreserveExt, func(v bool) { cfg.PreserveExt = v })
|
||||
|
||||
case ops.KindInsert:
|
||||
text("Text to insert", cfg.Text, func(v string) { cfg.Text = v })
|
||||
number("Insert index ("+ops.ToLast+")", cfg.Index, acceptIndex, func(v string) { cfg.Index = v })
|
||||
check("Preserve extension", cfg.PreserveExt, func(v bool) { cfg.PreserveExt = v })
|
||||
|
||||
case ops.KindIncrement:
|
||||
text("Prefix", cfg.Prefix, func(v string) { cfg.Prefix = v })
|
||||
number("Number to start", cfg.Start, acceptInt, func(v string) { cfg.Start = v })
|
||||
number("Incremental step", cfg.Step, acceptInt, func(v string) { cfg.Step = v })
|
||||
check("Preserve extension", cfg.PreserveExt, func(v bool) { cfg.PreserveExt = v })
|
||||
|
||||
case ops.KindLower, ops.KindUpper:
|
||||
check("Preserve extension", cfg.PreserveExt, func(v bool) { cfg.PreserveExt = v })
|
||||
|
||||
case ops.KindTruncate:
|
||||
number("First character index", cfg.First, acceptIndex, func(v string) { cfg.First = v })
|
||||
number("Last character index", cfg.Last, acceptIndex, func(v string) { cfg.Last = v })
|
||||
check("Keep characters between mode", cfg.KeepBetween, func(v bool) { cfg.KeepBetween = v })
|
||||
check("Preserve extension", cfg.PreserveExt, func(v bool) { cfg.PreserveExt = v })
|
||||
}
|
||||
|
||||
cancel := func() {
|
||||
a.clearPending(ctx)
|
||||
a.closeDialog(pageOperation)
|
||||
}
|
||||
form.AddButton("OK", func() {
|
||||
op, err := ops.New(cfg)
|
||||
if err != nil {
|
||||
a.showMessage("Invalid value", err.Error())
|
||||
return
|
||||
}
|
||||
if index >= 0 && index < len(a.operations) {
|
||||
a.operations[index] = op
|
||||
} else {
|
||||
a.operations = append(a.operations, op)
|
||||
}
|
||||
a.clearPending(ctx)
|
||||
a.closeDialog(pageOperation)
|
||||
a.app.SetFocus(a.opPane)
|
||||
a.setStatus("[white]%s: %s", verb(index), tview.Escape(op.Summary()))
|
||||
})
|
||||
form.AddButton("Cancel", cancel)
|
||||
form.SetCancelFunc(cancel)
|
||||
|
||||
form.SetBorder(true).
|
||||
SetTitle(fmt.Sprintf(" %s — %s ", verb(index), kind)).
|
||||
SetTitleAlign(tview.AlignLeft)
|
||||
form.SetFocusFunc(func() {
|
||||
a.setStatus("[white]↑↓/[yellow]Tab[white] move between fields · [yellow]Space[white] toggles a checkbox · [yellow]Enter[white] on OK confirms · [yellow]Esc[white] cancels")
|
||||
})
|
||||
|
||||
height := form.GetFormItemCount()*2 + 5
|
||||
a.showDialog(pageOperation, form, 62, height)
|
||||
preview()
|
||||
}
|
||||
|
||||
// verb describes whether an operation is being added or edited.
|
||||
func verb(index int) string {
|
||||
if index >= 0 {
|
||||
return "Edit operation"
|
||||
}
|
||||
return "Add operation"
|
||||
}
|
||||
|
||||
// setPending previews cfg in the file list without adding it to the operation
|
||||
// list. Invalid values are reported in the status bar as they are typed.
|
||||
func (a *App) setPending(ctx context.Context, cfg ops.Config, index int) {
|
||||
op, err := ops.New(cfg)
|
||||
if err != nil {
|
||||
a.pending = nil
|
||||
a.refresh(ctx)
|
||||
a.setStatus("[red]%s", tview.Escape(err.Error()))
|
||||
return
|
||||
}
|
||||
a.pending = op
|
||||
a.pendingIndex = index
|
||||
a.refresh(ctx)
|
||||
a.setStatus("[white]Previewing: %s", tview.Escape(op.Summary()))
|
||||
}
|
||||
|
||||
// clearPending drops the previewed operation and restores the file list.
|
||||
func (a *App) clearPending(ctx context.Context) {
|
||||
a.pending = nil
|
||||
a.pendingIndex = -1
|
||||
a.refresh(ctx)
|
||||
}
|
||||
|
||||
// confirmApply checks the plan and asks the user before touching the disk.
|
||||
func (a *App) confirmApply(ctx context.Context) {
|
||||
if name, _ := a.pages.GetFrontPage(); name != pageMain {
|
||||
// A dialog is open and the file list only shows a preview of it.
|
||||
return
|
||||
}
|
||||
if len(a.conflicts) > 0 {
|
||||
a.showMessage("Cannot rename", fmt.Sprintf("%d file(s) have a name conflict.\nThe offending names are shown in red.", len(a.conflicts)))
|
||||
return
|
||||
}
|
||||
changed := 0
|
||||
for _, entry := range a.entries {
|
||||
if entry.Changed() {
|
||||
changed++
|
||||
}
|
||||
}
|
||||
if changed == 0 {
|
||||
a.setStatus("[yellow]Nothing to rename[white] — add an operation that changes the names")
|
||||
return
|
||||
}
|
||||
|
||||
modal := tview.NewModal().
|
||||
SetText(fmt.Sprintf("Rename %d file(s) in\n%s?\n\nThe operation list is cleared afterwards.", changed, a.dir)).
|
||||
AddButtons([]string{"Rename", "Cancel"}).
|
||||
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
||||
a.closeDialog(pageConfirm)
|
||||
if buttonLabel != "Rename" {
|
||||
return
|
||||
}
|
||||
a.applyRename(ctx)
|
||||
})
|
||||
modal.SetInputCapture(dismissOnEscape(func() { a.closeDialog(pageConfirm) }))
|
||||
|
||||
a.dialogFocus[pageConfirm] = a.app.GetFocus()
|
||||
a.pages.AddPage(pageConfirm, modal, true, true)
|
||||
a.app.SetFocus(modal)
|
||||
}
|
||||
|
||||
// applyRename performs the rename on disk and reloads the folder.
|
||||
func (a *App) applyRename(ctx context.Context) {
|
||||
renamed, err := files.Rename(ctx, a.dir, a.entries)
|
||||
if err != nil {
|
||||
a.reload(ctx)
|
||||
a.refresh(ctx)
|
||||
a.showMessage("Rename failed", err.Error())
|
||||
return
|
||||
}
|
||||
a.operations = nil
|
||||
a.reload(ctx)
|
||||
a.refresh(ctx)
|
||||
a.setStatus("[green]Renamed %d file(s)[white] in %s", renamed, a.dir)
|
||||
}
|
||||
|
||||
// showMessage reports an error or a notice in a floating window.
|
||||
func (a *App) showMessage(title, body string) {
|
||||
modal := tview.NewModal().
|
||||
SetText(title + "\n\n" + body).
|
||||
AddButtons([]string{"OK"}).
|
||||
SetDoneFunc(func(int, string) { a.closeDialog(pageMessage) })
|
||||
modal.SetInputCapture(dismissOnEscape(func() { a.closeDialog(pageMessage) }))
|
||||
|
||||
a.dialogFocus[pageMessage] = a.app.GetFocus()
|
||||
a.pages.AddPage(pageMessage, modal, true, true)
|
||||
a.app.SetFocus(modal)
|
||||
}
|
||||
|
||||
// helpText lists every key binding of the interface.
|
||||
const helpText = `[yellow]Anywhere[white]
|
||||
Tab / Shift-Tab move between folder, files, operations, buttons
|
||||
F1 this help
|
||||
Ctrl-R reload the folder (restores removed files)
|
||||
Ctrl-Q / Ctrl-C quit
|
||||
|
||||
[yellow]Folder input[white]
|
||||
Tab complete the typed path
|
||||
Enter load the folder
|
||||
|
||||
[yellow]File list[white]
|
||||
Up / Down select a file
|
||||
x or Delete drop the selected file from the list
|
||||
click on ✕ drop that file from the list
|
||||
|
||||
[yellow]Operation list[white]
|
||||
Enter or e edit the selected operation
|
||||
x or Delete remove the selected operation
|
||||
u / d move the operation up or down
|
||||
a jump to the "Add operation" drop-down
|
||||
|
||||
[yellow]Dialogs[white]
|
||||
Tab / Up / Down move between fields
|
||||
Space toggle a checkbox
|
||||
Enter activate the focused button
|
||||
Esc cancel
|
||||
|
||||
[yellow]Note[white]
|
||||
Renaming is applied only when you press "Apply rename".
|
||||
Every file is renamed through a temporary name, so names can be swapped.`
|
||||
|
||||
// showHelp displays the key bindings.
|
||||
func (a *App) showHelp() {
|
||||
view := tview.NewTextView().
|
||||
SetDynamicColors(true).
|
||||
SetText(helpText)
|
||||
view.SetBorder(true).SetTitle(" Keyboard and mouse ").SetTitleAlign(tview.AlignLeft)
|
||||
view.SetInputCapture(dismissOnEscape(func() { a.closeDialog(pageHelp) }))
|
||||
a.showDialog(pageHelp, view, 70, 30)
|
||||
}
|
||||
|
||||
// dismissOnEscape returns a key handler that runs close on Escape, Enter or
|
||||
// F1 and passes every other key through.
|
||||
func dismissOnEscape(close func()) func(*tcell.EventKey) *tcell.EventKey {
|
||||
return func(event *tcell.EventKey) *tcell.EventKey {
|
||||
switch event.Key() {
|
||||
case tcell.KeyEscape, tcell.KeyF1:
|
||||
close()
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
}
|
||||
}
|
||||
|
||||
// showDialog puts p on top of the main layout as a centred floating window.
|
||||
func (a *App) showDialog(name string, p tview.Primitive, width, height int) {
|
||||
a.dialogFocus[name] = a.app.GetFocus()
|
||||
a.pages.AddPage(name, center(p, width, height), true, true)
|
||||
a.app.SetFocus(p)
|
||||
}
|
||||
|
||||
// closeDialog removes a floating window and restores the previous focus.
|
||||
func (a *App) closeDialog(name string) {
|
||||
a.pages.RemovePage(name)
|
||||
if previous, ok := a.dialogFocus[name]; ok {
|
||||
delete(a.dialogFocus, name)
|
||||
if previous != nil {
|
||||
a.app.SetFocus(previous)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// center wraps p in a layout that keeps it at the given size in the middle of
|
||||
// the screen.
|
||||
func center(p tview.Primitive, width, height int) tview.Primitive {
|
||||
column := tview.NewFlex().SetDirection(tview.FlexRow).
|
||||
AddItem(nil, 0, 1, false).
|
||||
AddItem(p, height, 0, true).
|
||||
AddItem(nil, 0, 1, false)
|
||||
return tview.NewFlex().
|
||||
AddItem(nil, 0, 1, false).
|
||||
AddItem(column, width, 0, true).
|
||||
AddItem(nil, 0, 1, false)
|
||||
}
|
||||
|
||||
// acceptInt accepts a possibly negative decimal number, or an empty field.
|
||||
func acceptInt(text string, _ rune) bool {
|
||||
if text == "" || text == "-" {
|
||||
return true
|
||||
}
|
||||
_, err := strconv.Atoi(text)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// acceptIndex accepts a character index: a number, an empty field, or the
|
||||
// [ops.ToLast] keyword as it is being typed.
|
||||
func acceptIndex(text string, _ rune) bool {
|
||||
if acceptInt(text, 0) {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(ops.ToLast, strings.ToLower(text))
|
||||
}
|
||||
Reference in New Issue
Block a user