aboutsummaryrefslogtreecommitdiffstats
path: root/ccp.go
diff options
context:
space:
mode:
authorRose Hogenson <rosehogenson@posteo.net>2025-04-08 15:51:46 -0700
committerRose Hogenson <rosehogenson@posteo.net>2025-04-08 15:51:46 -0700
commite2235c24231f8c33fa0b727b4b25fcd62729ccee (patch)
tree7755f64338a2f71bb6091a5123fbbdaeecae17e8 /ccp.go
parentInitial commit (diff)
downloadccp-e2235c24231f8c33fa0b727b4b25fcd62729ccee.tar.zst
Copy recursive
Diffstat (limited to 'ccp.go')
-rw-r--r--ccp.go216
1 files changed, 137 insertions, 79 deletions
diff --git a/ccp.go b/ccp.go
index 2dc59c7..e2cdacf 100644
--- a/ccp.go
+++ b/ccp.go
@@ -1,18 +1,23 @@
package main
import (
- "errors"
"flag"
"fmt"
- "io"
"os"
+ "path/filepath"
+ "strings"
+ "sync/atomic"
"time"
"github.com/charmbracelet/bubbles/progress"
tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "gitlab.com/rhogenson/ccp/internal/cp"
"gitlab.com/rhogenson/deque"
)
+var f = flag.Bool("f", false, "if an existing destination file cannot be opened, remove it and try again")
+
type measurement struct {
t time.Time
i int64
@@ -20,44 +25,98 @@ type measurement struct {
type model struct {
progress progress.Model
+ msgs chan tea.Msg
+
+ srcs []string
+ dst string
max int64
+ current atomic.Int64
measurements deque.Deque[measurement]
-}
-
-func (m *model) Init() tea.Cmd {
- return nil
+ copyingFiles map[string]bool
+ copyingFile string
+ errs []string
}
type (
- progressMsg int
- doneMsg struct{}
+ tickMsg struct{}
+
+ maxMsg int64
+ fileStartMsg string
+ fileDoneMsg struct {
+ name string
+ err error
+ }
+ doneMsg struct{}
)
+func (m *model) listen() tea.Cmd {
+ return func() tea.Msg {
+ return <-m.msgs
+ }
+}
+
+func tick() tea.Cmd {
+ return tea.Tick(10*time.Millisecond, func(time.Time) tea.Msg { return tickMsg{} })
+}
+
+func (m *model) Init() tea.Cmd {
+ return tea.Batch(
+ func() tea.Msg {
+ cp.Copy(m.srcs, m.dst, *f, m)
+ return doneMsg{}
+ },
+ m.listen(),
+ tick())
+}
+
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- m.progress.Width = msg.Width - 4
- return m, nil
- case progressMsg:
- n := int64(msg)
- if m.measurements.Len() > 0 {
- n += m.measurements.At(m.measurements.Len() - 1).i
+ case maxMsg:
+ m.max = int64(msg)
+ return m, m.listen()
+ case fileStartMsg:
+ name := string(msg)
+ m.copyingFiles[name] = true
+ if m.copyingFile == "" {
+ m.copyingFile = name
}
- if m.measurements.Len() > 0 && m.measurements.At(m.measurements.Len()-1).i > n {
- m.measurements.Reset()
+ return m, m.listen()
+ case fileDoneMsg:
+ delete(m.copyingFiles, msg.name)
+ if m.copyingFile == msg.name {
+ m.copyingFile = ""
+ for name := range m.copyingFiles {
+ m.copyingFile = name
+ break
+ }
}
- now := time.Now()
- for m.measurements.Len() > 2 && now.Sub(m.measurements.At(0).t) > 2*time.Minute {
- m.measurements.PopFront()
+ if msg.err != nil {
+ m.errs = append(m.errs, msg.err.Error())
}
- m.measurements.PushBack(measurement{now, n})
- return m, m.progress.SetPercent(float64(n) / float64(m.max))
+ return m, m.listen()
case doneMsg:
return m, tea.Sequence(
- m.progress.SetPercent(1),
+ m.progress.SetPercent(float64(m.current.Load())/float64(m.max)),
tea.Tick(500*time.Millisecond, func(time.Time) tea.Msg { return nil }),
tea.Quit)
+
+ case tickMsg:
+ now := time.Now()
+ for m.measurements.Len() > 1 && now.Sub(m.measurements.At(0).t) > 2*time.Minute {
+ m.measurements.PopFront()
+ }
+ n := m.current.Load()
+ m.measurements.PushBack(measurement{now, n})
+ cmds := []tea.Cmd{tick()}
+ if m.max > 0 {
+ cmds = append(cmds, m.progress.SetPercent(float64(n)/float64(m.max)))
+ }
+ return m, tea.Batch(cmds...)
+
+ case tea.WindowSizeMsg:
+ m.progress.Width = msg.Width - 4
+ return m, nil
// FrameMsg is sent when the progress bar wants to animate itself
case progress.FrameMsg:
progressModel, cmd := m.progress.Update(msg)
@@ -68,9 +127,15 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
+var warningStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")).Render
+
func (m *model) View() string {
+ copying := ""
+ if m.copyingFile != "" {
+ copying = "Copying " + m.copyingFile + "..."
+ }
etaStr := "calculating..."
- if m.measurements.Len() > 1 {
+ if m.max > 0 && m.measurements.Len() > 1 {
first := m.measurements.At(0)
last := m.measurements.At(m.measurements.Len() - 1)
deltaT := last.t.Sub(first.t)
@@ -80,71 +145,64 @@ func (m *model) View() string {
}
}
return "\n" +
+ " " + copying + "\n" +
" " + m.progress.View() + "\n" +
- " " + "ETA: " + etaStr + "\n"
+ " " + "ETA: " + etaStr + "\n\n" +
+ warningStyle(strings.Join(m.errs, "\n")) + "\n"
}
-func copy(out, in *os.File, progress func(int64)) error {
- for {
- n, err := io.CopyN(out, in, 1024*1024)
- if n > 0 {
- progress(n)
- }
- if err != nil {
- if err == io.EOF {
- break
- }
- return err
- }
- }
- if err := out.Close(); err != nil {
- return err
- }
- return nil
+func (m *model) Max(n int64) {
+ m.msgs <- maxMsg(n)
}
-func run() error {
- args := flag.Args()
- if len(args) != 2 {
- return errors.New("usage error")
- }
- in, err := os.Open(args[0])
- if err != nil {
- return err
- }
- defer in.Close()
- stat, err := in.Stat()
- if err != nil {
- return err
- }
- out, err := os.OpenFile(args[1], os.O_WRONLY|os.O_CREATE|os.O_TRUNC, stat.Mode().Perm())
- if err != nil {
- return err
- }
- defer out.Close()
- p := tea.NewProgram(&model{
- progress: progress.New(progress.WithDefaultGradient(), progress.WithoutPercentage()),
- max: stat.Size() + 1,
- }, tea.WithInput(nil), tea.WithOutput(os.Stderr))
- var copyErr error
- go func() {
- if copyErr = copy(out, in, func(n int64) { p.Send(progressMsg(n)) }); copyErr != nil {
- p.Send(tea.QuitMsg{})
- return
- }
- p.Send(doneMsg{})
- }()
- _, teaErr := p.Run()
- if copyErr != nil {
- return copyErr
- }
- return teaErr
+func (m *model) Progress(n int64) {
+ m.current.Add(n)
+}
+
+func (m *model) FileStart(name string) {
+ m.msgs <- fileStartMsg(name)
+}
+
+func (m *model) FileDone(name string, err error) {
+ m.msgs <- fileDoneMsg{name, err}
}
func main() {
+ flag.Usage = func() {
+ fmt.Fprintf(os.Stderr, `Usage: ccp [OPTION]... SOURCE DEST
+ or: ccp [OPTION]... SOURCE... DIRECTORY
+
+Copy SOURCE to DEST, or multiple SOURCE(s) to DIRECTORY.
+
+`)
+ flag.PrintDefaults()
+ }
flag.Parse()
- if err := run(); err != nil {
+ args := flag.Args()
+ if len(args) < 2 {
+ fmt.Fprintln(os.Stderr, "usage error")
+ os.Exit(2)
+ }
+ srcs, dst := args[:len(args)-1], args[len(args)-1]
+ if len(srcs) == 1 {
+ if stat, err := os.Stat(dst); err == nil && stat.IsDir() {
+ dst = filepath.Join(dst, filepath.Base(srcs[0]))
+ }
+ }
+ m := &model{
+ progress: progress.New(progress.WithDefaultGradient(), progress.WithoutPercentage()),
+ msgs: make(chan tea.Msg),
+ copyingFiles: make(map[string]bool),
+
+ srcs: srcs,
+ dst: dst,
+ }
+ if _, err := tea.NewProgram(m, tea.WithInput(nil), tea.WithOutput(os.Stderr)).Run(); err != nil {
fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ if len(m.errs) > 0 {
+ os.Exit(1)
}
}