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