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 repo = storage.repository(rid)?;
268 let (_, head) = repo.head()?;
269 let doc = repo.identity_doc()?;
270 let proj = doc.project()?;
271 let issues = term::cob::issues(profile, &repo)?;
272 let patches = term::cob::patches(profile, &repo)?;
273
274 let mut notifs = notifs.by_repo(&rid, sort_by.field)?.collect::<Vec<_>>();
275 if !sort_by.reverse {
276 notifs.reverse();
278 }
279
280 let table = notifs.into_iter().flat_map(|n| {
281 let n: Notification = match n {
282 Err(e) => return Some(Err(anyhow::Error::from(e))),
283 Ok(n) => n,
284 };
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, false).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 } => match NotificationRow::branch(name, head, &n, &repo) {
308 Err(e) => return Some(Err(e)),
309 Ok(b) => b,
310 },
311 NotificationKind::Cob { typed_id } => {
312 match NotificationRow::cob(typed_id, &n, &issues, &patches, &repo) {
313 Ok(Some(row)) => row,
314 Ok(None) => return None,
315 Err(e) => {
316 log::error!(target: "cli", "Error loading notification for {typed_id}: {e}");
317 return None
318 }
319 }
320 }
321 NotificationKind::Unknown { refname } => {
322 if show_unknown {
323 match NotificationRow::unknown(refname, &n, &repo) {
324 Err(e) => return Some(Err(e)),
325 Ok(u) => u,
326 }
327 } else {
328 return None
329 }
330 }
331 };
332
333 Some(Ok([
334 notification_id,
335 seen,
336 name.into(),
337 summary.into(),
338 category.into(),
339 state.into(),
340 author,
341 timestamp,
342 ]))
343 }).collect::<Result<term::Table<8, _>, anyhow::Error>>()?
344 .with_opts(term::TableOptions {
345 spacing: 3,
346 ..term::TableOptions::default()
347 });
348
349 if table.is_empty() {
350 Ok(None)
351 } else {
352 Ok(Some(
353 term::VStack::default()
354 .border(Some(term::colors::FAINT))
355 .child(term::label(term::format::bold(proj.name())))
356 .divider()
357 .child(table),
358 ))
359 }
360}
361
362struct NotificationRow {
363 category: term::Paint<String>,
364 summary: term::Paint<String>,
365 state: term::Paint<String>,
366 name: term::Paint<term::Paint<String>>,
367}
368
369impl NotificationRow {
370 fn new(
371 category: String,
372 summary: String,
373 state: term::Paint<String>,
374 name: term::Paint<String>,
375 ) -> Self {
376 Self {
377 category: term::format::dim(category),
378 summary: term::Paint::new(summary.to_string()),
379 state,
380 name: term::format::tertiary(name),
381 }
382 }
383
384 fn branch<S>(
385 name: &BranchName,
386 head: git::Oid,
387 n: &Notification,
388 repo: &S,
389 ) -> anyhow::Result<Self>
390 where
391 S: ReadRepository,
392 {
393 let commit = if let Some(head) = n.update.new() {
394 repo.commit(head)?.summary().unwrap_or_default().to_owned()
395 } else {
396 String::new()
397 };
398
399 let state = match n
400 .update
401 .new()
402 .map(|oid| repo.is_ancestor_of(oid, head))
403 .transpose()
404 {
405 Ok(Some(true)) => term::Paint::<String>::from(term::format::secondary("merged")),
406 Ok(Some(false)) | Ok(None) => term::format::ref_update(&n.update).into(),
407 Err(e) => return Err(e.into()),
408 }
409 .to_owned();
410
411 Ok(Self::new(
412 "branch".to_string(),
413 commit,
414 state,
415 term::format::default(name.to_string()),
416 ))
417 }
418
419 fn cob<S, I, P>(
420 typed_id: &TypedId,
421 n: &Notification,
422 issues: &I,
423 patches: &P,
424 repo: &S,
425 ) -> anyhow::Result<Option<Self>>
426 where
427 S: ReadRepository + cob::Store,
428 I: cob::issue::cache::Issues,
429 P: cob::patch::cache::Patches,
430 {
431 let TypedId { id, .. } = typed_id;
432 let (category, summary, state) = if typed_id.is_issue() {
433 let Some(issue) = issues.get(id)? else {
434 return Ok(None);
436 };
437 (
438 String::from("issue"),
439 issue.title().to_owned(),
440 term::format::issue::state(issue.state()),
441 )
442 } else if typed_id.is_patch() {
443 let Some(patch) = patches.get(id)? else {
444 return Ok(None);
446 };
447 (
448 String::from("patch"),
449 patch.title().to_owned(),
450 term::format::patch::state(patch.state()),
451 )
452 } else if typed_id.is_identity() {
453 let Ok(identity) = Identity::get(id, repo) else {
454 log::error!(
455 target: "cli",
456 "Error retrieving identity {id} for notification {}", n.id
457 );
458 return Ok(None);
459 };
460 let Some(rev) = n.update.new().and_then(|id| identity.revision(&id)) else {
461 log::error!(
462 target: "cli",
463 "Error retrieving identity revision for notification {}", n.id
464 );
465 return Ok(None);
466 };
467 (
468 String::from("id"),
469 rev.title.to_string(),
470 term::format::identity::state(&rev.state),
471 )
472 } else {
473 (
474 typed_id.type_name.to_string(),
475 "".to_owned(),
476 term::format::default(String::new()),
477 )
478 };
479 Ok(Some(Self::new(
480 category,
481 summary,
482 state,
483 term::format::cob(id),
484 )))
485 }
486
487 fn unknown<S>(refname: &Qualified<'static>, n: &Notification, repo: &S) -> anyhow::Result<Self>
488 where
489 S: ReadRepository,
490 {
491 let commit = if let Some(head) = n.update.new() {
492 repo.commit(head)?.summary().unwrap_or_default().to_owned()
493 } else {
494 String::new()
495 };
496 Ok(Self::new(
497 "unknown".to_string(),
498 commit,
499 "".into(),
500 term::format::default(refname.to_string()),
501 ))
502 }
503}
504
505fn clear(mode: Mode, notifs: &mut notifications::StoreWriter) -> anyhow::Result<()> {
506 let cleared = match mode {
507 Mode::All => notifs.clear_all()?,
508 Mode::ById(ids) => notifs.clear(&ids)?,
509 Mode::ByRepo(rid) => notifs.clear_by_repo(&rid)?,
510 Mode::Contextual => {
511 if let Ok((_, rid)) = radicle::rad::cwd() {
512 notifs.clear_by_repo(&rid)?
513 } else {
514 return Err(Error::WithHint {
515 err: anyhow!("not a radicle repository"),
516 hint: "to clear all repository notifications, use the `--all` flag",
517 }
518 .into());
519 }
520 }
521 };
522 if cleared > 0 {
523 term::success!("Cleared {cleared} item(s) from your inbox");
524 } else {
525 term::print(term::format::italic("Your inbox is empty."));
526 }
527 Ok(())
528}
529
530fn show(
531 mode: Mode,
532 notifs: &mut notifications::StoreWriter,
533 storage: &Storage,
534 profile: &Profile,
535) -> anyhow::Result<()> {
536 let id = match mode {
537 Mode::ById(ids) => match ids.as_slice() {
538 [id] => *id,
539 [] => anyhow::bail!("a Notification ID must be given"),
540 _ => anyhow::bail!("too many Notification IDs given"),
541 },
542 _ => anyhow::bail!("a Notification ID must be given"),
543 };
544 let n = notifs.get(id)?;
545 let repo = storage.repository(n.repo)?;
546
547 match n.kind {
548 NotificationKind::Cob { typed_id } if typed_id.is_issue() => {
549 let issues = term::cob::issues(profile, &repo)?;
550 let issue = issues.get(&typed_id.id)?.unwrap();
551
552 term::issue::show(
553 &issue,
554 &typed_id.id,
555 term::issue::Format::default(),
556 false,
557 profile,
558 )?;
559 }
560 NotificationKind::Cob { typed_id } if typed_id.is_patch() => {
561 let patches = term::cob::patches(profile, &repo)?;
562 let patch = patches.get(&typed_id.id)?.unwrap();
563
564 term::patch::show(&patch, &typed_id.id, false, &repo, None, profile)?;
565 }
566 NotificationKind::Cob { typed_id } if typed_id.is_identity() => {
567 let identity = Identity::get(&typed_id.id, &repo)?;
568
569 term::json::to_pretty(&identity.doc, Path::new("radicle.json"))?.print();
570 }
571 NotificationKind::Branch { .. } => {
572 let refstr = if let Some(remote) = n.remote {
573 n.qualified
574 .with_namespace(remote.to_component())
575 .to_string()
576 } else {
577 n.qualified.to_string()
578 };
579 process::Command::new("git")
580 .current_dir(repo.path())
581 .args(["log", refstr.as_str()])
582 .spawn()?
583 .wait()?;
584 }
585 notification => {
586 term::json::to_pretty(¬ification, Path::new("notification.json"))?.print();
587 }
588 }
589 notifs.set_status(NotificationStatus::ReadAt(LocalTime::now()), &[id])?;
590
591 Ok(())
592}