aboutsummaryrefslogtreecommitdiff
path: root/gui/internal
diff options
context:
space:
mode:
Diffstat (limited to 'gui/internal')
-rw-r--r--gui/internal/model/plan.go10
-rw-r--r--gui/internal/model/plan_test.go19
-rw-r--r--gui/internal/ui/plan.go95
3 files changed, 121 insertions, 3 deletions
diff --git a/gui/internal/model/plan.go b/gui/internal/model/plan.go
index 7496347..4066eae 100644
--- a/gui/internal/model/plan.go
+++ b/gui/internal/model/plan.go
@@ -73,9 +73,10 @@ func Plan(ctx context.Context, sess *engine.Session, d *engine.Dir) (*PlanTab, e
return t, nil
}
-// Close releases the directory's lock. The session outlives the tab, so
-// closing one plan to open another keeps the run - and its claims - going.
-// Closing twice is not an error.
+// Close releases the directory's lock, which Apply also does once the plan
+// is history. The session outlives the tab, so closing one plan to open
+// another keeps the run - and its claims - going. Closing twice is not an
+// error.
func (t *PlanTab) Close() error {
if t.lock == nil {
return nil
@@ -221,6 +222,9 @@ func (t *PlanTab) Apply(ctx context.Context) (*engine.ApplyResult, error) {
// used is free again, while one it did use stays protected for the rest
// of the run (spec §7.4).
t.sess.FinishDirectory()
+ // An applied plan is history, so the directory is free again without
+ // closing the window (GUI design §3).
+ t.Close()
if res != nil {
t.record(res)
}
diff --git a/gui/internal/model/plan_test.go b/gui/internal/model/plan_test.go
index 8b7e32f..bf8f19f 100644
--- a/gui/internal/model/plan_test.go
+++ b/gui/internal/model/plan_test.go
@@ -212,6 +212,25 @@ func TestPlanHoldsTheLock(t *testing.T) {
}
}
+// TestApplyReleasesTheLock: once a plan has been applied it is history, so
+// the directory is free again without closing the window (GUI design §3).
+func TestApplyReleasesTheLock(t *testing.T) {
+ conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n"
+ e, _ := sandboxDir(t, conf, map[string]string{"a.pdf": "one"})
+ tab, _ := planTab(t, e)
+ if _, err := tab.Apply(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ l, err := lock.Acquire(context.Background(), e.Config.LockFile("dl"), false)
+ if err != nil {
+ t.Fatalf("applying did not release the lock: %v", err)
+ }
+ l.Release()
+ if err := tab.Close(); err != nil {
+ t.Errorf("closing an applied tab: %v", err)
+ }
+}
+
// TestPlanRefusesAHeldDirectory: a directory another krino is working in is
// not planned at all, and the message names it.
func TestPlanRefusesAHeldDirectory(t *testing.T) {
diff --git a/gui/internal/ui/plan.go b/gui/internal/ui/plan.go
index 0fa429a..8cbbdfd 100644
--- a/gui/internal/ui/plan.go
+++ b/gui/internal/ui/plan.go
@@ -7,6 +7,7 @@ import (
"fmt"
"strings"
+ "github.com/diamondburned/gotk4/pkg/gdk/v4"
"github.com/diamondburned/gotk4/pkg/pango"
"github.com/diamondburned/gotk4/pkg/gtk/v4"
@@ -34,6 +35,8 @@ type planView struct {
list *gtk.ListBox
details *gtk.TextView
+ menu *gtk.Popover
+ menuRow int
tab *model.PlanTab
cancelOp context.CancelFunc
@@ -130,11 +133,103 @@ func newPlanView(w *Window) *planView {
p.showDetails(row.Index())
}
})
+ // The right button on a row offers the two overrides the terminal
+ // review has on t and d (GUI design §3).
+ p.menu = p.newMenu()
+ click := gtk.NewGestureClick()
+ click.SetButton(3)
+ click.ConnectPressed(func(_ int, x, y float64) { p.onRightClick(x, y) })
+ p.list.AddController(click)
+
p.showPath()
p.setBusy(false)
return p
}
+// newMenu builds the row menu: what to do with a file instead of what the
+// rules decided.
+func (p *planView) newMenu() *gtk.Popover {
+ box := gtk.NewBox(gtk.OrientationVertical, 0)
+ trash := gtk.NewButtonWithLabel("Trash instead")
+ perm := gtk.NewButtonWithLabel("Delete permanently instead...")
+ for _, b := range []*gtk.Button{trash, perm} {
+ b.SetHasFrame(false)
+ b.SetHAlign(gtk.AlignFill)
+ box.Append(b)
+ }
+ pop := gtk.NewPopover()
+ pop.SetChild(box)
+ pop.SetParent(p.list)
+ pop.SetHasArrow(false)
+ trash.ConnectClicked(func() {
+ pop.Popdown()
+ p.replace(plan.Trash)
+ })
+ perm.ConnectClicked(func() {
+ pop.Popdown()
+ p.confirmDeletePermanent()
+ })
+ return pop
+}
+
+// onRightClick opens the menu on the row under the pointer. A plan that has
+// been applied is history and cannot be changed.
+func (p *planView) onRightClick(x, y float64) {
+ if p.tab == nil || p.tab.Applied {
+ return
+ }
+ row := p.list.RowAtY(int(y))
+ if row == nil {
+ return
+ }
+ p.list.SelectRow(row)
+ p.menuRow = row.Index()
+ at := gdk.NewRectangle(int(x), int(y), 1, 1)
+ p.menu.SetPointingTo(&at)
+ p.menu.Popup()
+}
+
+// replace swaps the menu row's steps for the one the user chose.
+func (p *planView) replace(kind plan.Kind) {
+ if p.tab == nil {
+ return
+ }
+ if err := p.tab.Replace(p.menuRow, kind); err != nil {
+ p.w.setStatus("%v", err)
+ return
+ }
+ rel := p.tab.Rows[p.menuRow].Rel
+ p.fillList()
+ p.showDetails(p.menuRow)
+ p.w.setStatus("%s: %s instead", escape(rel), kind)
+}
+
+// confirmDeletePermanent asks before a step nothing can undo, naming the
+// file (GUI design §3).
+func (p *planView) confirmDeletePermanent() {
+ if p.tab == nil || p.menuRow < 0 || p.menuRow >= len(p.tab.Rows) {
+ return
+ }
+ rel := p.tab.Rows[p.menuRow].Rel
+ d := gtk.NewMessageDialog(&p.w.win.Window, gtk.DialogModal|gtk.DialogDestroyWithParent,
+ gtk.MessageWarning, gtk.ButtonsNone)
+ d.SetObjectProperty("text", "Delete "+escape(rel)+" permanently?")
+ d.SetObjectProperty("secondary-text",
+ "It is not moved to the Trash and undo cannot bring it back.")
+ d.AddButton("Cancel", int(gtk.ResponseCancel))
+ del := d.AddButton("Delete permanently", int(gtk.ResponseAccept))
+ if b, ok := del.(*gtk.Button); ok {
+ b.AddCSSClass("destructive-action")
+ }
+ d.ConnectResponse(func(response int) {
+ d.Destroy()
+ if response == int(gtk.ResponseAccept) {
+ p.replace(plan.DeletePermanent)
+ }
+ })
+ d.Show()
+}
+
// showPath writes the chosen directory's path beside the picker.
func (p *planView) showPath() {
if d := p.currentDir(); d != nil {