返回 CodeWhale
approval.rs
根目录 / vendor / codewhale-ratatui / src / gallery / approval.rs
1 //! Gallery entries for package Approval: the approval card, the review
2 //! verdict and the aggregate row. The fixtures double as usage examples: a
3 //! host maps its own request onto an [`ApprovalSubject`] and its own keys onto
4 //! an [`ApprovalState`], and the card paints what it was given.
5
6 use ratatui::{
7 buffer::Buffer,
8 layout::Rect,
9 style::Modifier,
10 text::{Line, Span},
11 };
12
13 use super::Entry;
14 use crate::{
15 ApprovalCard, ApprovalChoice, ApprovalEffect, ApprovalKey, ApprovalKind, ApprovalScope,
16 ApprovalState, ApprovalSubject, ChoiceId, DecisionBand, DecisionBandAction, DecisionBandSave,
17 KeyHint, KeyHints, Paint, ReviewAggregate, ReviewKind, ReviewVerdict, Theme,
18 };
19
20 const ALLOW_ONCE: ChoiceId = ChoiceId(1);
21 const ALLOW_SESSION: ChoiceId = ChoiceId(2);
22 const DENY: ChoiceId = ChoiceId(3);
23 const DENY_WHY: ChoiceId = ChoiceId(4);
24
25 /// The host's choices: allow once, allow for the session, deny, deny and say
26 /// why. Focus starts on `Deny`, not on an allow.
27 fn choices() -> ApprovalState {
28 ApprovalState::new(vec![
29 ApprovalChoice::new(ALLOW_ONCE, "Allow once", ApprovalEffect::Grants)
30 .char_key('y')
31 .char_key('1'),
32 ApprovalChoice::new(
33 ALLOW_SESSION,
34 "Allow for this session",
35 ApprovalEffect::Grants,
36 )
37 .char_key('a')
38 .char_key('2'),
39 ApprovalChoice::new(DENY, "Deny", ApprovalEffect::Refuses)
40 .char_key('n')
41 .char_key('3'),
42 ApprovalChoice::new(DENY_WHY, "Deny and say why", ApprovalEffect::Other).char_key('e'),
43 ])
44 .reveal(ApprovalKey::char('o'))
45 }
46
47 fn command(area: Rect, buf: &mut Buffer, theme: &Theme) {
48 let subject = ApprovalSubject::new(ApprovalKind::Command, "cargo publish --dry-run")
49 .cwd("codewhale/crates/tui")
50 .scope(ApprovalScope::Inside)
51 .agent("builder", false)
52 .risk_note("checks the package before release; publishes nothing");
53 ApprovalCard::new(&subject, &choices()).paint(area, buf, theme);
54 }
55
56 fn outside(area: Rect, buf: &mut Buffer, theme: &Theme) {
57 let subject = ApprovalSubject::new(ApprovalKind::Command, "rm -rf ../build /tmp/cache")
58 .cwd("/Users/me/work/codewhale")
59 .scope(ApprovalScope::Outside)
60 .agent("reviewer-3", true)
61 .risk_note("deletes files outside this project");
62 ApprovalCard::new(&subject, &choices()).paint(area, buf, theme);
63 }
64
65 fn patch(area: Rect, buf: &mut Buffer, theme: &Theme) {
66 let subject = ApprovalSubject::new(ApprovalKind::FileChange, "src/lib.rs, src/theme.rs")
67 .cwd("codewhale-ratatui")
68 .scope(ApprovalScope::Inside)
69 .agent("builder", false)
70 .preview_line("@@ -12,3 +12,4 @@")
71 .preview_line("-pub const OCEAN_TINT: f64 = 0.5;")
72 .preview_line("+pub const OCEAN_TINT: f64 = 0.55;")
73 .preview_line(" pub enum Ground {");
74 ApprovalCard::new(&subject, &choices()).paint(area, buf, theme);
75 }
76
77 fn elevation(area: Rect, buf: &mut Buffer, theme: &Theme) {
78 let subject = ApprovalSubject::new(ApprovalKind::Elevation, "npm install --global typescript")
79 .cwd("codewhale-app")
80 .scope(ApprovalScope::Inside)
81 .agent("builder", false)
82 .risk_note("the sandbox blocked a write to /usr/local; this runs without it");
83 ApprovalCard::new(&subject, &choices()).paint(area, buf, theme);
84 }
85
86 /// A command too long for the rows it was given: the card counts what is not
87 /// shown and offers the key that shows it.
88 fn clipped(area: Rect, buf: &mut Buffer, theme: &Theme) {
89 let long = "curl -fsSL https://example.com/install.sh | sh -s -- --prefix /opt/tools \
90 --channel stable --no-modify-path --verbose --accept-license";
91 let subject = ApprovalSubject::new(ApprovalKind::Command, long)
92 .cwd("codewhale")
93 .scope(ApprovalScope::Inside);
94 ApprovalCard::new(&subject, &choices()).paint(area, buf, theme);
95 }
96
97 /// Text that tries to differ from what runs: an escape sequence, a bidi
98 /// override, a zero-width space, a tab, a second command after a newline and
99 /// trailing spaces. Each is drawn as a named token.
100 fn spoofed(area: Rect, buf: &mut Buffer, theme: &Theme) {
101 let target = "echo ok\u{1b}[2K\u{200b}\u{202e}txt.exe\tx\nrm -rf ~ ";
102 let subject = ApprovalSubject::new(ApprovalKind::Command, target)
103 .cwd("codewhale")
104 .scope(ApprovalScope::Inside)
105 .agent("builder", false);
106 ApprovalCard::new(&subject, &choices()).paint(area, buf, theme);
107 }
108
109 fn verdicts(area: Rect, buf: &mut Buffer, theme: &Theme) {
110 let hints = KeyHints::new(vec![
111 KeyHint::new("y", "allow once"),
112 KeyHint::new("n", "deny"),
113 ]);
114 let cards = [
115 ReviewVerdict::new(
116 ReviewKind::Allowed,
117 "Auto-Review",
118 "reads files inside this project",
119 &hints,
120 None,
121 ),
122 ReviewVerdict::new(
123 ReviewKind::Denied,
124 "Auto-Review",
125 "deletes files outside this project",
126 &hints,
127 None,
128 )
129 .map(|v| {
130 v.subject("rm -rf ../build")
131 .category("Deletes unseen files")
132 }),
133 ReviewVerdict::new(
134 ReviewKind::Held,
135 "Auto-Review",
136 "pushes to a protected branch",
137 &hints,
138 None,
139 )
140 .map(|v| v.subject("git push origin main").id("ar_7K2M")),
141 ReviewVerdict::new(
142 ReviewKind::Undecided,
143 "Auto-Review",
144 "the review timed out",
145 &hints,
146 None,
147 )
148 .map(|v| v.subject("git push origin main")),
149 ];
150 let mut y = area.y;
151 for card in cards.into_iter().flatten() {
152 let h = card.height(area.width, theme);
153 let rect = Rect::new(area.x, y, area.width, h).intersection(area);
154 if rect.is_empty() {
155 break;
156 }
157 card.paint(rect, buf, theme);
158 y = rect.bottom().saturating_add(1);
159 }
160 }
161
162 fn aggregate(area: Rect, buf: &mut Buffer, theme: &Theme) {
163 let rows = [
164 ReviewAggregate {
165 allowed: 42,
166 denied: 3,
167 held: 1,
168 undecided: 1,
169 reviewing: 2,
170 ..ReviewAggregate::default()
171 },
172 ReviewAggregate::default(),
173 ];
174 let mut y = area.y;
175 for row in &rows {
176 let h = row.height(area.width, theme);
177 let rect = Rect::new(area.x, y, area.width, h).intersection(area);
178 if rect.is_empty() {
179 break;
180 }
181 row.paint(rect, buf, theme);
182 y = rect.bottom().saturating_add(1);
183 }
184 }
185
186 fn native_band(area: Rect, buf: &mut Buffer, theme: &Theme) {
187 let band = native_band_facts(theme);
188 band.paint(area, buf, theme);
189 }
190
191 fn native_band_collapsed(area: Rect, buf: &mut Buffer, theme: &Theme) {
192 let mut band = native_band_facts(theme);
193 band.collapsed = Some(Line::styled(
194 if theme.ascii() {
195 " Run checks - command [Tab expand] "
196 } else {
197 " Run checks — command [Tab expand] "
198 },
199 theme.fg(crate::Role::Primary),
200 ));
201 band.paint(area, buf, theme);
202 }
203
204 fn native_band_facts(theme: &Theme) -> DecisionBand {
205 let plain = theme.fg(crate::Role::Foreground);
206 let muted = theme.fg(crate::Role::Muted);
207 let primary = theme.fg(crate::Role::Primary);
208 DecisionBand {
209 body: vec![
210 Line::styled(
211 " Runs a command: verify this project",
212 primary.add_modifier(Modifier::BOLD),
213 ),
214 Line::styled(" cargo test --workspace", plain),
215 ],
216 saves: vec![DecisionBandSave {
217 summary: "always ask first".into(),
218 entries: vec!["run cargo test --workspace in this project".into()],
219 omitted: 0,
220 label: "Save: ".into(),
221 separator: if theme.ascii() { " / " } else { " · " }.into(),
222 compact_more: " +{count} more".into(),
223 full_more: "... {count} more".into(),
224 label_style: primary,
225 summary_style: plain,
226 entries_style: muted,
227 more_style: muted,
228 }],
229 question: Line::styled(
230 " Do you want to proceed?",
231 plain.add_modifier(Modifier::BOLD),
232 ),
233 actions: vec![
234 DecisionBandAction {
235 line: Line::styled(" [1/y] Allow once", plain),
236 persistent: false,
237 },
238 DecisionBandAction {
239 line: Line::styled(" [2/a] Allow for this session", plain),
240 persistent: false,
241 },
242 DecisionBandAction {
243 line: Line::styled(" [p] Save this exact project rule", plain),
244 persistent: true,
245 },
246 DecisionBandAction {
247 line: Line::styled("> [3/n] Deny", primary.add_modifier(Modifier::BOLD)),
248 persistent: false,
249 },
250 ],
251 footer: Line::styled(" Enter chooses; Esc stops; Alt+V details", muted),
252 save_hint: Some(Span::styled(" / s save ask rule", primary)),
253 background: theme.bg(crate::Role::Background),
254 rule: Span::styled(
255 if theme.ascii() { "-" } else { "─" },
256 theme.fg(crate::Role::Border),
257 ),
258 truncation_hint: Span::styled(" Details truncated: Alt+V", muted),
259 collapsed: None,
260 }
261 }
262
263 pub(crate) fn entries() -> Vec<Entry> {
264 vec![
265 Entry {
266 name: "approval-native-band",
267 width: 70,
268 height: 24,
269 draw: native_band,
270 },
271 Entry {
272 name: "approval-native-band-collapsed",
273 width: 70,
274 height: 3,
275 draw: native_band_collapsed,
276 },
277 Entry {
278 name: "approval-command",
279 width: 70,
280 height: 17,
281 draw: command,
282 },
283 Entry {
284 name: "approval-outside",
285 width: 70,
286 height: 18,
287 draw: outside,
288 },
289 Entry {
290 name: "approval-patch",
291 width: 70,
292 height: 20,
293 draw: patch,
294 },
295 Entry {
296 name: "approval-elevation",
297 width: 70,
298 height: 20,
299 draw: elevation,
300 },
301 Entry {
302 name: "approval-clipped",
303 width: 70,
304 height: 12,
305 draw: clipped,
306 },
307 Entry {
308 name: "approval-spoofed",
309 width: 70,
310 height: 20,
311 draw: spoofed,
312 },
313 Entry {
314 name: "review-verdicts",
315 width: 70,
316 height: 20,
317 draw: verdicts,
318 },
319 Entry {
320 name: "review-aggregate",
321 width: 70,
322 height: 4,
323 draw: aggregate,
324 },
325 ]
326 }
327
327 lines RUST