diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-16 09:28:32 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-16 09:28:32 +0200 |
| commit | 27eb6353030953e91a45b0104bd0567e53622090 (patch) | |
| tree | 3867b5741a9e524fcda33c8a574dcbda61faabf3 | |
| parent | bcfeb773afcc28641ef2fb47749c91fd0767054a (diff) | |
| download | krino-27eb6353030953e91a45b0104bd0567e53622090.tar.gz krino-27eb6353030953e91a45b0104bd0567e53622090.zip | |
gui: row menu for trash or permanent delete; Apply frees the directory
| -rw-r--r-- | gui/internal/model/plan.go | 10 | ||||
| -rw-r--r-- | gui/internal/model/plan_test.go | 19 | ||||
| -rw-r--r-- | gui/internal/ui/plan.go | 95 |
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 { |
