primeiro commit
This commit is contained in:
+20
@@ -0,0 +1,20 @@
|
|||||||
|
# Build output
|
||||||
|
/bin/
|
||||||
|
/aehenamer
|
||||||
|
*.exe
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Test, coverage, and profiling artifacts
|
||||||
|
coverage.out
|
||||||
|
coverage.html
|
||||||
|
*.prof
|
||||||
|
*.pprof
|
||||||
|
|
||||||
|
# Editor and operating-system files
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# agents.md
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
- Follow standard Go conventions: `gofmt`, `goimports`, and `golangci-lint`.
|
||||||
|
- Keep packages small and focused. Name them short, lowercase, no underscores.
|
||||||
|
- Avoid unnecessary abstractions — prefer simple, readable code over clever code.
|
||||||
|
- Define interfaces at the point of use, not the point of implementation. Keep them small.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- Always handle errors explicitly — never ignore them with `_`.
|
||||||
|
- Wrap errors with context: `fmt.Errorf("doing X: %w", err)`.
|
||||||
|
- Use `errors.Is` / `errors.As` for matching, not string comparison.
|
||||||
|
- Reserve `panic` for unrecoverable programmer mistakes, not runtime errors.
|
||||||
|
|
||||||
|
## Concurrency
|
||||||
|
|
||||||
|
- Pass `context.Context` as the first argument to any blocking or I/O function.
|
||||||
|
- Never store a `Context` in a struct. Never use `context.Background()` deep in business logic.
|
||||||
|
- Always manage goroutine lifecycles — use `errgroup`, `sync.WaitGroup`, or channels.
|
||||||
|
- Protect shared state with mutexes or by confining it to a single goroutine.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Write table-driven tests using `t.Run` for clarity and coverage.
|
||||||
|
- Use the standard `testing` package. Reach for `testify` only when it genuinely reduces noise.
|
||||||
|
- Prefer real implementations over mocks where feasible (e.g., `httptest`, in-memory stores).
|
||||||
|
- Keep tests close to the code they test; use `_test` packages for black-box testing.
|
||||||
|
- Benchmark with `testing.B` before optimizing anything.
|
||||||
|
|
||||||
|
## Dependencies & Modules
|
||||||
|
|
||||||
|
- Keep `go.mod` tidy — run `go mod tidy` before committing.
|
||||||
|
- Minimize external dependencies; prefer the standard library.
|
||||||
|
- Pin versions explicitly and review dependency updates carefully.
|
||||||
|
|
||||||
|
## General
|
||||||
|
|
||||||
|
- Measure before optimizing. Use `pprof` for profiling.
|
||||||
|
- Log at boundaries (entry/exit of services), not inside every function.
|
||||||
|
- Prefer explicit over implicit — avoid `init()` and global state.
|
||||||
|
|
||||||
|
## API Design
|
||||||
|
|
||||||
|
- Propagate `context.Context` through every layer of an API — from handler to store.
|
||||||
|
- Never drop context mid-call or substitute it with `context.Background()` silently.
|
||||||
|
- Honor context cancellation and deadlines in all I/O and long-running operations.
|
||||||
|
- Design APIs so callers can always pass a context; if a function does I/O, it takes a `ctx`.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- Document every exported type, function, method, and constant — no exceptions.
|
||||||
|
- Follow Go doc conventions: start the comment with the name of the thing being documented.
|
||||||
|
- Package-level comments should explain purpose and usage, not implementation details.
|
||||||
|
- Include examples (`Example*` functions) for non-trivial exported APIs.
|
||||||
|
|
||||||
|
```go
|
||||||
|
// UserStore retrieves and persists user records.
|
||||||
|
type UserStore interface { ... }
|
||||||
|
|
||||||
|
// GetByID returns the user with the given ID, or ErrNotFound if absent.
|
||||||
|
func (s *store) GetByID(ctx context.Context, id string) (*User, error) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing (expanded)
|
||||||
|
|
||||||
|
- Write tests as you write code — not after, not when asked. Tests are not optional.
|
||||||
|
- Every exported function and critical internal path must have at least one test.
|
||||||
|
- Cover edge cases, error paths, and boundary conditions — not just the happy path.
|
||||||
|
- Use `t.Helper()` in shared test utilities to keep failure output pointing at the call site.
|
||||||
|
- Integration tests live in `test/` or behind a build tag (e.g., `//go:build integration`).
|
||||||
|
|
||||||
|
## Microservice Readiness
|
||||||
|
|
||||||
|
- Expose `/healthz` (liveness) and `/readyz` (readiness) endpoints on a dedicated port.
|
||||||
|
- Liveness indicates the process is alive; readiness indicates it can serve traffic.
|
||||||
|
- Handle `SIGTERM` and `SIGINT` with graceful shutdown: stop accepting new requests, finish in-flight work, release resources, then exit.
|
||||||
|
- Set explicit timeouts on `http.Server`: `ReadTimeout`, `WriteTimeout`, `IdleTimeout`.
|
||||||
|
- Use `errgroup` with a context to coordinate shutdown across goroutines cleanly.
|
||||||
|
|
||||||
|
```go
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
|
||||||
|
defer stop()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Never log secrets, tokens, passwords, or PII — scrub them before logging.
|
||||||
|
- Validate and sanitize all external input; never trust data from outside the process boundary.
|
||||||
|
- Use `crypto/rand` for all randomness that touches security; never `math/rand`.
|
||||||
|
- Enforce TLS for all external communication; keep cipher suites and TLS version current.
|
||||||
|
- Follow the principle of least privilege for service accounts, DB roles, and IAM policies.
|
||||||
|
- Scan dependencies for known CVEs regularly (e.g., `govulncheck`).
|
||||||
|
- Set strict timeouts on all outbound HTTP clients — never use the default zero-timeout client.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
GO ?= go
|
||||||
|
BINARY ?= aehenamer
|
||||||
|
BUILD_DIR ?= bin
|
||||||
|
BUILD_FLAGS ?= -trimpath
|
||||||
|
|
||||||
|
.PHONY: all build test clean
|
||||||
|
|
||||||
|
all: build
|
||||||
|
|
||||||
|
build:
|
||||||
|
mkdir -p $(BUILD_DIR)
|
||||||
|
CGO_ENABLED=0 $(GO) build $(BUILD_FLAGS) -o $(BUILD_DIR)/$(BINARY) .
|
||||||
|
|
||||||
|
test:
|
||||||
|
CGO_ENABLED=0 $(GO) test ./...
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -f $(BUILD_DIR)/$(BINARY)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
module aehenamer
|
||||||
|
|
||||||
|
go 1.24.4
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gdamore/tcell/v2 v2.13.10
|
||||||
|
github.com/rivo/tview v0.42.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gdamore/encoding v1.0.1 // indirect
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
|
golang.org/x/term v0.37.0 // indirect
|
||||||
|
golang.org/x/text v0.31.0 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
|
||||||
|
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
|
||||||
|
github.com/gdamore/tcell/v2 v2.13.10 h1:Afs3JKt83HnhuUKdZ3MnxUgOqQRWftj5JyDqv1LLynA=
|
||||||
|
github.com/gdamore/tcell/v2 v2.13.10/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c=
|
||||||
|
github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||||
|
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
|
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||||
|
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||||
|
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
// Package files lists the files a rename operates on and applies a finished
|
||||||
|
// rename plan to disk.
|
||||||
|
package files
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Entry pairs a file currently on disk with the name proposed for it.
|
||||||
|
type Entry struct {
|
||||||
|
Original string
|
||||||
|
Proposed string
|
||||||
|
}
|
||||||
|
|
||||||
|
// pending is one file staged under a temporary name during a rename.
|
||||||
|
type pending struct {
|
||||||
|
original string
|
||||||
|
temp string
|
||||||
|
proposed string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changed reports whether the entry would actually be renamed.
|
||||||
|
func (e Entry) Changed() bool { return e.Original != e.Proposed }
|
||||||
|
|
||||||
|
// List returns the names of the files directly inside dir, sorted by name.
|
||||||
|
// Subdirectories are ignored: the listing is one level deep and never
|
||||||
|
// recursive. The returned names are base names, not paths.
|
||||||
|
func List(ctx context.Context, dir string) ([]string, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading directory %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
names = append(names, entry.Name())
|
||||||
|
}
|
||||||
|
return names, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conflicts checks a rename plan and returns, keyed by entry index, the reason
|
||||||
|
// each problematic entry cannot be renamed. An empty result means the plan is
|
||||||
|
// safe to apply. dir is inspected for filesystem entries that are not part of
|
||||||
|
// the plan but would block a destination; a directory that cannot be read
|
||||||
|
// simply contributes no such conflicts.
|
||||||
|
func Conflicts(ctx context.Context, dir string, entries []Entry) map[int]string {
|
||||||
|
problems := make(map[int]string)
|
||||||
|
|
||||||
|
targets := make(map[string][]int, len(entries))
|
||||||
|
for i, e := range entries {
|
||||||
|
switch {
|
||||||
|
case strings.TrimSpace(e.Proposed) == "":
|
||||||
|
problems[i] = "new name is empty"
|
||||||
|
case e.Proposed == "." || e.Proposed == "..":
|
||||||
|
problems[i] = "new name is a directory reference"
|
||||||
|
case strings.ContainsRune(e.Proposed, os.PathSeparator), strings.ContainsRune(e.Proposed, '/'):
|
||||||
|
problems[i] = "new name contains a path separator"
|
||||||
|
}
|
||||||
|
targets[e.Proposed] = append(targets[e.Proposed], i)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, indexes := range targets {
|
||||||
|
if len(indexes) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, i := range indexes {
|
||||||
|
if _, taken := problems[i]; !taken {
|
||||||
|
problems[i] = "several files would get this name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
planned := make(map[string]bool, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
planned[e.Original] = true
|
||||||
|
}
|
||||||
|
existing, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return problems
|
||||||
|
}
|
||||||
|
onDisk := make(map[string]bool, len(existing))
|
||||||
|
for _, entry := range existing {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return problems
|
||||||
|
}
|
||||||
|
onDisk[entry.Name()] = true
|
||||||
|
}
|
||||||
|
for i, e := range entries {
|
||||||
|
if _, taken := problems[i]; taken || !e.Changed() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if onDisk[e.Proposed] && !planned[e.Proposed] {
|
||||||
|
problems[i] = "a filesystem entry with this name already exists"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return problems
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename applies the plan inside dir and returns the number of files renamed.
|
||||||
|
// Entries whose name is unchanged are skipped. Renaming happens in two passes
|
||||||
|
// through temporary names so that files can swap or rotate names, and a
|
||||||
|
// failure in the first pass is rolled back before the error is returned.
|
||||||
|
func Rename(ctx context.Context, dir string, entries []Entry) (int, error) {
|
||||||
|
problems := Conflicts(ctx, dir, entries)
|
||||||
|
for i, e := range entries {
|
||||||
|
if reason, bad := problems[i]; bad {
|
||||||
|
return 0, fmt.Errorf("cannot rename %s: %s", e.Original, reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var staged []pending
|
||||||
|
|
||||||
|
rollbackStaged := func() {
|
||||||
|
for i := len(staged) - 1; i >= 0; i-- {
|
||||||
|
_ = os.Rename(filepath.Join(dir, staged[i].temp), filepath.Join(dir, staged[i].original))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, e := range entries {
|
||||||
|
if !e.Changed() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
rollbackStaged()
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
temp, err := tempName(dir, i, e.Original)
|
||||||
|
if err != nil {
|
||||||
|
rollbackStaged()
|
||||||
|
return 0, fmt.Errorf("preparing rename of %s: %w", e.Original, err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(filepath.Join(dir, e.Original), filepath.Join(dir, temp)); err != nil {
|
||||||
|
rollbackStaged()
|
||||||
|
return 0, fmt.Errorf("preparing rename of %s: %w", e.Original, err)
|
||||||
|
}
|
||||||
|
staged = append(staged, pending{original: e.Original, temp: temp, proposed: e.Proposed})
|
||||||
|
}
|
||||||
|
|
||||||
|
renamed := 0
|
||||||
|
for i, p := range staged {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
rollbackCommitted(dir, staged, i)
|
||||||
|
return renamed, err
|
||||||
|
}
|
||||||
|
if err := os.Rename(filepath.Join(dir, p.temp), filepath.Join(dir, p.proposed)); err != nil {
|
||||||
|
rollbackCommitted(dir, staged, i)
|
||||||
|
return renamed, fmt.Errorf("renaming to %s: %w", p.proposed, err)
|
||||||
|
}
|
||||||
|
renamed++
|
||||||
|
}
|
||||||
|
return renamed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxNameLen is the longest file name accepted by common file systems.
|
||||||
|
const maxNameLen = 255
|
||||||
|
|
||||||
|
// rollbackCommitted moves completed destinations back to temporary names,
|
||||||
|
// then restores every staged file to its original name. The first phase is
|
||||||
|
// necessary for swaps, where an original name may currently be a destination.
|
||||||
|
func rollbackCommitted(dir string, staged []pending, committed int) {
|
||||||
|
for i := committed - 1; i >= 0; i-- {
|
||||||
|
_ = os.Rename(filepath.Join(dir, staged[i].proposed), filepath.Join(dir, staged[i].temp))
|
||||||
|
}
|
||||||
|
for i := len(staged) - 1; i >= 0; i-- {
|
||||||
|
_ = os.Rename(filepath.Join(dir, staged[i].temp), filepath.Join(dir, staged[i].original))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tempName returns an unused, hard-to-guess intermediate name, short enough
|
||||||
|
// to stay valid on common file systems.
|
||||||
|
func tempName(dir string, i int, original string) (string, error) {
|
||||||
|
var random [8]byte
|
||||||
|
if _, err := rand.Read(random[:]); err != nil {
|
||||||
|
return "", fmt.Errorf("generating temporary name: %w", err)
|
||||||
|
}
|
||||||
|
prefix := fmt.Sprintf(".aehenamer-%d-%s-", i, hex.EncodeToString(random[:]))
|
||||||
|
remaining := maxNameLen - len(prefix)
|
||||||
|
if remaining < 0 {
|
||||||
|
remaining = 0
|
||||||
|
}
|
||||||
|
if len(original) > remaining {
|
||||||
|
original = original[:remaining]
|
||||||
|
}
|
||||||
|
name := prefix + original
|
||||||
|
if _, err := os.Lstat(filepath.Join(dir, name)); err == nil {
|
||||||
|
return "", fmt.Errorf("temporary name %q already exists", name)
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
return "", fmt.Errorf("checking temporary name %q: %w", name, err)
|
||||||
|
}
|
||||||
|
return name, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
package files_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"aehenamer/internal/files"
|
||||||
|
)
|
||||||
|
|
||||||
|
// makeDir creates a temporary directory containing 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// namesOn returns the sorted file names currently in dir.
|
||||||
|
func namesOn(t *testing.T, dir string) []string {
|
||||||
|
t.Helper()
|
||||||
|
got, err := files.List(context.Background(), dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listing %s: %v", dir, err)
|
||||||
|
}
|
||||||
|
return got
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestList(t *testing.T) {
|
||||||
|
dir := makeDir(t, "b.txt", "a.txt", ".hidden")
|
||||||
|
if err := os.Mkdir(filepath.Join(dir, "sub"), 0o750); err != nil {
|
||||||
|
t.Fatalf("creating subdirectory: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "sub", "deep.txt"), nil, 0o600); err != nil {
|
||||||
|
t.Fatalf("creating nested file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := namesOn(t, dir)
|
||||||
|
want := []string{".hidden", "a.txt", "b.txt"}
|
||||||
|
if !slices.Equal(got, want) {
|
||||||
|
t.Errorf("List() = %v, want %v (directories and nested files must be skipped)", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListErrors(t *testing.T) {
|
||||||
|
t.Run("missing directory", func(t *testing.T) {
|
||||||
|
_, err := files.List(context.Background(), filepath.Join(t.TempDir(), "nope"))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("List() of a missing directory returned no error")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Errorf("error %v does not wrap os.ErrNotExist", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("cancelled context", func(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
if _, err := files.List(ctx, t.TempDir()); !errors.Is(err, context.Canceled) {
|
||||||
|
t.Errorf("List() = %v, want context.Canceled", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConflicts(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
onDisk []string
|
||||||
|
directories []string
|
||||||
|
entries []files.Entry
|
||||||
|
want map[int]string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "clean plan",
|
||||||
|
onDisk: []string{"a.txt", "b.txt"},
|
||||||
|
entries: []files.Entry{
|
||||||
|
{Original: "a.txt", Proposed: "1.txt"},
|
||||||
|
{Original: "b.txt", Proposed: "2.txt"},
|
||||||
|
},
|
||||||
|
want: map[int]string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "swapping names is allowed",
|
||||||
|
onDisk: []string{"a.txt", "b.txt"},
|
||||||
|
entries: []files.Entry{
|
||||||
|
{Original: "a.txt", Proposed: "b.txt"},
|
||||||
|
{Original: "b.txt", Proposed: "a.txt"},
|
||||||
|
},
|
||||||
|
want: map[int]string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "duplicate targets",
|
||||||
|
onDisk: []string{"a.txt", "b.txt"},
|
||||||
|
entries: []files.Entry{
|
||||||
|
{Original: "a.txt", Proposed: "same.txt"},
|
||||||
|
{Original: "b.txt", Proposed: "same.txt"},
|
||||||
|
},
|
||||||
|
want: map[int]string{0: "several files would get this name", 1: "several files would get this name"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty name",
|
||||||
|
onDisk: []string{"a.txt"},
|
||||||
|
entries: []files.Entry{
|
||||||
|
{Original: "a.txt", Proposed: " "},
|
||||||
|
},
|
||||||
|
want: map[int]string{0: "new name is empty"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "path separator",
|
||||||
|
onDisk: []string{"a.txt"},
|
||||||
|
entries: []files.Entry{
|
||||||
|
{Original: "a.txt", Proposed: "sub/a.txt"},
|
||||||
|
},
|
||||||
|
want: map[int]string{0: "new name contains a path separator"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "directory reference",
|
||||||
|
onDisk: []string{"a.txt"},
|
||||||
|
entries: []files.Entry{
|
||||||
|
{Original: "a.txt", Proposed: ".."},
|
||||||
|
},
|
||||||
|
want: map[int]string{0: "new name is a directory reference"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "would overwrite a file that is not part of the plan",
|
||||||
|
onDisk: []string{"a.txt", "keep.txt"},
|
||||||
|
entries: []files.Entry{
|
||||||
|
{Original: "a.txt", Proposed: "keep.txt"},
|
||||||
|
},
|
||||||
|
want: map[int]string{0: "a filesystem entry with this name already exists"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "would collide with an existing directory",
|
||||||
|
onDisk: []string{"a.txt"},
|
||||||
|
directories: []string{"archive"},
|
||||||
|
entries: []files.Entry{
|
||||||
|
{Original: "a.txt", Proposed: "archive"},
|
||||||
|
},
|
||||||
|
want: map[int]string{0: "a filesystem entry with this name already exists"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unchanged names never conflict with themselves",
|
||||||
|
onDisk: []string{"a.txt"},
|
||||||
|
entries: []files.Entry{
|
||||||
|
{Original: "a.txt", Proposed: "a.txt"},
|
||||||
|
},
|
||||||
|
want: map[int]string{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
dir := makeDir(t, tt.onDisk...)
|
||||||
|
for _, name := range tt.directories {
|
||||||
|
if err := os.Mkdir(filepath.Join(dir, name), 0o750); err != nil {
|
||||||
|
t.Fatalf("creating directory %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got := files.Conflicts(context.Background(), dir, tt.entries)
|
||||||
|
if len(got) != len(tt.want) {
|
||||||
|
t.Fatalf("Conflicts() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
for index, reason := range tt.want {
|
||||||
|
if got[index] != reason {
|
||||||
|
t.Errorf("entry %d: got %q, want %q", index, got[index], reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRename(t *testing.T) {
|
||||||
|
t.Run("renames and skips unchanged files", func(t *testing.T) {
|
||||||
|
dir := makeDir(t, "a.txt", "b.txt")
|
||||||
|
renamed, err := files.Rename(context.Background(), dir, []files.Entry{
|
||||||
|
{Original: "a.txt", Proposed: "1.txt"},
|
||||||
|
{Original: "b.txt", Proposed: "b.txt"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Rename() error: %v", err)
|
||||||
|
}
|
||||||
|
if renamed != 1 {
|
||||||
|
t.Errorf("Rename() = %d, want 1", renamed)
|
||||||
|
}
|
||||||
|
if got, want := namesOn(t, dir), []string{"1.txt", "b.txt"}; !slices.Equal(got, want) {
|
||||||
|
t.Errorf("directory holds %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("swaps names", func(t *testing.T) {
|
||||||
|
dir := makeDir(t, "a.txt", "b.txt")
|
||||||
|
if _, err := files.Rename(context.Background(), dir, []files.Entry{
|
||||||
|
{Original: "a.txt", Proposed: "b.txt"},
|
||||||
|
{Original: "b.txt", Proposed: "a.txt"},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("Rename() error: %v", err)
|
||||||
|
}
|
||||||
|
content, err := os.ReadFile(filepath.Join(dir, "a.txt"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading a.txt: %v", err)
|
||||||
|
}
|
||||||
|
if string(content) != "b.txt" {
|
||||||
|
t.Errorf("a.txt holds %q, want the contents of the old b.txt", content)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("rotates names", func(t *testing.T) {
|
||||||
|
dir := makeDir(t, "a.txt", "b.txt", "c.txt")
|
||||||
|
if _, err := files.Rename(context.Background(), dir, []files.Entry{
|
||||||
|
{Original: "a.txt", Proposed: "b.txt"},
|
||||||
|
{Original: "b.txt", Proposed: "c.txt"},
|
||||||
|
{Original: "c.txt", Proposed: "a.txt"},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("Rename() error: %v", err)
|
||||||
|
}
|
||||||
|
for name, want := range map[string]string{"b.txt": "a.txt", "c.txt": "b.txt", "a.txt": "c.txt"} {
|
||||||
|
content, err := os.ReadFile(filepath.Join(dir, name))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading %s: %v", name, err)
|
||||||
|
}
|
||||||
|
if string(content) != want {
|
||||||
|
t.Errorf("%s holds %q, want %q", name, content, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("refuses a conflicting plan", func(t *testing.T) {
|
||||||
|
dir := makeDir(t, "a.txt", "b.txt")
|
||||||
|
_, err := files.Rename(context.Background(), dir, []files.Entry{
|
||||||
|
{Original: "a.txt", Proposed: "same.txt"},
|
||||||
|
{Original: "b.txt", Proposed: "same.txt"},
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Rename() accepted a conflicting plan")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "a.txt") {
|
||||||
|
t.Errorf("error %v does not name the offending file", err)
|
||||||
|
}
|
||||||
|
if got, want := namesOn(t, dir), []string{"a.txt", "b.txt"}; !slices.Equal(got, want) {
|
||||||
|
t.Errorf("directory holds %v, want the untouched %v", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("rolls back when a file disappears", func(t *testing.T) {
|
||||||
|
dir := makeDir(t, "a.txt", "b.txt")
|
||||||
|
if err := os.Remove(filepath.Join(dir, "b.txt")); err != nil {
|
||||||
|
t.Fatalf("removing b.txt: %v", err)
|
||||||
|
}
|
||||||
|
_, err := files.Rename(context.Background(), dir, []files.Entry{
|
||||||
|
{Original: "a.txt", Proposed: "1.txt"},
|
||||||
|
{Original: "b.txt", Proposed: "2.txt"},
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Rename() succeeded although a file was missing")
|
||||||
|
}
|
||||||
|
if got, want := namesOn(t, dir), []string{"a.txt"}; !slices.Equal(got, want) {
|
||||||
|
t.Errorf("directory holds %v, want the rolled back %v", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("rolls back when the final pass fails", func(t *testing.T) {
|
||||||
|
dir := makeDir(t, "a.txt", "b.txt")
|
||||||
|
tooLong := strings.Repeat("x", 256)
|
||||||
|
|
||||||
|
renamed, err := files.Rename(context.Background(), dir, []files.Entry{
|
||||||
|
{Original: "a.txt", Proposed: "1.txt"},
|
||||||
|
{Original: "b.txt", Proposed: tooLong},
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Rename() succeeded with an overlong destination name")
|
||||||
|
}
|
||||||
|
if renamed != 1 {
|
||||||
|
t.Errorf("Rename() = %d completed renames before rollback, want 1", renamed)
|
||||||
|
}
|
||||||
|
if got, want := namesOn(t, dir), []string{"a.txt", "b.txt"}; !slices.Equal(got, want) {
|
||||||
|
t.Errorf("directory holds files %v, want the rolled back %v", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("cancelled context stops before touching anything", func(t *testing.T) {
|
||||||
|
dir := makeDir(t, "a.txt")
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
if _, err := files.Rename(ctx, dir, []files.Entry{{Original: "a.txt", Proposed: "1.txt"}}); !errors.Is(err, context.Canceled) {
|
||||||
|
t.Errorf("Rename() = %v, want context.Canceled", err)
|
||||||
|
}
|
||||||
|
if got, want := namesOn(t, dir), []string{"a.txt"}; !slices.Equal(got, want) {
|
||||||
|
t.Errorf("directory holds %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
// Package ops implements the file name transformations that a bulk rename is
|
||||||
|
// built from. Operations are pure functions of a name and its position in the
|
||||||
|
// file list, which makes a rename easy to preview before anything is renamed
|
||||||
|
// on disk.
|
||||||
|
package ops
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Kind identifies a type of rename operation.
|
||||||
|
type Kind string
|
||||||
|
|
||||||
|
// The supported operation kinds.
|
||||||
|
const (
|
||||||
|
KindReplace Kind = "Replace"
|
||||||
|
KindRemove Kind = "Remove"
|
||||||
|
KindInsert Kind = "Insert"
|
||||||
|
KindIncrement Kind = "Increment"
|
||||||
|
KindLower Kind = "To lowercase"
|
||||||
|
KindUpper Kind = "To uppercase"
|
||||||
|
KindTruncate Kind = "Truncate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Kinds lists every supported operation kind, in menu order.
|
||||||
|
var Kinds = []Kind{
|
||||||
|
KindReplace,
|
||||||
|
KindRemove,
|
||||||
|
KindInsert,
|
||||||
|
KindIncrement,
|
||||||
|
KindLower,
|
||||||
|
KindUpper,
|
||||||
|
KindTruncate,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToLast is the sentinel accepted by index fields to address the last
|
||||||
|
// character of a name instead of a fixed position.
|
||||||
|
const ToLast = "to-last"
|
||||||
|
|
||||||
|
// Config holds the raw, user-entered settings of a single operation. Values
|
||||||
|
// are kept as typed by the user so that an operation can be re-opened for
|
||||||
|
// editing exactly as it was created. Fields not used by Config.Kind are
|
||||||
|
// ignored by [New].
|
||||||
|
type Config struct {
|
||||||
|
Kind Kind
|
||||||
|
|
||||||
|
Target string // Replace, Remove: the text or pattern to look for.
|
||||||
|
Replacement string // Replace: the text to put in its place.
|
||||||
|
Limit string // Replace, Remove: max number of matches, empty or 0 for all.
|
||||||
|
Text string // Insert: the text to insert.
|
||||||
|
Index string // Insert: character position, or ToLast for the end.
|
||||||
|
Prefix string // Increment: text placed before the counter.
|
||||||
|
Start string // Increment: first counter value.
|
||||||
|
Step string // Increment: counter increment per file.
|
||||||
|
First string // Truncate: first character index, or ToLast.
|
||||||
|
Last string // Truncate: last character index, or ToLast.
|
||||||
|
|
||||||
|
FromStart bool // Replace, Remove: apply Limit to the first matches instead of the last.
|
||||||
|
CaseSensitive bool // Replace, Remove: match letter case exactly.
|
||||||
|
Regex bool // Replace, Remove: treat Target as a regular expression.
|
||||||
|
KeepBetween bool // Truncate: keep the selected range instead of removing it.
|
||||||
|
PreserveExt bool // All kinds: leave the file extension untouched.
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultConfig returns the configuration a new operation of the given kind
|
||||||
|
// starts with, including the documented default values.
|
||||||
|
func DefaultConfig(kind Kind) Config {
|
||||||
|
cfg := Config{Kind: kind, FromStart: true, PreserveExt: true}
|
||||||
|
switch kind {
|
||||||
|
case KindInsert:
|
||||||
|
cfg.Index = "0"
|
||||||
|
case KindIncrement:
|
||||||
|
cfg.Start = "0"
|
||||||
|
cfg.Step = "1"
|
||||||
|
case KindTruncate:
|
||||||
|
cfg.First = "0"
|
||||||
|
cfg.Last = "0"
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// Operation transforms file names as one step of a rename pipeline.
|
||||||
|
type Operation interface {
|
||||||
|
// Config returns the settings the operation was built from.
|
||||||
|
Config() Config
|
||||||
|
// Summary returns a short one-line description for the operation list.
|
||||||
|
Summary() string
|
||||||
|
// Apply returns name transformed. index is the zero-based position of the
|
||||||
|
// file within the list being renamed; only counter-based operations use it.
|
||||||
|
Apply(name string, index int) string
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds an operation from cfg. It returns an error if a numeric field
|
||||||
|
// cannot be parsed or a regular expression does not compile.
|
||||||
|
func New(cfg Config) (Operation, error) {
|
||||||
|
switch cfg.Kind {
|
||||||
|
case KindReplace, KindRemove:
|
||||||
|
return newSubstitute(cfg)
|
||||||
|
case KindInsert:
|
||||||
|
return newInsert(cfg)
|
||||||
|
case KindIncrement:
|
||||||
|
return newIncrement(cfg)
|
||||||
|
case KindLower, KindUpper:
|
||||||
|
return &caseOp{cfg: cfg}, nil
|
||||||
|
case KindTruncate:
|
||||||
|
return newTruncate(cfg)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown operation kind %q", cfg.Kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preview applies every operation, in order, to each of names and returns the
|
||||||
|
// resulting names. The input slice is not modified.
|
||||||
|
func Preview(names []string, list []Operation) []string {
|
||||||
|
out := make([]string, len(names))
|
||||||
|
for i, name := range names {
|
||||||
|
for _, op := range list {
|
||||||
|
name = op.Apply(name, i)
|
||||||
|
}
|
||||||
|
out[i] = name
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitExt splits name into a stem and an extension. When preserve is false,
|
||||||
|
// or when name has no extension to speak of (such as ".bashrc"), the whole
|
||||||
|
// name is returned as the stem.
|
||||||
|
func splitExt(name string, preserve bool) (stem, ext string) {
|
||||||
|
if !preserve {
|
||||||
|
return name, ""
|
||||||
|
}
|
||||||
|
ext = filepath.Ext(name)
|
||||||
|
if ext == "" || ext == name {
|
||||||
|
return name, ""
|
||||||
|
}
|
||||||
|
return name[:len(name)-len(ext)], ext
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseInt parses a decimal integer, returning def for an empty string. The
|
||||||
|
// field name is used to give the error context.
|
||||||
|
func parseInt(field, s string, def int) (int, error) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(s)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("%s: %q is not a number", field, s)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseIndex parses a character index that may also be the [ToLast] sentinel,
|
||||||
|
// in which case toLast is true and n is meaningless.
|
||||||
|
func parseIndex(field, s string, def int) (n int, toLast bool, err error) {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(s), ToLast) {
|
||||||
|
return 0, true, nil
|
||||||
|
}
|
||||||
|
n, err = parseInt(field, s, def)
|
||||||
|
return n, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// compileTarget turns the user's search text into a regular expression,
|
||||||
|
// quoting it unless cfg asks for regex mode. It returns a nil expression for
|
||||||
|
// an empty target, which makes the operation a no-op.
|
||||||
|
func compileTarget(cfg Config) (*regexp.Regexp, error) {
|
||||||
|
if cfg.Target == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
pattern := cfg.Target
|
||||||
|
if !cfg.Regex {
|
||||||
|
pattern = regexp.QuoteMeta(pattern)
|
||||||
|
}
|
||||||
|
if !cfg.CaseSensitive {
|
||||||
|
pattern = "(?i)" + pattern
|
||||||
|
}
|
||||||
|
re, err := regexp.Compile(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid regular expression: %w", err)
|
||||||
|
}
|
||||||
|
return re, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// substituteOp implements both [KindReplace] and [KindRemove]; a removal is a
|
||||||
|
// replacement with empty text.
|
||||||
|
type substituteOp struct {
|
||||||
|
cfg Config
|
||||||
|
re *regexp.Regexp
|
||||||
|
repl string
|
||||||
|
limit int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSubstitute(cfg Config) (Operation, error) {
|
||||||
|
re, err := compileTarget(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
limit, err := parseInt("limit", cfg.Limit, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if limit < 0 {
|
||||||
|
return nil, fmt.Errorf("limit: must not be negative")
|
||||||
|
}
|
||||||
|
op := &substituteOp{cfg: cfg, re: re, limit: limit}
|
||||||
|
if cfg.Kind == KindReplace {
|
||||||
|
op.repl = cfg.Replacement
|
||||||
|
}
|
||||||
|
return op, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *substituteOp) Config() Config { return o.cfg }
|
||||||
|
|
||||||
|
func (o *substituteOp) Apply(name string, _ int) string {
|
||||||
|
if o.re == nil {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
stem, ext := splitExt(name, o.cfg.PreserveExt)
|
||||||
|
return o.substitute(stem) + ext
|
||||||
|
}
|
||||||
|
|
||||||
|
// substitute replaces the selected matches in s. In regex mode the
|
||||||
|
// replacement may refer to capture groups with $1 or ${name}.
|
||||||
|
func (o *substituteOp) substitute(s string) string {
|
||||||
|
matches := o.re.FindAllStringSubmatchIndex(s, -1)
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
if o.limit > 0 && o.limit < len(matches) {
|
||||||
|
if o.cfg.FromStart {
|
||||||
|
matches = matches[:o.limit]
|
||||||
|
} else {
|
||||||
|
matches = matches[len(matches)-o.limit:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var b []byte
|
||||||
|
last := 0
|
||||||
|
for _, m := range matches {
|
||||||
|
b = append(b, s[last:m[0]]...)
|
||||||
|
if o.cfg.Regex {
|
||||||
|
b = o.re.ExpandString(b, o.repl, s, m)
|
||||||
|
} else {
|
||||||
|
b = append(b, o.repl...)
|
||||||
|
}
|
||||||
|
last = m[1]
|
||||||
|
}
|
||||||
|
return string(append(b, s[last:]...))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *substituteOp) Summary() string {
|
||||||
|
var b strings.Builder
|
||||||
|
if o.cfg.Kind == KindRemove {
|
||||||
|
fmt.Fprintf(&b, "Remove %q", o.cfg.Target)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(&b, "Replace %q with %q", o.cfg.Target, o.cfg.Replacement)
|
||||||
|
}
|
||||||
|
flags := []string{}
|
||||||
|
if o.limit > 0 {
|
||||||
|
where := "last"
|
||||||
|
if o.cfg.FromStart {
|
||||||
|
where = "first"
|
||||||
|
}
|
||||||
|
flags = append(flags, fmt.Sprintf("%s %d", where, o.limit))
|
||||||
|
}
|
||||||
|
if o.cfg.CaseSensitive {
|
||||||
|
flags = append(flags, "case sensitive")
|
||||||
|
}
|
||||||
|
if o.cfg.Regex {
|
||||||
|
flags = append(flags, "regex")
|
||||||
|
}
|
||||||
|
return b.String() + flagSuffix(flags, o.cfg.PreserveExt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// insertOp implements [KindInsert].
|
||||||
|
type insertOp struct {
|
||||||
|
cfg Config
|
||||||
|
at int
|
||||||
|
toLast bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newInsert(cfg Config) (Operation, error) {
|
||||||
|
at, toLast, err := parseIndex("insert index", cfg.Index, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &insertOp{cfg: cfg, at: at, toLast: toLast}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *insertOp) Config() Config { return o.cfg }
|
||||||
|
|
||||||
|
func (o *insertOp) Apply(name string, _ int) string {
|
||||||
|
stem, ext := splitExt(name, o.cfg.PreserveExt)
|
||||||
|
r := []rune(stem)
|
||||||
|
at := o.at
|
||||||
|
if o.toLast || at > len(r) {
|
||||||
|
at = len(r)
|
||||||
|
}
|
||||||
|
if at < 0 {
|
||||||
|
at = 0
|
||||||
|
}
|
||||||
|
return string(r[:at]) + o.cfg.Text + string(r[at:]) + ext
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *insertOp) Summary() string {
|
||||||
|
where := strconv.Itoa(o.at)
|
||||||
|
if o.toLast {
|
||||||
|
where = "the end"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Insert %q at %s", o.cfg.Text, where) + flagSuffix(nil, o.cfg.PreserveExt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// incrementOp implements [KindIncrement]. The counter is appended to the name,
|
||||||
|
// preceded by the configured prefix.
|
||||||
|
type incrementOp struct {
|
||||||
|
cfg Config
|
||||||
|
start int
|
||||||
|
step int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newIncrement(cfg Config) (Operation, error) {
|
||||||
|
start, err := parseInt("number to start", cfg.Start, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
step, err := parseInt("incremental step", cfg.Step, 1)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &incrementOp{cfg: cfg, start: start, step: step}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *incrementOp) Config() Config { return o.cfg }
|
||||||
|
|
||||||
|
func (o *incrementOp) Apply(name string, index int) string {
|
||||||
|
stem, ext := splitExt(name, o.cfg.PreserveExt)
|
||||||
|
return stem + o.cfg.Prefix + strconv.Itoa(o.start+index*o.step) + ext
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *incrementOp) Summary() string {
|
||||||
|
return fmt.Sprintf("Append %q + counter from %d step %d", o.cfg.Prefix, o.start, o.step) +
|
||||||
|
flagSuffix(nil, o.cfg.PreserveExt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// caseOp implements [KindLower] and [KindUpper].
|
||||||
|
type caseOp struct {
|
||||||
|
cfg Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *caseOp) Config() Config { return o.cfg }
|
||||||
|
|
||||||
|
func (o *caseOp) Apply(name string, _ int) string {
|
||||||
|
stem, ext := splitExt(name, o.cfg.PreserveExt)
|
||||||
|
if o.cfg.Kind == KindUpper {
|
||||||
|
return strings.ToUpper(stem) + ext
|
||||||
|
}
|
||||||
|
return strings.ToLower(stem) + ext
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *caseOp) Summary() string {
|
||||||
|
return string(o.cfg.Kind) + flagSuffix(nil, o.cfg.PreserveExt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncateOp implements [KindTruncate].
|
||||||
|
type truncateOp struct {
|
||||||
|
cfg Config
|
||||||
|
first int
|
||||||
|
last int
|
||||||
|
firstLast bool
|
||||||
|
lastIsLast bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTruncate(cfg Config) (Operation, error) {
|
||||||
|
first, firstLast, err := parseIndex("first character index", cfg.First, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
last, lastIsLast, err := parseIndex("last character index", cfg.Last, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &truncateOp{cfg: cfg, first: first, last: last, firstLast: firstLast, lastIsLast: lastIsLast}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *truncateOp) Config() Config { return o.cfg }
|
||||||
|
|
||||||
|
func (o *truncateOp) Apply(name string, _ int) string {
|
||||||
|
stem, ext := splitExt(name, o.cfg.PreserveExt)
|
||||||
|
r := []rune(stem)
|
||||||
|
if len(r) == 0 {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
first, last := o.first, o.last
|
||||||
|
if o.firstLast {
|
||||||
|
first = len(r) - 1
|
||||||
|
}
|
||||||
|
if o.lastIsLast {
|
||||||
|
last = len(r) - 1
|
||||||
|
}
|
||||||
|
first = clamp(first, 0, len(r)-1)
|
||||||
|
last = clamp(last, 0, len(r)-1)
|
||||||
|
if first > last {
|
||||||
|
first, last = last, first
|
||||||
|
}
|
||||||
|
if o.cfg.KeepBetween {
|
||||||
|
return string(r[first:last+1]) + ext
|
||||||
|
}
|
||||||
|
return string(r[:first]) + string(r[last+1:]) + ext
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *truncateOp) Summary() string {
|
||||||
|
verb := "Remove"
|
||||||
|
if o.cfg.KeepBetween {
|
||||||
|
verb = "Keep"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s characters %s..%s", verb, indexLabel(o.first, o.firstLast), indexLabel(o.last, o.lastIsLast)) +
|
||||||
|
flagSuffix(nil, o.cfg.PreserveExt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// indexLabel renders a character index for display.
|
||||||
|
func indexLabel(n int, toLast bool) string {
|
||||||
|
if toLast {
|
||||||
|
return ToLast
|
||||||
|
}
|
||||||
|
return strconv.Itoa(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clamp confines n to the inclusive range [lo, hi].
|
||||||
|
func clamp(n, lo, hi int) int {
|
||||||
|
if n < lo {
|
||||||
|
return lo
|
||||||
|
}
|
||||||
|
if n > hi {
|
||||||
|
return hi
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// flagSuffix renders the parenthesised flag list shown after a summary.
|
||||||
|
func flagSuffix(flags []string, preserveExt bool) string {
|
||||||
|
if preserveExt {
|
||||||
|
flags = append(flags, "keep extension")
|
||||||
|
}
|
||||||
|
if len(flags) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return " (" + strings.Join(flags, ", ") + ")"
|
||||||
|
}
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
package ops_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"aehenamer/internal/ops"
|
||||||
|
)
|
||||||
|
|
||||||
|
// apply builds the operation and applies it to a single name.
|
||||||
|
func apply(t *testing.T, cfg ops.Config, name string, index int) string {
|
||||||
|
t.Helper()
|
||||||
|
op, err := ops.New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ops.New(%+v): %v", cfg, err)
|
||||||
|
}
|
||||||
|
return op.Apply(name, index)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReplace(t *testing.T) {
|
||||||
|
base := func() ops.Config {
|
||||||
|
cfg := ops.DefaultConfig(ops.KindReplace)
|
||||||
|
cfg.Target = "foo"
|
||||||
|
cfg.Replacement = "baz"
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cfg func(c *ops.Config)
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "all occurrences", in: "foo bar foo.txt", want: "baz bar baz.txt"},
|
||||||
|
{
|
||||||
|
name: "limit from start",
|
||||||
|
cfg: func(c *ops.Config) { c.Limit = "1" },
|
||||||
|
in: "foo bar foo.txt", want: "baz bar foo.txt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "limit from end",
|
||||||
|
cfg: func(c *ops.Config) { c.Limit = "1"; c.FromStart = false },
|
||||||
|
in: "foo bar foo.txt", want: "foo bar baz.txt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "limit larger than matches",
|
||||||
|
cfg: func(c *ops.Config) { c.Limit = "9" },
|
||||||
|
in: "foo.txt", want: "baz.txt",
|
||||||
|
},
|
||||||
|
{name: "case insensitive by default", in: "FoO.txt", want: "baz.txt"},
|
||||||
|
{
|
||||||
|
name: "case sensitive",
|
||||||
|
cfg: func(c *ops.Config) { c.CaseSensitive = true },
|
||||||
|
in: "FoO.txt", want: "FoO.txt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "extension preserved",
|
||||||
|
cfg: func(c *ops.Config) { c.Target = "txt"; c.Replacement = "md" },
|
||||||
|
in: "txt.txt", want: "md.txt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "extension included when not preserved",
|
||||||
|
cfg: func(c *ops.Config) { c.Target = "txt"; c.Replacement = "md"; c.PreserveExt = false },
|
||||||
|
in: "txt.txt", want: "md.md",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "regex with capture group",
|
||||||
|
cfg: func(c *ops.Config) { c.Regex = true; c.Target = `(\d+)`; c.Replacement = "[$1]" },
|
||||||
|
in: "episode 12.mkv", want: "episode [12].mkv",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "regex is quoted in literal mode",
|
||||||
|
cfg: func(c *ops.Config) { c.Target = `a.c`; c.Replacement = "x" },
|
||||||
|
in: "abc a.c.txt", want: "abc x.txt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty target is a no-op",
|
||||||
|
cfg: func(c *ops.Config) { c.Target = "" },
|
||||||
|
in: "foo.txt", want: "foo.txt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no match leaves the name alone",
|
||||||
|
cfg: func(c *ops.Config) { c.Target = "zzz" },
|
||||||
|
in: "foo.txt", want: "foo.txt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dotfile has no extension to preserve",
|
||||||
|
cfg: func(c *ops.Config) { c.Target = "bashrc"; c.Replacement = "zshrc" },
|
||||||
|
in: ".bashrc", want: ".zshrc",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cfg := base()
|
||||||
|
if tt.cfg != nil {
|
||||||
|
tt.cfg(&cfg)
|
||||||
|
}
|
||||||
|
if got := apply(t, cfg, tt.in, 0); got != tt.want {
|
||||||
|
t.Errorf("Apply(%q) = %q, want %q", tt.in, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemove(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cfg func(c *ops.Config)
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "literal text",
|
||||||
|
cfg: func(c *ops.Config) { c.Target = " - copy" },
|
||||||
|
in: "report - copy.pdf", want: "report.pdf",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "regular expression",
|
||||||
|
cfg: func(c *ops.Config) { c.Target = `\s*\(\d+\)`; c.Regex = true },
|
||||||
|
in: "photo (1).jpg", want: "photo.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "last match only",
|
||||||
|
cfg: func(c *ops.Config) { c.Target = "x"; c.Limit = "1"; c.FromStart = false },
|
||||||
|
in: "xaxbx.txt", want: "xaxb.txt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "extension untouched",
|
||||||
|
cfg: func(c *ops.Config) { c.Target = "a" },
|
||||||
|
in: "banana.aac", want: "bnn.aac",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cfg := ops.DefaultConfig(ops.KindRemove)
|
||||||
|
tt.cfg(&cfg)
|
||||||
|
if got := apply(t, cfg, tt.in, 0); got != tt.want {
|
||||||
|
t.Errorf("Apply(%q) = %q, want %q", tt.in, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInsert(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
text string
|
||||||
|
index string
|
||||||
|
ext bool
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "at the beginning", text: "2024-", index: "0", ext: true, in: "trip.jpg", want: "2024-trip.jpg"},
|
||||||
|
{name: "in the middle", text: "-", index: "2", ext: true, in: "abcd.jpg", want: "ab-cd.jpg"},
|
||||||
|
{name: "to-last", text: "-end", index: ops.ToLast, ext: true, in: "abcd.jpg", want: "abcd-end.jpg"},
|
||||||
|
{name: "to-last without preserving extension", text: "!", index: ops.ToLast, ext: false, in: "abcd.jpg", want: "abcd.jpg!"},
|
||||||
|
{name: "index beyond the name is clamped", text: "!", index: "99", ext: true, in: "ab.jpg", want: "ab!.jpg"},
|
||||||
|
{name: "negative index is clamped", text: "!", index: "-3", ext: true, in: "ab.jpg", want: "!ab.jpg"},
|
||||||
|
{name: "empty index defaults to the front", text: "!", index: "", ext: true, in: "ab.jpg", want: "!ab.jpg"},
|
||||||
|
{name: "counts characters not bytes", text: "-", index: "2", ext: true, in: "héllo.txt", want: "hé-llo.txt"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cfg := ops.DefaultConfig(ops.KindInsert)
|
||||||
|
cfg.Text, cfg.Index, cfg.PreserveExt = tt.text, tt.index, tt.ext
|
||||||
|
if got := apply(t, cfg, tt.in, 0); got != tt.want {
|
||||||
|
t.Errorf("Apply(%q) = %q, want %q", tt.in, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIncrement(t *testing.T) {
|
||||||
|
cfg := ops.DefaultConfig(ops.KindIncrement)
|
||||||
|
cfg.Prefix = "_"
|
||||||
|
cfg.Start = "1"
|
||||||
|
cfg.Step = "2"
|
||||||
|
|
||||||
|
names := []string{"a.txt", "b.txt", "c.txt"}
|
||||||
|
op, err := ops.New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ops.New: %v", err)
|
||||||
|
}
|
||||||
|
got := ops.Preview(names, []ops.Operation{op})
|
||||||
|
want := []string{"a_1.txt", "b_3.txt", "c_5.txt"}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Errorf("file %d = %q, want %q", i, got[i], want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("defaults", func(t *testing.T) {
|
||||||
|
cfg := ops.DefaultConfig(ops.KindIncrement)
|
||||||
|
if got := apply(t, cfg, "a.txt", 3); got != "a3.txt" {
|
||||||
|
t.Errorf("got %q, want %q", got, "a3.txt")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCase(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
kind ops.Kind
|
||||||
|
ext bool
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "lower keeps extension", kind: ops.KindLower, ext: true, in: "MyFile.TXT", want: "myfile.TXT"},
|
||||||
|
{name: "lower whole name", kind: ops.KindLower, ext: false, in: "MyFile.TXT", want: "myfile.txt"},
|
||||||
|
{name: "upper keeps extension", kind: ops.KindUpper, ext: true, in: "MyFile.txt", want: "MYFILE.txt"},
|
||||||
|
{name: "upper whole name", kind: ops.KindUpper, ext: false, in: "MyFile.txt", want: "MYFILE.TXT"},
|
||||||
|
{name: "dotfile", kind: ops.KindUpper, ext: true, in: ".bashrc", want: ".BASHRC"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cfg := ops.DefaultConfig(tt.kind)
|
||||||
|
cfg.PreserveExt = tt.ext
|
||||||
|
if got := apply(t, cfg, tt.in, 0); got != tt.want {
|
||||||
|
t.Errorf("Apply(%q) = %q, want %q", tt.in, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTruncate(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
first, last string
|
||||||
|
keepBetween bool
|
||||||
|
ext bool
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "remove range", first: "0", last: "2", ext: true, in: "abcdef.txt", want: "def.txt"},
|
||||||
|
{name: "keep range", first: "0", last: "2", keepBetween: true, ext: true, in: "abcdef.txt", want: "abc.txt"},
|
||||||
|
{name: "remove to the end", first: "2", last: ops.ToLast, ext: true, in: "abcdef.txt", want: "ab.txt"},
|
||||||
|
{name: "keep to the end", first: "2", last: ops.ToLast, keepBetween: true, ext: true, in: "abcdef.txt", want: "cdef.txt"},
|
||||||
|
{name: "single character", first: "1", last: "1", ext: true, in: "abc.txt", want: "ac.txt"},
|
||||||
|
{name: "swapped indexes", first: "4", last: "1", ext: true, in: "abcdef.txt", want: "af.txt"},
|
||||||
|
{name: "indexes beyond the name are clamped", first: "0", last: "99", ext: true, in: "abc.txt", want: ".txt"},
|
||||||
|
{name: "extension included", first: "0", last: "0", ext: false, in: "abc.txt", want: "bc.txt"},
|
||||||
|
{name: "counts characters not bytes", first: "0", last: "1", ext: true, in: "héllo.txt", want: "llo.txt"},
|
||||||
|
{name: "dotfile is truncated as a whole", first: "0", last: "0", ext: true, in: ".txt", want: "txt"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cfg := ops.DefaultConfig(ops.KindTruncate)
|
||||||
|
cfg.First, cfg.Last = tt.first, tt.last
|
||||||
|
cfg.KeepBetween, cfg.PreserveExt = tt.keepBetween, tt.ext
|
||||||
|
if got := apply(t, cfg, tt.in, 0); got != tt.want {
|
||||||
|
t.Errorf("Apply(%q) = %q, want %q", tt.in, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewRejectsBadInput(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cfg ops.Config
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "unknown kind",
|
||||||
|
cfg: ops.Config{Kind: "Frobnicate"},
|
||||||
|
want: "unknown operation kind",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid regular expression",
|
||||||
|
cfg: ops.Config{Kind: ops.KindReplace, Target: "([a-z", Regex: true},
|
||||||
|
want: "invalid regular expression",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "limit is not a number",
|
||||||
|
cfg: ops.Config{Kind: ops.KindReplace, Target: "a", Limit: "many"},
|
||||||
|
want: "limit",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "negative limit",
|
||||||
|
cfg: ops.Config{Kind: ops.KindRemove, Target: "a", Limit: "-2"},
|
||||||
|
want: "must not be negative",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "insert index is not a number",
|
||||||
|
cfg: ops.Config{Kind: ops.KindInsert, Index: "somewhere"},
|
||||||
|
want: "insert index",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "start is not a number",
|
||||||
|
cfg: ops.Config{Kind: ops.KindIncrement, Start: "one"},
|
||||||
|
want: "number to start",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "step is not a number",
|
||||||
|
cfg: ops.Config{Kind: ops.KindIncrement, Step: "two"},
|
||||||
|
want: "incremental step",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "truncate index is not a number",
|
||||||
|
cfg: ops.Config{Kind: ops.KindTruncate, First: "x"},
|
||||||
|
want: "first character index",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
op, err := ops.New(tt.cfg)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("ops.New(%+v) = %v, want an error", tt.cfg, op)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), tt.want) {
|
||||||
|
t.Errorf("error %q does not mention %q", err, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreviewAppliesOperationsInOrder(t *testing.T) {
|
||||||
|
remove := ops.DefaultConfig(ops.KindRemove)
|
||||||
|
remove.Target = "IMG_"
|
||||||
|
lower := ops.DefaultConfig(ops.KindUpper)
|
||||||
|
increment := ops.DefaultConfig(ops.KindIncrement)
|
||||||
|
increment.Prefix = "-"
|
||||||
|
increment.Start = "10"
|
||||||
|
|
||||||
|
list := make([]ops.Operation, 0, 3)
|
||||||
|
for _, cfg := range []ops.Config{remove, lower, increment} {
|
||||||
|
op, err := ops.New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ops.New(%v): %v", cfg.Kind, err)
|
||||||
|
}
|
||||||
|
list = append(list, op)
|
||||||
|
}
|
||||||
|
|
||||||
|
names := []string{"IMG_one.jpg", "IMG_two.jpg"}
|
||||||
|
got := ops.Preview(names, list)
|
||||||
|
want := []string{"ONE-10.jpg", "TWO-11.jpg"}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Errorf("file %d = %q, want %q", i, got[i], want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if names[0] != "IMG_one.jpg" {
|
||||||
|
t.Errorf("Preview modified its input: %q", names[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreviewWithoutOperations(t *testing.T) {
|
||||||
|
names := []string{"a.txt", "b.txt"}
|
||||||
|
got := ops.Preview(names, nil)
|
||||||
|
for i := range names {
|
||||||
|
if got[i] != names[i] {
|
||||||
|
t.Errorf("file %d = %q, want %q", i, got[i], names[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSummaryMentionsTheSettings(t *testing.T) {
|
||||||
|
cfg := ops.DefaultConfig(ops.KindReplace)
|
||||||
|
cfg.Target = "a"
|
||||||
|
cfg.Replacement = "b"
|
||||||
|
cfg.Limit = "2"
|
||||||
|
cfg.Regex = true
|
||||||
|
|
||||||
|
op, err := ops.New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ops.New: %v", err)
|
||||||
|
}
|
||||||
|
summary := op.Summary()
|
||||||
|
for _, want := range []string{`"a"`, `"b"`, "first 2", "regex", "keep extension"} {
|
||||||
|
if !strings.Contains(summary, want) {
|
||||||
|
t.Errorf("summary %q does not mention %q", summary, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if op.Config().Target != "a" {
|
||||||
|
t.Errorf("Config() lost the entered values: %+v", op.Config())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultConfig(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
kind ops.Kind
|
||||||
|
want ops.Config
|
||||||
|
}{
|
||||||
|
{kind: ops.KindIncrement, want: ops.Config{Kind: ops.KindIncrement, Start: "0", Step: "1", FromStart: true, PreserveExt: true}},
|
||||||
|
{kind: ops.KindTruncate, want: ops.Config{Kind: ops.KindTruncate, First: "0", Last: "0", FromStart: true, PreserveExt: true}},
|
||||||
|
{kind: ops.KindInsert, want: ops.Config{Kind: ops.KindInsert, Index: "0", FromStart: true, PreserveExt: true}},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(string(tt.kind), func(t *testing.T) {
|
||||||
|
if got := ops.DefaultConfig(tt.kind); got != tt.want {
|
||||||
|
t.Errorf("DefaultConfig(%q) = %+v, want %+v", tt.kind, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// Command aehenamer renames the files of a folder in bulk, from a terminal
|
||||||
|
// interface: pick a folder, stack up rename operations, preview the result and
|
||||||
|
// apply it.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// aehenamer [folder]
|
||||||
|
//
|
||||||
|
// The folder defaults to the current working directory. Only the files
|
||||||
|
// directly inside it are listed; subdirectories are never entered.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"aehenamer/internal/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
flag.Usage = func() {
|
||||||
|
fmt.Fprintf(flag.CommandLine.Output(), "usage: %s [folder]\n\nBulk file renamer. The folder defaults to the current directory.\n", filepath.Base(os.Args[0]))
|
||||||
|
}
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
dir := "."
|
||||||
|
if flag.NArg() > 0 {
|
||||||
|
dir = flag.Arg(0)
|
||||||
|
}
|
||||||
|
if abs, err := filepath.Abs(dir); err == nil {
|
||||||
|
dir = abs
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
if err := ui.New(dir).Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||||
|
fmt.Fprintf(os.Stderr, "aehenamer: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user