1use std::ffi::OsString;
2use std::path::Path;
3use std::process;
4
5use anyhow::anyhow;
6
7use git_ref_format::Qualified;
8use localtime::LocalTime;
9use radicle::cob::TypedId;
10use radicle::identity::Identity;
11use radicle::issue::cache::Issues as _;
12use radicle::node::notifications;
13use radicle::node::notifications::*;
14use radicle::patch::cache::Patches as _;
15use radicle::prelude::{NodeId, Profile, RepoId};
16use radicle::storage::{BranchName, ReadRepository, ReadStorage};
17use radicle::{cob, git, Storage};
18
19use term::Element as _;
20
21use crate::terminal as term;
22use crate::terminal::args;
23use crate::terminal::args::{Args, Error, Help};
24
25pub const HELP: Help = Help {
26 name: "inbox",
27 description: "Manage your Radicle notifications",
28 version: env!("RADICLE_VERSION"),
29 usage: r#"
30Usage
31
32 rad inbox [<option>...]
33 rad inbox list [<option>...]
34 rad inbox show <id> [<option>...]
35 rad inbox clear <id...> [<option>...]
36
37 By default, this command lists all items in your inbox.
38 If your working directory is a Radicle repository, it only shows item
39 belonging to this repository, unless `--all` is used.
40
41 The `rad inbox show` command takes a notification ID (which can be found in
42 the `list` command) and displays the information related to that
43 notification. This will mark the notification as read.
44
45 The `rad inbox clear` command will delete all notifications by their passed id
46 or all notifications if no ids were passed.
47
48Options
49
50 --all Operate on all repositories
51 --repo <rid> Operate on the given repository (default: rad .)
52 --sort-by <field> Sort by `id` or `timestamp` (default: timestamp)
53 --reverse, -r Reverse the list
54 --show-unknown Show any updates that were not recognized
55 --help Print help
56"#,
57};
58
59#[derive(Debug, Default, PartialEq, Eq)]
60enum Operation {
61 #[default]
62 List,
63 Show,
64 Clear,
65}
66
67#[derive(Default, Debug)]
68enum Mode {
69 #[default]
70 Contextual,
71 All,
72 ById(Vec<NotificationId>),
73 ByRepo(RepoId),
74}
75
76#[derive(Clone, Copy, Debug)]
77struct SortBy {
78 reverse: bool,
79 field: &'static str,
80}
81
82pub struct Options {
83 op: Operation,
84 mode: Mode,
85 sort_by: SortBy,
86 show_unknown: bool,
87}
88
89impl Args for Options {
90 fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
91 use lexopt::prelude::*;
92
93 let mut parser = lexopt::Parser::from_args(args);
94 let mut op: Option<Operation> = None;
95 let mut mode = None;
96 let mut ids = Vec::new();
97 let mut reverse = None;
98 let mut field = None;
99 let mut show_unknown = false;
100
101 while let Some(arg) = parser.next()? {
102 match arg {
103 Long("help") | Short('h') => {
104 return Err(Error::Help.into());
105 }
106 Long("all") | Short('a') if mode.is_none() => {
107 mode = Some(Mode::All);
108 }
109 Long("reverse") | Short('r') => {
110 reverse = Some(true);
111 }
112 Long("show-unknown") => {
113 show_unknown = true;
114 }
115 Long("sort-by") => {
116 let val = parser.value()?;
117
118 match term::args::string(&val).as_str() {
119 "timestamp" => field = Some("timestamp"),
120 "id" => field = Some("rowid"),
121 other => {
122 return Err(anyhow!(
123 "unknown sorting field `{other}`, see `rad inbox --help`"
124 ))
125 }
126 }
127 }
128 Long("repo") if mode.is_none() => {
129 let val = parser.value()?;
130 let repo = args::rid(&val)?;
131
132 mode = Some(Mode::ByRepo(repo));
133 }
134 Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
135 "list" => op = Some(Operation::List),
136 "show" => op = Some(Operation::Show),
137 "clear" => op = Some(Operation::Clear),
138 cmd => return Err(anyhow!("unknown command `{cmd}`, see `rad inbox --help`")),
139 },
140 Value(val) if op.is_some() && mode.is_none() => {
141 let id = term::args::number(&val)? as NotificationId;
142 ids.push(id);
143 }
144 _ => anyhow::bail!(arg.unexpected()),
145 }
146 }
147 let mode = if ids.is_empty() {
148 mode.unwrap_or_default()
149 } else {
150 Mode::ById(ids)
151 };
152 let op = op.unwrap_or_default();
153
154 let sort_by = if let Some(field) = field {
155 SortBy {
156 field,
157 reverse: reverse.unwrap_or(false),
158 }
159 } else {
160 SortBy {
161 field: "timestamp",
162 reverse: true,
163 }
164 };
165
166 Ok((
167 Options {
168 op,
169 mode,
170 sort_by,
171 show_unknown,
172 },
173 vec![],
174 ))
175 }
176}
177
178pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
179 let profile = ctx.profile()?;
180 let storage = &profile.storage;
181 let mut notifs = profile.notifications_mut()?;
182 let Options {
183 op,
184 mode,
185 sort_by,
186 show_unknown,
187 } = options;
188
189 match op {
190 Operation::List => list(
191 mode,
192 sort_by,
193 show_unknown,
194 ¬ifs.read_only(),
195 storage,
196 &profile,
197 ),
198 Operation::Clear => clear(mode, &mut notifs),
199 Operation::Show => show(mode, &mut notifs, storage, &profile),
200 }
201}
202
203fn list(
204 mode: Mode,
205 sort_by: SortBy,
206 show_unknown: bool,
207 notifs: ¬ifications::StoreReader,
208 storage: &Storage,
209 profile: &Profile,
210) -> anyhow::Result<()> {
211 let repos: Vec<term::VStack<'_>> = match mode {
212 Mode::Contextual => {
213 if let Ok((_, rid)) = radicle::rad::cwd() {
214 list_repo(rid, sort_by, show_unknown, notifs, storage, profile)?
215 .into_iter()
216 .collect()
217 } else {
218 list_all(sort_by, show_unknown, notifs, storage, profile)?
219 }
220 }
221 Mode::ByRepo(rid) => list_repo(rid, sort_by, show_unknown, notifs, storage, profile)?
222 .into_iter()
223 .collect(),
224 Mode::All => list_all(sort_by, show_unknown, notifs, storage, profile)?,
225 Mode::ById(_) => anyhow::bail!("the `list` command does not take IDs"),
226 };
227
228 if repos.is_empty() {
229 term::print(term::format::italic("Your inbox is empty."));
230 } else {
231 for repo in repos {
232 repo.print();
233 }
234 }
235 Ok(())
236}
237
238fn list_all<'a>(
239 sort_by: SortBy,
240 show_unknown: bool,
241 notifs: ¬ifications::StoreReader,
242 storage: &Storage,
243 profile: &Profile,
244) -> anyhow::Result<Vec<term::VStack<'a>>> {
245 let mut repos = storage.repositories()?;
246 repos.sort_by_key(|r| r.rid);
247
248 let mut vstacks = Vec::new();
249 for repo in repos {
250 let vstack = list_repo(repo.rid, sort_by, show_unknown, notifs, storage, profile)?;
251 vstacks.extend(vstack.into_iter());
252 }
253 Ok(vstacks)
254}
255
256fn list_repo<'a, R: ReadStorage>(
257 rid: RepoId,
258 sort_by: SortBy,
259 show_unknown: bool,
260 notifs: ¬ifications::StoreReader,
261 storage: &R,
262 profile: &Profile,
263) -> anyhow::Result<Option<term::VStack<'a>>>
264where
265 <R as ReadStorage>::Repository: cob::Store<Namespace = NodeId>,
266{
267 let mut table = term::Table::new(term::TableOptions {
268 spacing: 3,
269 ..term::TableOptions::default()
270 });
271 let repo = storage.repository(rid)?;
272 let (_, head) = repo.head()?;
273 let doc = repo.identity_doc()?;
274 let proj = doc.project()?;
275 let issues = term::cob::issues(profile, &repo)?;
276 let patches = term::cob::patches(profile, &repo)?;
277
278 let mut notifs = notifs.by_repo(&rid, sort_by.field)?.collect::<Vec<_>>();
279 if !sort_by.reverse {
280 notifs.reverse();
282 }
283
284 for n in notifs {
285 let n: Notification = n?;
286
287 let seen = if n.status.is_read() {
288 term::Label::blank()
289 } else {
290 term::format::tertiary(String::from("●")).into()
291 };
292 let author = n
293 .remote
294 .map(|r| {
295 let (alias, _) = term::format::Author::new(&r, profile, false).labels();
296 alias
297 })
298 .unwrap_or_default();
299 let notification_id = term::format::dim(format!("{:-03}", n.id)).into();
300 let timestamp = term::format::italic(term::format::timestamp(n.timestamp)).into();
301
302 let NotificationRow {
303 category,
304 summary,
305 state,
306 name,
307 } = match &n.kind {
308 NotificationKind::Branch { name } => NotificationRow::branch(name, head, &n, &repo)?,
309 NotificationKind::Cob { typed_id } => {
310 match NotificationRow::cob(typed_id, &n, &issues, &patches, &repo) {
311 Ok(Some(row)) => row,
312 Ok(None) => continue,
313 Err(e) => {
314 log::error!(target: "cli", "Error loading notification for {typed_id}: {e}");
315 continue;
316 }
317 }
318 }
319 NotificationKind::Unknown { refname } => {
320 if show_unknown {
321 NotificationRow::unknown(refname, &n, &repo)?
322 } else {
323 continue;
324 }
325 }
326 };
327 table.push([
328 notification_id,
329 seen,
330 name.into(),
331 summary.into(),
332 category.into(),
333 state.into(),
334 author,
335 timestamp,
336 ]);
337 }
338
339 if table.is_empty() {
340 Ok(None)
341 } else {
342 Ok(Some(
343 term::VStack::default()
344 .border(Some(term::colors::FAINT))
345 .child(term::label(term::format::bold(proj.name())))
346 .divider()
347 .child(table),
348 ))
349 }
350}
351
352struct NotificationRow {
353 category: term::Paint<String>,
354 summary: term::Paint<String>,
355 state: term::Paint<String>,
356 name: term::Paint<term::Paint<String>>,
357}
358
359impl NotificationRow {
360 fn new(
361 category: String,
362 summary: String,
363 state: term::Paint<String>,
364 name: term::Paint<String>,
365 ) -> Self {
366 Self {
367 category: term::format::dim(category),
368 summary: term::Paint::new(summary.to_string()),
369 state,
370 name: term::format::tertiary(name),
371 }
372 }
373
374 fn branch<S>(
375 name: &BranchName,
376 head: git::Oid,
377 n: &Notification,
378 repo: &S,
379 ) -> anyhow::Result<Self>
380 where
381 S: ReadRepository,
382 {
383 let commit = if let Some(head) = n.update.new() {
384 repo.commit(head)?.summary().unwrap_or_default().to_owned()
385 } else {
386 String::new()
387 };
388
389 let state = match n
390 .update
391 .new()
392 .map(|oid| repo.is_ancestor_of(oid, head))
393 .transpose()
394 {
395 Ok(Some(true)) => term::Paint::<String>::from(term::format::secondary("merged")),
396 Ok(Some(false)) | Ok(None) => term::format::ref_update(&n.update).into(),
397 Err(e) => return Err(e.into()),
398 }
399 .to_owned();
400
401 Ok(Self::new(
402 "branch".to_string(),
403 commit,
404 state,
405 term::format::default(name.to_string()),
406 ))
407 }
408
409 fn cob<S, I, P>(
410 typed_id: &TypedId,
411 n: &Notification,
412 issues: &I,
413 patches: &P,
414 repo: &S,
415 ) -> anyhow::Result<Option<Self>>
416 where
417 S: ReadRepository + cob::Store,
418 I: cob::issue::cache::Issues,
419 P: cob::patch::cache::Patches,
420 {
421 let TypedId { id, .. } = typed_id;
422 let (category, summary, state) = if typed_id.is_issue() {
423 let Some(issue) = issues.get(id)? else {
424 return Ok(None);
426 };
427 (
428 String::from("issue"),
429 issue.title().to_owned(),
430 term::format::issue::state(issue.state()),
431 )
432 } else if typed_id.is_patch() {
433 let Some(patch) = patches.get(id)? else {
434 return Ok(None);
436 };
437 (
438 String::from("patch"),
439 patch.title().to_owned(),
440 term::format::patch::state(patch.state()),
441 )
442 } else if typed_id.is_identity() {
443 let Ok(identity) = Identity::get(id, repo) else {
444 log::error!(
445 target: "cli",
446 "Error retrieving identity {id} for notification {}", n.id
447 );
448 return Ok(None);
449 };
450 let Some(rev) = n.update.new().and_then(|id| identity.revision(&id)) else {
451 log::error!(
452 target: "cli",
453 "Error retrieving identity revision for notification {}", n.id
454 );
455 return Ok(None);
456 };
457 (
458 String::from("id"),
459 rev.title.to_string(),
460 term::format::identity::state(&rev.state),
461 )
462 } else {
463 (
464 typed_id.type_name.to_string(),
465 "".to_owned(),
466 term::format::default(String::new()),
467 )
468 };
469 Ok(Some(Self::new(
470 category,
471 summary,
472 state,
473 term::format::cob(id),
474 )))
475 }
476
477 fn unknown<S>(refname: &Qualified<'static>, n: &Notification, repo: &S) -> anyhow::Result<Self>
478 where
479 S: ReadRepository,
480 {
481 let commit = if let Some(head) = n.update.new() {
482 repo.commit(head)?.summary().unwrap_or_default().to_owned()
483 } else {
484 String::new()
485 };
486 Ok(Self::new(
487 "unknown".to_string(),
488 commit,
489 "".into(),
490 term::format::default(refname.to_string()),
491 ))
492 }
493}
494
495fn clear(mode: Mode, notifs: &mut notifications::StoreWriter) -> anyhow::Result<()> {
496 let cleared = match mode {
497 Mode::All => notifs.clear_all()?,
498 Mode::ById(ids) => notifs.clear(&ids)?,
499 Mode::ByRepo(rid) => notifs.clear_by_repo(&rid)?,
500 Mode::Contextual => {
501 if let Ok((_, rid)) = radicle::rad::cwd() {
502 notifs.clear_by_repo(&rid)?
503 } else {
504 return Err(Error::WithHint {
505 err: anyhow!("not a radicle repository"),
506 hint: "to clear all repository notifications, use the `--all` flag",
507 }
508 .into());
509 }
510 }
511 };
512 if cleared > 0 {
513 term::success!("Cleared {cleared} item(s) from your inbox");
514 } else {
515 term::print(term::format::italic("Your inbox is empty."));
516 }
517 Ok(())
518}
519
520fn show(
521 mode: Mode,
522 notifs: &mut notifications::StoreWriter,
523 storage: &Storage,
524 profile: &Profile,
525) -> anyhow::Result<()> {
526 let id = match mode {
527 Mode::ById(ids) => match ids.as_slice() {
528 [id] => *id,
529 [] => anyhow::bail!("a Notification ID must be given"),
530 _ => anyhow::bail!("too many Notification IDs given"),
531 },
532 _ => anyhow::bail!("a Notification ID must be given"),
533 };
534 let n = notifs.get(id)?;
535 let repo = storage.repository(n.repo)?;
536
537 match n.kind {
538 NotificationKind::Cob { typed_id } if typed_id.is_issue() => {
539 let issues = term::cob::issues(profile, &repo)?;
540 let issue = issues.get(&typed_id.id)?.unwrap();
541
542 term::issue::show(
543 &issue,
544 &typed_id.id,
545 term::issue::Format::default(),
546 false,
547 profile,
548 )?;
549 }
550 NotificationKind::Cob { typed_id } if typed_id.is_patch() => {
551 let patches = term::cob::patches(profile, &repo)?;
552 let patch = patches.get(&typed_id.id)?.unwrap();
553
554 term::patch::show(&patch, &typed_id.id, false, &repo, None, profile)?;
555 }
556 NotificationKind::Cob { typed_id } if typed_id.is_identity() => {
557 let identity = Identity::get(&typed_id.id, &repo)?;
558
559 term::json::to_pretty(&identity.doc, Path::new("radicle.json"))?.print();
560 }
561 NotificationKind::Branch { .. } => {
562 let refstr = if let Some(remote) = n.remote {
563 n.qualified
564 .with_namespace(remote.to_component())
565 .to_string()
566 } else {
567 n.qualified.to_string()
568 };
569 process::Command::new("git")
570 .current_dir(repo.path())
571 .args(["log", refstr.as_str()])
572 .spawn()?
573 .wait()?;
574 }
575 notification => {
576 term::json::to_pretty(¬ification, Path::new("notification.json"))?.print();
577 }
578 }
579 notifs.set_status(NotificationStatus::ReadAt(LocalTime::now()), &[id])?;
580
581 Ok(())
582}