package ui import ( "errors" "fmt" "time" "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/driver/desktop" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" "github.com/pkg/sftp" "golang.org/x/crypto/ssh" "gitea.apointless.space/bsncubed/haul/internal/vfs" "gitea.apointless.space/bsncubed/haul/internal/xfer" ) // session is one connected host: a window with the local disk on the left, the // remote host on the right, and the transfer controls between them. It owns the // SSH and SFTP connections and closes them when the window closes. type session struct { win fyne.Window client *ssh.Client sf *sftp.Client engine *xfer.Engine local *pane remote *pane active *pane uploadBtn *widget.Button downloadBtn *widget.Button stopBtn *widget.Button progBar *widget.ProgressBar progLabel *widget.Label busy bool // onClose brings the connection window back when this session ends. onClose func() } // newSession builds and shows the browser window for an established connection. func newSession(app fyne.App, name string, client *ssh.Client, sf *sftp.Client, onClose func()) *session { s := &session{ win: app.NewWindow("haul — " + name), client: client, sf: sf, engine: xfer.New(sf), onClose: onClose, } s.local = newPane(s.win, vfs.Local{}, "LOCAL", s.showError, s.setActive) s.remote = newPane(s.win, vfs.Remote{C: sf}, "REMOTE — "+name, s.showError, s.setActive) s.active = s.local // The arrows in the labels carry the direction; an icon next to them only // competes with it, since up/down icons say nothing about left/right panes. s.uploadBtn = widget.NewButton("Upload →", func() { s.transfer(true) }) s.downloadBtn = widget.NewButton("← Download", func() { s.transfer(false) }) // Disconnect goes through the same guard as the window's close button, so a // running transfer is never dropped without asking. disconnect := widget.NewButtonWithIcon("Disconnect", theme.LogoutIcon(), s.closeRequested) s.progBar = widget.NewProgressBar() // An empty bar sitting at 0% reads as "stuck", so it only appears once // there's a transfer to report on, and stays afterwards showing the result. s.progBar.Hide() s.progLabel = widget.NewLabel("Ready") s.stopBtn = widget.NewButtonWithIcon("Stop", theme.CancelIcon(), func() { s.engine.Cancel() }) s.stopBtn.Disable() controls := container.NewHBox(s.uploadBtn, s.downloadBtn) transferBar := container.NewBorder(nil, nil, controls, disconnect) progressBar := container.NewBorder(nil, nil, nil, s.stopBtn, container.NewBorder(nil, nil, nil, s.progLabel, s.progBar)) split := container.NewHSplit(s.local.root, s.remote.root) split.SetOffset(0.5) s.win.SetContent(container.NewBorder( nil, container.NewVBox(widget.NewSeparator(), transferBar, progressBar), nil, nil, split, )) s.win.Resize(fyne.NewSize(1180, 720)) s.win.SetCloseIntercept(s.closeRequested) s.registerShortcuts() s.local.navigate(s.local.fs.Home()) s.remote.navigate(s.remote.fs.Home()) s.win.Show() return s } func (s *session) setActive(p *pane) { s.active = p } func (s *session) other(p *pane) *pane { if p == s.local { return s.remote } return s.local } func (s *session) registerShortcuts() { add := func(key fyne.KeyName, fn func()) { s.win.Canvas().AddShortcut( &desktop.CustomShortcut{KeyName: key, Modifier: fyne.KeyModifierControl}, func(fyne.Shortcut) { fn() }, ) } add(fyne.KeyR, func() { s.active.reload() }) add(fyne.KeyUp, func() { s.active.up() }) // Ctrl+T sends the active pane's selection to the other side, so the same // key uploads or downloads depending on which pane you were last in. add(fyne.KeyT, func() { s.transfer(s.active == s.local) }) } // --- transfers --- // transfer copies the source pane's marked entries (or its highlighted row) // into the other pane's current directory. func (s *session) transfer(push bool) { if s.busy { return } src, dst := s.remote, s.local if push { src, dst = s.local, s.remote } sources := src.sources() if len(sources) == 0 { s.showError(fmt.Errorf("mark or select something in %s first", src.title)) return } destDir := dst.cwd s.setBusy(true) s.progBar.SetValue(0) go func() { // applyAll lives in this goroutine alone, so the "apply to all" answer // needs no synchronisation with the UI. var applyAll *xfer.Conflict s.engine.OnProgress = s.progressSink() s.engine.OnConflict = func(name string) xfer.Conflict { if applyAll != nil { return *applyAll } reply := make(chan conflictAnswer, 1) fyne.Do(func() { askConflict(s.win, name, reply) }) a := <-reply if a.all { c := a.choice applyAll = &c } return a.choice } var err error if push { err = s.engine.Push(sources, destDir) } else { err = s.engine.Pull(sources, destDir) } fyne.Do(func() { s.setBusy(false) src.marked = map[string]bool{} src.table.Refresh() src.updateFooter() dst.reload() // show what just landed switch { case err == nil: s.progBar.SetValue(1) s.progLabel.SetText(fmt.Sprintf("Transferred %d item(s)", len(sources))) case errors.Is(err, xfer.ErrCanceled): s.progLabel.SetText("Transfer stopped — partial files can be resumed by transferring again") default: s.progLabel.SetText("Transfer failed") s.showError(err) } }) }() } // progressSink returns the engine's progress callback, rate-limited before it // reaches the UI thread. The engine reports on every write — tens of thousands // of times for a large file — and repainting at that rate is slower than the // transfer itself. func (s *session) progressSink() xfer.ProgressFunc { // Only the transfer goroutine calls this, so last needs no locking. var last time.Time const minInterval = 60 * time.Millisecond return func(p xfer.Progress) { now := time.Now() done := p.FilesTotal > 0 && p.FilesDone == p.FilesTotal if !done && now.Sub(last) < minInterval { return } last = now fyne.Do(func() { s.paintProgress(p) }) } } func (s *session) paintProgress(p xfer.Progress) { if p.BytesTotal > 0 { s.progBar.SetValue(float64(p.BytesDone) / float64(p.BytesTotal)) } s.progLabel.SetText(fmt.Sprintf("%d/%d files · %s of %s · %s", p.FilesDone, p.FilesTotal, humanBytes(p.BytesDone), humanBytes(p.BytesTotal), p.CurrentName)) } func (s *session) setBusy(busy bool) { s.busy = busy if busy { s.progBar.Show() s.uploadBtn.Disable() s.downloadBtn.Disable() s.stopBtn.Enable() return } s.uploadBtn.Enable() s.downloadBtn.Enable() s.stopBtn.Disable() } // --- lifecycle --- func (s *session) showError(err error) { if err == nil { return } dialog.ShowError(err, s.win) } // closeRequested guards against closing the window out from under a running // transfer, which would kill the connection mid-file. func (s *session) closeRequested() { if !s.busy { s.shutdown() return } confirmDialog("Transfer in progress", "A transfer is still running. Stop it and disconnect?", s.win, func(ok bool) { if !ok { return } s.engine.Cancel() s.shutdown() }) } func (s *session) shutdown() { if s.sf != nil { s.sf.Close() } if s.client != nil { s.client.Close() } s.win.Close() if s.onClose != nil { s.onClose() } }