aboutsummaryrefslogtreecommitdiff
path: root/gui/internal/model/plan.go
blob: 6d3e857d440ba2f7813f7dea5aac0e15886c93ba (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
// SPDX-License-Identifier: GPL-3.0-or-later

// Package model is krino-gui's state and operations: what each tab shows,
// and what a click does. It holds no GTK, so it can be tested the way the
// engine's own packages are (GUI design §1.2).
package model

import (
	"context"
	"fmt"
	"sort"

	"krino/internal/engine"
	"krino/internal/lock"
	"krino/internal/plan"
)

// Row is one line of the Plan tab: a file krino would act on, or one it
// could not decide about.
type Row struct {
	Rel      string // the file, relative to the directory's root
	Steps    []plan.Step
	Rule     string // the rule that matched first, for the Rule column
	Warnings []string
	// Selected is the checkbox. A row that cannot be applied - every step
	// skipped, or nothing but warnings - is never selected and has no box.
	Selected bool
	Actable  bool
	// Outcome is what applying did to this file: "", "done", "failed: ...",
	// "declined".
	Outcome string
}

// PlanTab is the state of one directory's plan.
type PlanTab struct {
	Dir      *engine.Dir
	Rows     []Row
	Counts   Counts
	Warnings []string // directory-level
	Applied  bool     // this plan has been applied and is now history

	sess *engine.Session
	dp   *engine.DirPlan
	lock *lock.Lock
}

// Counts is the plan's summary line.
type Counts struct {
	Scanned, Acting, Excluded, Skipped, Unmatched, Warned int
}

// Plan locks d and plans it, returning the tab to show. The lock is held
// until Close, so what the window shows stays the truth while the user
// chooses, and no other krino moves the files under them (GUI design §3).
// A directory another run is already working in is not planned at all: the
// error is lock.ErrHeld, naming the directory.
//
// Each plan gets its own session, so applying it is one run of krino with
// its own run id in the log - the same shape a command line invocation
// writes. A window is not a run: one that sorted a directory and later
// undid something must not log both under one id, which would make the run
// the undo of itself.
func Plan(ctx context.Context, e *engine.Engine, d *engine.Dir) (*PlanTab, error) {
	sess, err := e.NewSession(false)
	if err != nil {
		return nil, err
	}
	l, err := sess.Lock(ctx, d, false)
	if err != nil {
		sess.Close()
		return nil, fmt.Errorf("%s: %w", d.Name, err)
	}
	dp, err := sess.Plan(ctx, d)
	if err != nil {
		l.Release()
		sess.Close()
		return nil, err
	}
	t := &PlanTab{Dir: d, sess: sess, dp: dp, lock: l}
	t.fill()
	return t, nil
}

// Run is the run id this plan was applied under, "" until Apply.
func (t *PlanTab) Run() string { return t.sess.Run() }

// Close releases the directory's lock and ends the run, which Apply also
// does once the plan is history. Closing twice is not an error.
func (t *PlanTab) Close() error {
	if t.lock == nil {
		return nil
	}
	l := t.lock
	t.lock = nil
	err := l.Release()
	if cerr := t.sess.Close(); err == nil {
		err = cerr
	}
	return err
}

// fill turns the engine's plan into rows and counts.
func (t *PlanTab) fill() {
	r := t.dp.Result
	t.Warnings = append([]string(nil), r.Warnings...)
	warnings := map[string][]string{}
	for _, fms := range [][]engine.FileMatch{r.Matched, r.Unmatched} {
		for _, fm := range fms {
			if len(fm.Warnings) > 0 {
				warnings[fm.File.Rel] = fm.Warnings
			}
		}
	}
	excluded := 0
	for _, fm := range r.Matched {
		if fm.Excluded != "" {
			excluded++
		}
	}
	t.Rows = nil
	for _, c := range t.dp.Chains {
		row := Row{Rel: c.File.Rel, Steps: c.Steps, Warnings: warnings[c.File.Rel]}
		for _, s := range c.Steps {
			if s.Skip == "" {
				row.Actable = true
			}
			if row.Rule == "" {
				row.Rule = s.Rule
			}
		}
		row.Selected = row.Actable
		if len(c.Steps) > 0 {
			t.Rows = append(t.Rows, row)
		}
		delete(warnings, c.File.Rel)
	}
	// Files no rule acted on that still raised a warning: krino could not
	// decide about them, and they are shown but never selectable.
	var rest []string
	for rel := range warnings {
		rest = append(rest, rel)
	}
	sort.Strings(rest)
	for _, rel := range rest {
		t.Rows = append(t.Rows, Row{Rel: rel, Warnings: warnings[rel]})
	}
	t.Counts = Counts{
		Scanned:   len(r.Matched) + len(r.Unmatched) + len(r.Skipped),
		Excluded:  excluded,
		Skipped:   len(r.Skipped),
		Unmatched: len(r.Unmatched),
	}
	for _, row := range t.Rows {
		if row.Actable {
			t.Counts.Acting++
		}
		if len(row.Warnings) > 0 {
			t.Counts.Warned++
		}
	}
}

// SelectAll selects every row that can be applied; SelectNone clears them.
func (t *PlanTab) SelectAll()  { t.setAll(true) }
func (t *PlanTab) SelectNone() { t.setAll(false) }

func (t *PlanTab) setAll(on bool) {
	for i := range t.Rows {
		t.Rows[i].Selected = on && t.Rows[i].Actable
	}
}

// Toggle flips row i's checkbox; a row that cannot be applied stays off.
func (t *PlanTab) Toggle(i int) {
	if i < 0 || i >= len(t.Rows) || !t.Rows[i].Actable {
		return
	}
	t.Rows[i].Selected = !t.Rows[i].Selected
}

// SelectedCount is how many files Apply would act on.
func (t *PlanTab) SelectedCount() int {
	n := 0
	for _, r := range t.Rows {
		if r.Selected {
			n++
		}
	}
	return n
}

// Replace swaps a row's planned steps for the one step the user chose
// instead - "Trash instead", "Delete permanently instead" - as the terminal
// review's t and d keys do, and selects it.
func (t *PlanTab) Replace(i int, kind plan.Kind) error {
	if i < 0 || i >= len(t.Rows) {
		return fmt.Errorf("model: no row %d", i)
	}
	if kind != plan.Trash && kind != plan.DeletePermanent {
		return fmt.Errorf("model: %s is not a replacement action", kind)
	}
	rel := t.Rows[i].Rel
	for j, c := range t.dp.Chains {
		if c.File.Rel != rel {
			continue
		}
		t.dp.Chains[j].Steps = []plan.Step{{
			Kind:   kind,
			Rule:   "(review)",
			Src:    c.File.Path,
			Reason: "chosen in review",
		}}
		t.Rows[i].Steps = t.dp.Chains[j].Steps
		t.Rows[i].Rule = "(review)"
		t.Rows[i].Actable = true
		t.Rows[i].Selected = true
		return nil
	}
	return fmt.Errorf("model: %s is not in this plan", rel)
}

// ReplaceSelected swaps the steps of every checked file for the one action
// chosen - "Trash the checked files", "Delete them permanently" - and
// reports how many were changed. Nothing happens on disk: like every other
// review decision, it changes the plan, and Apply carries it out (his
// request, 2026-09-17).
func (t *PlanTab) ReplaceSelected(kind plan.Kind) (int, error) {
	n := 0
	for i, r := range t.Rows {
		if !r.Selected {
			continue
		}
		if err := t.Replace(i, kind); err != nil {
			return n, err
		}
		n++
	}
	return n, nil
}

// Apply acts on the selected files and logs the rest as declined, exactly
// as choosing per file in the terminal does. Each row then carries its
// outcome.
func (t *PlanTab) Apply(ctx context.Context) (*engine.ApplyResult, error) {
	approved := map[string]bool{}
	for _, r := range t.Rows {
		if r.Selected {
			approved[r.Rel] = true
		}
	}
	res, err := t.sess.Apply(ctx, t.dp, approved)
	t.Applied = true
	// An applied plan is history: the run is over and the directory free
	// again, without closing the window (GUI design §3).
	t.Close()
	if res != nil {
		t.record(res)
	}
	return res, err
}

// record writes each file's outcome onto its row.
func (t *PlanTab) record(res *engine.ApplyResult) {
	byRel := map[string]string{}
	for _, fr := range res.Files {
		outcome := "done"
		for _, sr := range fr.Steps {
			switch sr.Status {
			case "failed":
				outcome = "failed: " + sr.Detail
			case "declined":
				outcome = "declined"
			}
		}
		byRel[fr.File.Rel] = outcome
	}
	for i, r := range t.Rows {
		if o, ok := byRel[r.Rel]; ok {
			t.Rows[i].Outcome = o
		}
	}
}