aboutsummaryrefslogtreecommitdiff
path: root/gui/internal/model/plan.go
blob: 607fa29ad28734fb02a0537d364e2ae650f56c3f (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
// 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/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
}

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

// Plan locks dir and plans it, returning the tab to show. The lock is held
// until Apply or Close: what the user sees stays the truth while they
// choose (GUI design §3).
func Plan(ctx context.Context, sess *engine.Session, d *engine.Dir) (*PlanTab, error) {
	dp, err := sess.Plan(ctx, d)
	if err != nil {
		return nil, err
	}
	t := &PlanTab{Dir: d, sess: sess, dp: dp}
	t.fill()
	return t, nil
}

// 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)
}

// 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
	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
		}
	}
}