From d5632ed5349fed88cb14f2780100e5b49679df2d Mon Sep 17 00:00:00 2001 From: Sebastian Martinez Date: Tue, 21 Jan 2025 13:42:16 +0100 Subject: [PATCH] Add global inbox --- .github/workflows/check-unit-test.yml | 15 + crates/radicle-tauri/src/commands.rs | 1 + crates/radicle-tauri/src/commands/inbox.rs | 181 ++++++++ crates/radicle-tauri/src/commands/repo.rs | 5 + crates/radicle-tauri/src/lib.rs | 19 +- .../bindings/cob/inbox/ActionWithAuthor.ts | 8 + .../radicle-types/bindings/cob/inbox/Issue.ts | 15 + .../bindings/cob/inbox/NotificationCount.ts | 3 + .../bindings/cob/inbox/NotificationItem.ts | 7 + .../radicle-types/bindings/cob/inbox/Patch.ts | 15 + .../bindings/cob/inbox/RefUpdate.ts | 7 + .../cob/inbox/SetStatusNotifications.ts | 6 + .../bindings/cob/inbox/TypedId.ts | 6 + .../radicle-types/bindings/repo/RepoCount.ts | 8 + crates/radicle-types/src/cobs.rs | 2 +- crates/radicle-types/src/cobs/issue.rs | 6 +- crates/radicle-types/src/cobs/patch.rs | 2 +- crates/radicle-types/src/cobs/stream.rs | 33 +- crates/radicle-types/src/cobs/stream/iter.rs | 83 +++- crates/radicle-types/src/cobs/thread.rs | 6 +- .../src/domain/inbox/models/notification.rs | 279 +++++++++++- crates/radicle-types/src/error.rs | 22 +- crates/radicle-types/src/outbound/sqlite.rs | 6 +- crates/radicle-types/src/repo.rs | 11 + crates/radicle-types/src/traits/repo.rs | 43 +- crates/test-http-api/src/api.rs | 15 + package-lock.json | 396 ++++++++++++++++++ package.json | 3 + src/App.svelte | 7 +- src/components/Avatar.svelte | 6 +- src/components/Changeset/FileDiff.svelte | 2 +- src/components/Header.svelte | 4 +- src/components/HomeSidebar.svelte | 202 +++++++++ src/components/Icon.svelte | 50 ++- src/components/Id.svelte | 2 +- src/components/NotificationTeaser.svelte | 227 ++++++++++ src/components/RepoNotifications.svelte | 74 ++++ src/lib/notification.ts | 188 +++++++++ src/lib/router.ts | 9 +- src/lib/router/definitions.ts | 122 +++++- src/lib/utils.ts | 8 + src/views/Home.svelte | 70 ---- src/views/home/Inbox.svelte | 215 ++++++++++ src/views/home/Repos.svelte | 99 +++++ src/views/repo/Layout.svelte | 22 +- tests/e2e/authenticate.spec.ts | 4 +- tests/unit/notifications.test.ts | 205 +++++++++ vite.config.ts | 5 + 48 files changed, 2572 insertions(+), 152 deletions(-) create mode 100644 .github/workflows/check-unit-test.yml create mode 100644 crates/radicle-tauri/src/commands/inbox.rs create mode 100644 crates/radicle-types/bindings/cob/inbox/ActionWithAuthor.ts create mode 100644 crates/radicle-types/bindings/cob/inbox/Issue.ts create mode 100644 crates/radicle-types/bindings/cob/inbox/NotificationCount.ts create mode 100644 crates/radicle-types/bindings/cob/inbox/NotificationItem.ts create mode 100644 crates/radicle-types/bindings/cob/inbox/Patch.ts create mode 100644 crates/radicle-types/bindings/cob/inbox/RefUpdate.ts create mode 100644 crates/radicle-types/bindings/cob/inbox/SetStatusNotifications.ts create mode 100644 crates/radicle-types/bindings/cob/inbox/TypedId.ts create mode 100644 crates/radicle-types/bindings/repo/RepoCount.ts create mode 100644 src/components/HomeSidebar.svelte create mode 100644 src/components/NotificationTeaser.svelte create mode 100644 src/components/RepoNotifications.svelte create mode 100644 src/lib/notification.ts delete mode 100644 src/views/Home.svelte create mode 100644 src/views/home/Inbox.svelte create mode 100644 src/views/home/Repos.svelte create mode 100644 tests/unit/notifications.test.ts diff --git a/.github/workflows/check-unit-test.yml b/.github/workflows/check-unit-test.yml new file mode 100644 index 000000000..b6c5e529b --- /dev/null +++ b/.github/workflows/check-unit-test.yml @@ -0,0 +1,15 @@ +name: check-unit-test +on: push + +jobs: + check-unit-test: + runs-on: ubuntu-latest + steps: + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20.9.0" + - name: Checkout + uses: actions/checkout@v4 + - run: npm ci + - run: npm run test:unit diff --git a/crates/radicle-tauri/src/commands.rs b/crates/radicle-tauri/src/commands.rs index 12c3bfd65..fae1019bc 100644 --- a/crates/radicle-tauri/src/commands.rs +++ b/crates/radicle-tauri/src/commands.rs @@ -1,6 +1,7 @@ pub mod auth; pub mod cob; pub mod diff; +pub mod inbox; pub mod profile; pub mod repo; pub mod thread; diff --git a/crates/radicle-tauri/src/commands/inbox.rs b/crates/radicle-tauri/src/commands/inbox.rs new file mode 100644 index 000000000..75fd6e903 --- /dev/null +++ b/crates/radicle-tauri/src/commands/inbox.rs @@ -0,0 +1,181 @@ +use std::collections::BTreeMap; + +use radicle::identity::DocAt; +use radicle::issue::cache::Issues; +use radicle::node; +use radicle::patch::cache::Patches; +use radicle::storage::{ReadRepository, ReadStorage}; +use radicle::{git, identity}; + +use radicle_types::cobs::PaginatedQuery; +use radicle_types::domain::inbox::models::notification; +use radicle_types::domain::inbox::service::Service; +use radicle_types::domain::inbox::traits::InboxService; +use radicle_types::error::Error; +use radicle_types::outbound::sqlite::Sqlite; +use radicle_types::AppState; + +#[tauri::command] +pub fn list_notifications( + ctx: tauri::State, + sqlite_service: tauri::State>, + params: notification::RepoGroupParams, +) -> Result< + PaginatedQuery, Vec>>, + Error, +> { + let profile = &ctx.profile; + let aliases = profile.aliases(); + let cursor = params.skip.unwrap_or(0); + let take = params.take.unwrap_or(20); + + let all = sqlite_service.repo_group(params.clone())?; + let more = cursor + take < all.len(); + let repo = profile.storage.repository(params.repo)?; + let patches = profile.patches(&repo)?; + let issues = profile.issues(&repo)?; + + let content = all + .into_iter() + .skip(cursor) + .take(take) + .map(|(qualified, n)| { + let items = n + .into_iter() + .filter_map(|s| { + let update: notification::RefUpdate = + (qualified.clone().into_refstring(), s.new, s.old).into(); + let update: radicle::storage::RefUpdate = update.into(); + let kind = + node::notifications::NotificationKind::try_from(qualified.clone()).ok()?; + + match kind { + node::notifications::NotificationKind::Cob { ref typed_id } => { + if typed_id.is_patch() { + let actions = notification::actions( + typed_id.type_name.clone(), + typed_id.id, + update.old(), + update.new(), + &repo, + &aliases, + ) + .unwrap_or_default(); + + match patches.get(&typed_id.id) { + Ok(Some(p)) => Some(notification::NotificationItem::Patch( + notification::Patch { + row_id: s.row_id, + id: typed_id.id, + update: update.into(), + timestamp: s.timestamp, + title: p.title().to_string(), + status: (p.state().clone()).into(), + actions, + }, + )), + Ok(None) => { + log::error!("No patch found"); + None + } + Err(e) => { + log::error!("{}", e); + None + } + } + } else if typed_id.is_issue() { + let actions = notification::actions( + typed_id.type_name.clone(), + typed_id.id, + update.old(), + update.new(), + &repo, + &aliases, + ) + .unwrap_or_default(); + + match issues.get(&typed_id.id) { + Ok(Some(i)) => Some(notification::NotificationItem::Issue( + notification::Issue { + row_id: s.row_id, + id: typed_id.id, + update: update.into(), + timestamp: s.timestamp, + title: i.title().to_string(), + status: (*i.state()).into(), + actions, + }, + )), + Ok(None) => { + log::error!("No issue found"); + None + } + Err(e) => { + log::error!("{}", e); + None + } + } + } else { + None + } + } + _ => None, + } + }) + .collect::>(); + + (qualified, items) + }) + .filter(|(_, v)| !v.is_empty()) + .collect::, Vec>>(); + + Ok(PaginatedQuery { + cursor, + more, + content, + }) +} + +#[tauri::command] +pub fn count_notifications_by_repo( + ctx: tauri::State, + inbox: tauri::State>, +) -> Result, Error> { + let profile = &ctx.profile; + let result = inbox + .counts_by_repo()? + .filter_map(|s| { + let (rid, count) = s.ok()?; + let repo = profile.storage.repository(rid).ok()?; + let DocAt { doc, .. } = repo.identity_doc().ok()?; + let project = doc.project().ok()?; + + Some(( + rid, + notification::NotificationCount { + rid, + name: project.name().to_string(), + count, + }, + )) + }) + .collect::>(); + + Ok(result) +} + +#[tauri::command] +pub fn clear_notifications( + ctx: tauri::State, + params: notification::SetStatusNotifications, +) -> Result<(), Error> { + let profile = &ctx.profile; + let mut notifications = profile.notifications_mut()?; + match params { + notification::SetStatusNotifications::Ids(ids) => notifications.clear(&ids)?, + notification::SetStatusNotifications::Repo(repo) => notifications.clear_by_repo(&repo)?, + notification::SetStatusNotifications::All => notifications.clear_all()?, + }; + + Ok(()) +} diff --git a/crates/radicle-tauri/src/commands/repo.rs b/crates/radicle-tauri/src/commands/repo.rs index 9f4a56d1b..7527382f4 100644 --- a/crates/radicle-tauri/src/commands/repo.rs +++ b/crates/radicle-tauri/src/commands/repo.rs @@ -15,6 +15,11 @@ pub fn list_repos( ctx.list_repos(show) } +#[tauri::command] +pub fn repo_count(ctx: tauri::State) -> Result { + ctx.repo_count() +} + #[tauri::command] pub fn repo_by_id( ctx: tauri::State, diff --git a/crates/radicle-tauri/src/lib.rs b/crates/radicle-tauri/src/lib.rs index 800998c3a..80bf9a44b 100644 --- a/crates/radicle-tauri/src/lib.rs +++ b/crates/radicle-tauri/src/lib.rs @@ -1,14 +1,15 @@ mod commands; -use tauri::Emitter; -use tauri::Manager; +use tauri::{Emitter, Manager}; -use radicle::node::Handle; +use radicle::node::{Handle, NOTIFICATIONS_DB_FILE}; use radicle::Node; + +use radicle_types::domain; use radicle_types::error::Error; use radicle_types::AppState; -use commands::{auth, cob, diff, profile, repo, thread}; +use commands::{auth, cob, diff, inbox, profile, repo, thread}; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -33,12 +34,18 @@ pub fn run() { }), }?; + let inbox_db = radicle_types::outbound::sqlite::Sqlite::reader( + profile.node().join(NOTIFICATIONS_DB_FILE), + )?; + let inbox_service = domain::inbox::service::Service::new(inbox_db); + let events_handler = app.handle().clone(); let node_handler = app.handle().clone(); let node = Node::new(profile.socket()); let node_status = node.clone(); + app.manage(inbox_service); app.manage(AppState { profile }); tauri::async_runtime::spawn(async move { @@ -71,11 +78,15 @@ pub fn run() { .plugin(tauri_plugin_window_state::Builder::default().build()) .invoke_handler(tauri::generate_handler![ auth::authenticate, + repo::repo_count, repo::list_repos, repo::repo_by_id, repo::diff_stats, repo::list_commits, diff::get_diff, + inbox::list_notifications, + inbox::count_notifications_by_repo, + inbox::clear_notifications, cob::get_embed, cob::save_embed_to_disk, cob::save_embed_by_path, diff --git a/crates/radicle-types/bindings/cob/inbox/ActionWithAuthor.ts b/crates/radicle-types/bindings/cob/inbox/ActionWithAuthor.ts new file mode 100644 index 000000000..b2a4e855e --- /dev/null +++ b/crates/radicle-types/bindings/cob/inbox/ActionWithAuthor.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Author } from "../Author"; + +export type ActionWithAuthor = { + oid: string; + timestamp: number; + author: Author; +} & T; diff --git a/crates/radicle-types/bindings/cob/inbox/Issue.ts b/crates/radicle-types/bindings/cob/inbox/Issue.ts new file mode 100644 index 000000000..d76e908d2 --- /dev/null +++ b/crates/radicle-types/bindings/cob/inbox/Issue.ts @@ -0,0 +1,15 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Action } from "../issue/Action"; +import type { ActionWithAuthor } from "./ActionWithAuthor"; +import type { RefUpdate } from "./RefUpdate"; +import type { State } from "../issue/State"; + +export type Issue = { + rowId: string; + id: string; + update: RefUpdate; + title: string; + timestamp: number; + status: State; + actions: Array>; +}; diff --git a/crates/radicle-types/bindings/cob/inbox/NotificationCount.ts b/crates/radicle-types/bindings/cob/inbox/NotificationCount.ts new file mode 100644 index 000000000..683f3987f --- /dev/null +++ b/crates/radicle-types/bindings/cob/inbox/NotificationCount.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type NotificationCount = { rid: string; name: string; count: number }; diff --git a/crates/radicle-types/bindings/cob/inbox/NotificationItem.ts b/crates/radicle-types/bindings/cob/inbox/NotificationItem.ts new file mode 100644 index 000000000..c80c93306 --- /dev/null +++ b/crates/radicle-types/bindings/cob/inbox/NotificationItem.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Issue } from "./Issue"; +import type { Patch } from "./Patch"; + +export type NotificationItem = + | { "type": "issue" } & Issue + | { "type": "patch" } & Patch; diff --git a/crates/radicle-types/bindings/cob/inbox/Patch.ts b/crates/radicle-types/bindings/cob/inbox/Patch.ts new file mode 100644 index 000000000..599f26af1 --- /dev/null +++ b/crates/radicle-types/bindings/cob/inbox/Patch.ts @@ -0,0 +1,15 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Action } from "../patch/Action"; +import type { ActionWithAuthor } from "./ActionWithAuthor"; +import type { RefUpdate } from "./RefUpdate"; +import type { State } from "../patch/State"; + +export type Patch = { + rowId: string; + id: string; + update: RefUpdate; + timestamp: number; + title: string; + status: State; + actions: Array>; +}; diff --git a/crates/radicle-types/bindings/cob/inbox/RefUpdate.ts b/crates/radicle-types/bindings/cob/inbox/RefUpdate.ts new file mode 100644 index 000000000..bd97da762 --- /dev/null +++ b/crates/radicle-types/bindings/cob/inbox/RefUpdate.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type RefUpdate = + | { "type": "updated"; name: string; old: string; new: string } + | { "type": "created"; name: string; oid: string } + | { "type": "deleted"; name: string; oid: string } + | { "type": "skipped"; name: string; oid: string }; diff --git a/crates/radicle-types/bindings/cob/inbox/SetStatusNotifications.ts b/crates/radicle-types/bindings/cob/inbox/SetStatusNotifications.ts new file mode 100644 index 000000000..ca5c408d8 --- /dev/null +++ b/crates/radicle-types/bindings/cob/inbox/SetStatusNotifications.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SetStatusNotifications = + | { "type": "ids"; "content": Array } + | { "type": "repo"; "content": string } + | { "type": "all" }; diff --git a/crates/radicle-types/bindings/cob/inbox/TypedId.ts b/crates/radicle-types/bindings/cob/inbox/TypedId.ts new file mode 100644 index 000000000..7b4867f6c --- /dev/null +++ b/crates/radicle-types/bindings/cob/inbox/TypedId.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * The exact identifier for a particular COB. + */ +export type TypedId = { id: string; typeName: string }; diff --git a/crates/radicle-types/bindings/repo/RepoCount.ts b/crates/radicle-types/bindings/repo/RepoCount.ts new file mode 100644 index 000000000..0ddafd59d --- /dev/null +++ b/crates/radicle-types/bindings/repo/RepoCount.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type RepoCount = { + total: number; + delegate: number; + private: number; + seeding: number; +}; diff --git a/crates/radicle-types/src/cobs.rs b/crates/radicle-types/src/cobs.rs index f3ab981f6..1603843b0 100644 --- a/crates/radicle-types/src/cobs.rs +++ b/crates/radicle-types/src/cobs.rs @@ -11,7 +11,7 @@ pub mod patch; pub mod stream; pub mod thread; -#[derive(Debug, Serialize, TS)] +#[derive(Debug, Clone, Serialize, TS, Deserialize)] #[serde(rename_all = "camelCase")] #[ts(export)] #[ts(export_to = "cob/")] diff --git a/crates/radicle-types/src/cobs/issue.rs b/crates/radicle-types/src/cobs/issue.rs index 802753ded..6dc4f65db 100644 --- a/crates/radicle-types/src/cobs/issue.rs +++ b/crates/radicle-types/src/cobs/issue.rs @@ -54,7 +54,7 @@ impl Issue { } } -#[derive(Default, Serialize, Deserialize, TS)] +#[derive(Debug, Default, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase", tag = "status")] #[ts(export)] #[ts(export_to = "cob/issue/")] @@ -88,7 +88,7 @@ impl From for State { } } -#[derive(Serialize, Deserialize, TS)] +#[derive(Debug, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export)] #[ts(export_to = "cob/issue/")] @@ -130,7 +130,7 @@ pub struct NewIssue { pub embeds: Vec, } -#[derive(Serialize, Deserialize, TS)] +#[derive(Debug, Serialize, Deserialize, TS)] #[serde(tag = "type", rename_all = "camelCase")] #[ts(export)] #[ts(export_to = "cob/issue/")] diff --git a/crates/radicle-types/src/cobs/patch.rs b/crates/radicle-types/src/cobs/patch.rs index bcda972da..96486d8c4 100644 --- a/crates/radicle-types/src/cobs/patch.rs +++ b/crates/radicle-types/src/cobs/patch.rs @@ -294,7 +294,7 @@ impl From for cob::patch::Verdict { } } -#[derive(Serialize, Deserialize, TS)] +#[derive(Debug, Serialize, Deserialize, TS)] #[serde(tag = "type", rename_all = "camelCase")] #[ts(export)] #[ts(export_to = "cob/patch/")] diff --git a/crates/radicle-types/src/cobs/stream.rs b/crates/radicle-types/src/cobs/stream.rs index 2e7c2e9ef..2bd3ff25c 100644 --- a/crates/radicle-types/src/cobs/stream.rs +++ b/crates/radicle-types/src/cobs/stream.rs @@ -1,16 +1,19 @@ -mod error; +pub mod error; mod iter; pub use iter::ActionsIter; use iter::Walk; +use std::fmt::Debug; use std::marker::PhantomData; +use radicle::cob::{ObjectId, TypeName}; +use radicle::git::Oid; +use radicle::profile::Aliases; +use radicle::storage::git::Repository; use serde::Deserialize; -use radicle::cob::{ObjectId, TypeName}; -use radicle::git::raw as git2; -use radicle::git::Oid; +use crate::domain::inbox::models::notification::ActionWithAuthor; /// Helper trait for anything can provide its initial commit. Generally, this is /// the root of a COB object. @@ -92,7 +95,8 @@ impl HasRoot for CobRange { /// /// To construct a `Stream`, use [`Stream::new`]. pub struct Stream<'a, A> { - repo: &'a git2::Repository, + repo: &'a Repository, + aliases: &'a Aliases, range: CobRange, typename: TypeName, marker: PhantomData, @@ -101,11 +105,17 @@ pub struct Stream<'a, A> { impl<'a, A> Stream<'a, A> { /// Construct a new stream providing the underlying `repo`, a [`CobRange`], /// and the [`TypeName`] of the COB that is being streamed. - pub fn new(repo: &'a git2::Repository, range: CobRange, typename: TypeName) -> Self { + pub fn new( + repo: &'a Repository, + range: CobRange, + typename: TypeName, + aliases: &'a Aliases, + ) -> Self { Self { repo, range, typename, + aliases, marker: PhantomData, } } @@ -120,9 +130,10 @@ impl<'a, A> HasRoot for Stream<'a, A> { impl<'a, A> CobStream for Stream<'a, A> where A: for<'de> Deserialize<'de>, + A: Debug, { type IterError = error::Actions; - type Action = A; + type Action = ActionWithAuthor; type Iter = ActionsIter<'a, A>; fn all(&self) -> Result { @@ -131,6 +142,8 @@ where .iter(self.repo) .map_err(error::Stream::new)?, self.typename.clone(), + self.repo, + self.aliases, )) } @@ -141,6 +154,8 @@ where .iter(self.repo) .map_err(error::Stream::new)?, self.typename.clone(), + self.repo, + self.aliases, )) } @@ -151,6 +166,8 @@ where .iter(self.repo) .map_err(error::Stream::new)?, self.typename.clone(), + self.repo, + self.aliases, )) } @@ -160,6 +177,8 @@ where .iter(self.repo) .map_err(error::Stream::new)?, self.typename.clone(), + self.repo, + self.aliases, )) } } diff --git a/crates/radicle-types/src/cobs/stream/iter.rs b/crates/radicle-types/src/cobs/stream/iter.rs index 92f5dc3c6..0027f92e3 100644 --- a/crates/radicle-types/src/cobs/stream/iter.rs +++ b/crates/radicle-types/src/cobs/stream/iter.rs @@ -1,12 +1,19 @@ +use std::fmt::Debug; use std::marker::PhantomData; use std::path::Path; use serde::Deserialize; use serde_json as json; -use radicle::cob::{Manifest, TypeName}; +use radicle::cob::change::Storage; +use radicle::cob::{Manifest, Op, TypeName}; use radicle::git::raw as git2; use radicle::git::{Oid, PatternString}; +use radicle::profile::Aliases; +use radicle::storage::git::Repository; + +use crate::cobs::Author; +use crate::domain::inbox::models::notification::ActionWithAuthor; use super::error; use super::CobRange; @@ -42,7 +49,7 @@ impl From for Until { /// from. pub(super) struct WalkIter<'a> { /// Git repository for looking up the commit object during the revwalk. - repo: &'a git2::Repository, + repo: &'a Repository, /// The root commit that is being walked from. /// /// N.b. This is required since ranges are non-inclusive in Git, and if the @@ -79,8 +86,8 @@ impl Walk { } /// Get the iterator for the walk. - pub(super) fn iter(self, repo: &git2::Repository) -> Result, git2::Error> { - let mut walk = repo.revwalk()?; + pub(super) fn iter(self, repo: &Repository) -> Result, git2::Error> { + let mut walk = repo.backend.revwalk()?; // N.b. ensure that we start from the `self.from` commit. walk.set_sorting(git2::Sort::TOPOLOGICAL.union(git2::Sort::REVERSE))?; match self.until { @@ -106,10 +113,10 @@ impl<'a> Iterator for WalkIter<'a> { // N.b. ensure that we start using the `from` commit and use the revwalk // after that. if let Some(from) = self.from.take() { - return Some(self.repo.find_commit(*from)); + return Some(self.repo.backend.find_commit(*from)); } let oid = self.inner.next()?; - Some(oid.and_then(|oid| self.repo.find_commit(oid))) + Some(oid.and_then(|oid| self.repo.backend.find_commit(oid))) } } @@ -124,14 +131,23 @@ pub struct ActionsIter<'a, A> { /// The walk can iterate over other COBs, e.g. an Identity COB, so this is /// used to filter for the correct type. typename: TypeName, + repo: &'a Repository, + aliases: &'a Aliases, } impl<'a, A> ActionsIter<'a, A> { - pub(super) fn new(walk: WalkIter<'a>, typename: TypeName) -> Self { + pub(super) fn new( + walk: WalkIter<'a>, + typename: TypeName, + repo: &'a Repository, + aliases: &'a Aliases, + ) -> Self { Self { walk, tree: None, typename, + repo, + aliases, } } @@ -147,7 +163,7 @@ impl<'a, A> ActionsIter<'a, A> { } }; let object = entry - .to_object(self.walk.repo) + .to_object(&self.walk.repo.backend) .map_err(|err| error::TreeAction::InvalidEntry { err })?; let blob = object .into_blob() @@ -169,8 +185,9 @@ impl<'a, A> ActionsIter<'a, A> { impl<'a, A> Iterator for ActionsIter<'a, A> where A: for<'de> Deserialize<'de>, + A: Debug, { - type Item = Result; + type Item = Result, error::Actions>; fn next(&mut self) -> Option { // Are we currently iterating over a tree? @@ -202,8 +219,13 @@ where } log::trace!(target: "radicle", "Iterating over commit {}", commit.id()); log::trace!(target: "radicle", "Iterating over tree {}", tree.id()); + + let entry = self.repo.load(commit.id().into()).ok()?; + let op = Op::from(entry); + let author = Author::new(&op.author.into(), self.aliases); // Set the tree iterator and walk over that - self.tree = Some(TreeActionsIter::new(self.walk.repo, tree)); + self.tree = + Some(TreeActionsIter::new(self.walk.repo, tree, op, author)); // Hide this commit so we do not double process it self.walk.inner.hide(commit.id()).ok(); self.next() @@ -227,9 +249,11 @@ where struct TreeActionsIter<'a, A> { /// The repository is required to get the underlying object of the tree /// entry. - repo: &'a git2::Repository, + repo: &'a Repository, /// The Git tree from which the actions are being extracted. tree: git2::Tree<'a>, + op: Op>, + author: Author, /// Use an index to keep track of which entry is being processed. Note that /// `TreeIter` is *not* used since it poses many borrow-checker challenge. /// Instead, `self.tree.iter()` is called and the iterator is indexed into. @@ -239,13 +263,15 @@ struct TreeActionsIter<'a, A> { } impl<'a, A> TreeActionsIter<'a, A> { - fn new(repo: &'a git2::Repository, tree: git2::Tree<'a>) -> Self + fn new(repo: &'a Repository, tree: git2::Tree<'a>, op: Op>, author: Author) -> Self where A: for<'de> Deserialize<'de>, { Self { repo, tree, + op, + author, index: 0, marker: PhantomData, } @@ -256,14 +282,15 @@ impl<'a, A> Iterator for TreeActionsIter<'a, A> where A: for<'de> Deserialize<'de>, { - type Item = Result; + type Item = Result, error::TreeAction>; fn next(&mut self) -> Option { let entry = self.tree.iter().nth(self.index)?; self.index += 1; // N.b. if `from_tree_entry` is `None` we have filtered the entry so we // go the `next` entry - from_tree_entry(self.repo, entry).or_else(|| self.next()) + from_tree_entry(self.repo, entry, self.op.clone(), self.author.clone()) + .or_else(|| self.next()) } } @@ -272,15 +299,17 @@ where /// /// The entry is only an action if it is a blob and its name is numerical. fn from_tree_entry( - repo: &git2::Repository, + repo: &Repository, entry: git2::TreeEntry, -) -> Option> + op: Op>, + author: Author, +) -> Option, error::TreeAction>> where A: for<'de> Deserialize<'de>, { - let as_action = |entry: git2::TreeEntry| -> Result { + let as_action = |entry: git2::TreeEntry| -> Result, error::TreeAction> { let object = entry - .to_object(repo) + .to_object(&repo.backend) .map_err(|err| error::TreeAction::InvalidEntry { err })?; let blob = object .into_blob() @@ -289,7 +318,7 @@ where .kind() .map_or("unknown".to_string(), |kind| kind.to_string()), })?; - action(&blob).map_err(error::TreeAction::from) + action(&blob, op, author).map_err(error::TreeAction::from) }; let name = entry.name()?; // An entry is only considered an action if it: @@ -301,10 +330,22 @@ where } /// Helper to deserialize an action from a blob's contents. -fn action(blob: &git2::Blob) -> Result +fn action( + blob: &git2::Blob, + op: Op>, + author: Author, +) -> Result, error::Action> where A: for<'de> Deserialize<'de>, { log::trace!(target: "radicle", "Deserializing action {}", blob.id()); - json::from_slice::(blob.content()).map_err(|err| error::Action::new(blob.id().into(), err)) + let action = json::from_slice::(blob.content()) + .map_err(|err| error::Action::new(blob.id().into(), err))?; + + Ok(ActionWithAuthor { + author, + timestamp: op.timestamp, + oid: op.id, + action, + }) } diff --git a/crates/radicle-types/src/cobs/thread.rs b/crates/radicle-types/src/cobs/thread.rs index 8d70375f6..5a862edf7 100644 --- a/crates/radicle-types/src/cobs/thread.rs +++ b/crates/radicle-types/src/cobs/thread.rs @@ -194,7 +194,7 @@ pub struct NewPatchComment { pub embeds: Vec, } -#[derive(Clone, TS, Serialize, Deserialize)] +#[derive(Debug, Clone, TS, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[ts(export)] #[ts(export_to = "cob/thread/")] @@ -239,7 +239,7 @@ impl From for cob::CodeLocation { } } -#[derive(Clone, TS, Serialize, Deserialize)] +#[derive(Debug, Clone, TS, Serialize, Deserialize)] #[serde(rename_all = "camelCase", tag = "type")] #[ts(export)] #[ts(export_to = "cob/thread/")] @@ -273,7 +273,7 @@ impl From for cob::CodeRange { } } -#[derive(TS, Clone, Deserialize, Serialize)] +#[derive(Debug, TS, Clone, Deserialize, Serialize)] #[ts(export)] #[ts(export_to = "cob/thread/")] pub struct Embed { diff --git a/crates/radicle-types/src/domain/inbox/models/notification.rs b/crates/radicle-types/src/domain/inbox/models/notification.rs index ac2f8f672..2b11b2847 100644 --- a/crates/radicle-types/src/domain/inbox/models/notification.rs +++ b/crates/radicle-types/src/domain/inbox/models/notification.rs @@ -1,11 +1,33 @@ -use radicle::{git, identity, node, storage}; +use std::fmt::Debug; + +use radicle::cob::Timestamp; +use radicle::node::notifications::NotificationId; +use radicle::profile::Aliases; +use radicle::{cob, git, identity, node, storage}; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::cobs::stream::{self, CobStream}; +use crate::cobs::{self, Author}; + +#[derive(Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[serde(tag = "type", content = "content")] +#[ts(export)] +#[ts(export_to = "cob/inbox/")] +pub enum SetStatusNotifications { + Ids(Vec), + Repo(#[ts(as = "String")] identity::RepoId), + All, +} #[derive(Debug, PartialEq, Eq, Clone, serde::Serialize, serde::Deserialize)] #[serde(rename = "camelCase")] pub struct NotificationRow { pub row_id: node::notifications::NotificationId, pub timestamp: localtime::LocalTime, - pub remote: Option, + /// Node Id that provided us with this notification. + pub remote: storage::RemoteId, pub old: Option, pub new: Option, } @@ -18,9 +40,11 @@ pub struct CountsByRepoParams { pub repo: identity::RepoId, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, serde::Deserialize)] pub struct RepoGroupParams { pub repo: identity::RepoId, + pub skip: Option, + pub take: Option, } #[derive(Debug, thiserror::Error)] @@ -34,6 +58,9 @@ pub enum ListNotificationsError { #[error(transparent)] Sqlite(#[from] sqlite::Error), + #[error(transparent)] + CobStream(#[from] stream::error::Stream), + #[error(transparent)] NotificationKindError(#[from] node::notifications::NotificationKindError), @@ -41,3 +68,249 @@ pub enum ListNotificationsError { Unknown(#[from] anyhow::Error), // to be extended as new error scenarios are introduced } + +pub fn actions( + typename: cob::TypeName, + oid: cob::ObjectId, + from: Option, + until: Option, + repo: &storage::git::Repository, + aliases: &Aliases, +) -> Result>, ListNotificationsError> +where + A: serde::Serialize, + A: for<'de> serde::Deserialize<'de>, + A: Debug, +{ + let history = stream::CobRange::new(&typename, &oid); + let stream = stream::Stream::::new(repo, history, typename, aliases); + let iter = match (from, until) { + (None, None) => stream.all()?, + (None, Some(until)) => stream.until(until)?, + (Some(from), None) => stream.since(from)?, + (Some(from), Some(until)) => stream.range(from, until)?, + }; + + Ok(iter.filter_map(|a| a.ok()).collect::>()) +} + +#[derive(Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +#[ts(export_to = "cob/inbox/")] +pub struct NotificationCount { + #[ts(as = "String")] + pub rid: identity::RepoId, + pub name: String, + #[ts(type = "number")] + pub count: usize, +} + +#[derive(Debug, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[serde(tag = "type")] +#[ts(export)] +#[ts(export_to = "cob/inbox/")] +pub enum NotificationItem { + Issue(Issue), + Patch(Patch), +} + +#[derive(Debug, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "cob/inbox/")] +pub struct Issue { + #[ts(as = "String")] + pub row_id: NotificationId, + #[ts(as = "String")] + pub id: cob::ObjectId, + pub update: RefUpdate, + pub title: String, + #[ts(type = "number")] + pub timestamp: localtime::LocalTime, + pub status: cobs::issue::State, + pub actions: Vec>, +} + +#[derive(Debug, Serialize, TS, Deserialize)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "cob/inbox/")] +pub struct ActionWithAuthor { + #[ts(as = "String")] + pub oid: git::Oid, + #[ts(type = "number")] + pub timestamp: Timestamp, + pub author: Author, + #[serde(flatten)] + pub action: T, +} + +#[derive(Debug, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "cob/inbox/")] +pub struct Patch { + #[ts(as = "String")] + pub row_id: NotificationId, + #[ts(as = "String")] + pub id: cob::ObjectId, + pub update: RefUpdate, + #[ts(type = "number")] + pub timestamp: localtime::LocalTime, + pub title: String, + pub status: cobs::patch::State, + pub actions: Vec>, +} + +/// Type of notification. +#[derive(Debug, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[serde(tag = "type")] +pub enum NotificationKind { + /// A COB changed. + Cob { + #[serde(flatten)] + typed_id: TypedId, + }, + /// A source branch changed. + Branch { + #[ts(as = "String")] + name: git::BranchName, + }, + /// Unknown reference. + Unknown { + #[ts(as = "String")] + refname: git::Qualified<'static>, + }, +} + +impl From for NotificationKind { + fn from(value: node::notifications::NotificationKind) -> Self { + match value { + node::notifications::NotificationKind::Branch { name } => Self::Branch { name }, + node::notifications::NotificationKind::Cob { typed_id } => Self::Cob { + typed_id: typed_id.into(), + }, + node::notifications::NotificationKind::Unknown { refname } => Self::Unknown { refname }, + } + } +} + +impl From for node::notifications::NotificationKind { + fn from(value: NotificationKind) -> Self { + match value { + NotificationKind::Branch { name } => { + node::notifications::NotificationKind::Branch { name } + } + NotificationKind::Cob { typed_id } => node::notifications::NotificationKind::Cob { + typed_id: typed_id.into(), + }, + NotificationKind::Unknown { refname } => { + node::notifications::NotificationKind::Unknown { refname } + } + } + } +} + +/// The exact identifier for a particular COB. +#[derive(Debug, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +#[ts(export_to = "cob/inbox/")] +pub struct TypedId { + #[ts(as = "String")] + pub id: cob::ObjectId, + #[ts(as = "String")] + pub type_name: cob::TypeName, +} + +impl From for TypedId { + fn from(value: cob::TypedId) -> Self { + Self { + id: value.id, + type_name: value.type_name, + } + } +} + +impl From for cob::TypedId { + fn from(value: TypedId) -> Self { + Self { + id: value.id, + type_name: value.type_name, + } + } +} + +#[derive(Debug, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[serde(tag = "type")] +#[ts(export)] +#[ts(export_to = "cob/inbox/")] +pub enum RefUpdate { + Updated { + #[ts(as = "String")] + name: git::RefString, + #[ts(as = "String")] + old: git::Oid, + #[ts(as = "String")] + new: git::Oid, + }, + Created { + #[ts(as = "String")] + name: git::RefString, + #[ts(as = "String")] + oid: git::Oid, + }, + Deleted { + #[ts(as = "String")] + name: git::RefString, + #[ts(as = "String")] + oid: git::Oid, + }, + Skipped { + #[ts(as = "String")] + name: git::RefString, + #[ts(as = "String")] + oid: git::Oid, + }, +} + +impl From for RefUpdate { + fn from(value: storage::RefUpdate) -> Self { + match value { + storage::RefUpdate::Updated { name, old, new } => RefUpdate::Updated { name, old, new }, + storage::RefUpdate::Created { name, oid } => RefUpdate::Created { name, oid }, + storage::RefUpdate::Deleted { name, oid } => RefUpdate::Deleted { name, oid }, + storage::RefUpdate::Skipped { name, oid } => RefUpdate::Skipped { name, oid }, + } + } +} + +impl From for storage::RefUpdate { + fn from(value: RefUpdate) -> Self { + match value { + RefUpdate::Updated { name, old, new } => storage::RefUpdate::Updated { name, old, new }, + RefUpdate::Created { name, oid } => storage::RefUpdate::Created { name, oid }, + RefUpdate::Deleted { name, oid } => storage::RefUpdate::Deleted { name, oid }, + RefUpdate::Skipped { name, oid } => storage::RefUpdate::Skipped { name, oid }, + } + } +} + +impl From<(git::RefString, Option, Option)> for RefUpdate { + fn from((name, new, old): (git::RefString, Option, Option)) -> Self { + match (new, old) { + (None, Some(b)) => RefUpdate::Deleted { name, oid: b }, + (Some(a), None) => RefUpdate::Created { name, oid: a }, + (Some(a), Some(b)) if a != b => RefUpdate::Updated { + name, + old: a, + new: b, + }, + _ => RefUpdate::Skipped { + name, + oid: new.unwrap_or(radicle::git::raw::Oid::zero().into()), + }, + } + } +} diff --git a/crates/radicle-types/src/error.rs b/crates/radicle-types/src/error.rs index c231f88bc..7c70866a6 100644 --- a/crates/radicle-types/src/error.rs +++ b/crates/radicle-types/src/error.rs @@ -3,12 +3,20 @@ use axum::http::{Response, StatusCode}; use axum::response::IntoResponse; use serde::Serialize; +use crate::cobs::stream; + #[derive(Debug, thiserror::Error)] pub enum Error { /// Profile error. #[error(transparent)] Profile(#[from] radicle::profile::Error), + /// List notification error. + #[error(transparent)] + ListNotificationsError( + #[from] crate::domain::inbox::models::notification::ListNotificationsError, + ), + /// CobStore error. #[error(transparent)] CobStore(#[from] radicle::cob::store::Error), @@ -21,10 +29,22 @@ pub enum Error { #[error(transparent)] Io(#[from] std::io::Error), - /// Io error. + /// Sqlite error. #[error(transparent)] Sqlite(#[from] sqlite::Error), + /// Io error. + #[error(transparent)] + Actions(#[from] stream::error::Actions), + + /// CobStream error. + #[error(transparent)] + CobStream(#[from] stream::error::Stream), + + /// Inbox error. + #[error(transparent)] + Inbox(#[from] radicle::node::notifications::Error), + /// Crypto error. #[error(transparent)] Crypto(#[from] radicle::crypto::ssh::keystore::Error), diff --git a/crates/radicle-types/src/outbound/sqlite.rs b/crates/radicle-types/src/outbound/sqlite.rs index 0be944441..c94e77e79 100644 --- a/crates/radicle-types/src/outbound/sqlite.rs +++ b/crates/radicle-types/src/outbound/sqlite.rs @@ -3,7 +3,6 @@ use std::path::Path; use std::sync::Arc; use std::time; -use radicle::git::Qualified; use radicle::{git, identity}; use sqlite as sql; @@ -38,8 +37,9 @@ impl InboxStorage for Sqlite { notification::ListNotificationsError, > { let stmt = self.db.prepare( - "SELECT repo, COUNT(*) as count + "SELECT ref, repo, COUNT(*) as count FROM `repository-notifications` + WHERE ref LIKE '%cobs%' GROUP BY repo", )?; @@ -79,7 +79,7 @@ impl InboxStorage for Sqlite { Ok((reference.to_owned(), items)) }) .collect::, Vec>, + BTreeMap, Vec>, notification::ListNotificationsError, >>() } diff --git a/crates/radicle-types/src/repo.rs b/crates/radicle-types/src/repo.rs index 5ce6073f9..4be65ef13 100644 --- a/crates/radicle-types/src/repo.rs +++ b/crates/radicle-types/src/repo.rs @@ -9,6 +9,17 @@ use radicle::{git, identity, issue, patch}; use crate::cobs::Author; use crate::error; +#[derive(Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +#[ts(export_to = "repo/")] +pub struct RepoCount { + pub total: usize, + pub delegate: usize, + pub private: usize, + pub seeding: usize, +} + #[derive(Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export)] diff --git a/crates/radicle-types/src/traits/repo.rs b/crates/radicle-types/src/traits/repo.rs index b62d033d5..752f30439 100644 --- a/crates/radicle-types/src/traits/repo.rs +++ b/crates/radicle-types/src/traits/repo.rs @@ -12,7 +12,7 @@ use radicle::{git, identity}; use crate::cobs; use crate::diff::Diff; use crate::error::Error; -use crate::repo; +use crate::repo::{self, RepoCount}; use crate::syntax::{Highlighter, ToPretty}; use crate::traits::Profile; @@ -22,6 +22,7 @@ pub enum Show { Delegate, All, Seeded, + Private, } pub trait Repo: Profile { @@ -41,6 +42,10 @@ pub trait Repo: Profile { continue; } + if !doc.is_private() && show == Show::Private { + continue; + } + if !doc.delegates().contains(&profile.public_key.into()) && show == Show::Delegate { continue; } @@ -56,6 +61,42 @@ pub trait Repo: Profile { Ok::<_, Error>(entries) } + fn repo_count(&self) -> Result { + let profile = self.profile(); + let storage = &profile.storage; + let policies = profile.policies()?; + let repos = storage.repositories()?; + let mut total = 0; + let mut delegate = 0; + let mut private = 0; + let mut seeding = 0; + + for RepositoryInfo { rid, doc, refs, .. } in repos { + if policies.is_seeding(&rid)? { + seeding += 1; + } + + if doc.is_private() { + private += 1; + } + + if doc.delegates().contains(&profile.public_key.into()) { + delegate += 1; + } + + if refs.is_some() { + total += 1; + } + } + + Ok::<_, Error>(RepoCount { + total, + seeding, + private, + delegate, + }) + } + fn repo_by_id(&self, rid: identity::RepoId) -> Result { let profile = self.profile(); let repo = profile.storage.repository(rid)?; diff --git a/crates/test-http-api/src/api.rs b/crates/test-http-api/src/api.rs index 16e5d59e9..db5e33a29 100644 --- a/crates/test-http-api/src/api.rs +++ b/crates/test-http-api/src/api.rs @@ -8,6 +8,7 @@ use axum::routing::post; use axum::Router; use hyper::header::CONTENT_TYPE; use hyper::Method; +use radicle_types::domain::inbox::models::notification::NotificationCount; use serde::{Deserialize, Serialize}; use tower_http::cors::{self, CorsLayer}; @@ -55,6 +56,11 @@ pub fn router(ctx: Context) -> Router { Router::new() .route("/config", post(config_handler)) .route("/authenticate", post(auth_handler)) + .route( + "/count_notifications_by_repo", + post(repo_count_notifications_handler), + ) + .route("/repo_count", post(repo_count_handler)) .route("/list_repos", post(repo_root_handler)) .route("/repo_by_id", post(repo_handler)) .route("/diff_stats", post(diff_stats_handler)) @@ -116,6 +122,15 @@ async fn repo_root_handler( Ok::<_, Error>(Json(repos)) } +async fn repo_count_handler(State(ctx): State) -> impl IntoResponse { + let repos = ctx.repo_count()?; + Ok::<_, Error>(Json(repos)) +} + +async fn repo_count_notifications_handler() -> impl IntoResponse { + Ok::<_, Error>(Json(Vec::::new())) +} + #[derive(Serialize, Deserialize)] struct RepoBody { pub rid: identity::RepoId, diff --git a/package-lock.json b/package-lock.json index 96e453d50..334af3f8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "eslint-plugin-svelte": "^2.46.1", "execa": "^9.5.2", "get-port": "^7.1.0", + "happy-dom": "^16.7.2", "hast-util-to-dom": "^4.0.0", "lodash": "^4.17.21", "marked": "^15.0.4", @@ -55,6 +56,7 @@ "typescript": "^5.7.2", "typescript-eslint": "^8.18.1", "vite": "^6.0.3", + "vitest": "^3.0.3", "wait-on": "^8.0.1" }, "engines": { @@ -1598,6 +1600,112 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@vitest/expect": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.3.tgz", + "integrity": "sha512-SbRCHU4qr91xguu+dH3RUdI5dC86zm8aZWydbp961aIR7G8OYNN6ZiayFuf9WAngRbFOfdrLHCGgXTj3GtoMRQ==", + "dev": true, + "dependencies": { + "@vitest/spy": "3.0.3", + "@vitest/utils": "3.0.3", + "chai": "^5.1.2", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.3.tgz", + "integrity": "sha512-XT2XBc4AN9UdaxJAeIlcSZ0ILi/GzmG5G8XSly4gaiqIvPV3HMTSIDZWJVX6QRJ0PX1m+W8Cy0K9ByXNb/bPIA==", + "dev": true, + "dependencies": { + "@vitest/spy": "3.0.3", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.3.tgz", + "integrity": "sha512-gCrM9F7STYdsDoNjGgYXKPq4SkSxwwIU5nkaQvdUxiQ0EcNlez+PdKOVIsUJvh9P9IeIFmjn4IIREWblOBpP2Q==", + "dev": true, + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.3.tgz", + "integrity": "sha512-Rgi2kOAk5ZxWZlwPguRJFOBmWs6uvvyAAR9k3MvjRvYrG7xYvKChZcmnnpJCS98311CBDMqsW9MzzRFsj2gX3g==", + "dev": true, + "dependencies": { + "@vitest/utils": "3.0.3", + "pathe": "^2.0.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.3.tgz", + "integrity": "sha512-kNRcHlI4txBGztuJfPEJ68VezlPAXLRT1u5UCx219TU3kOG2DplNxhWLwDf2h6emwmTPogzLnGVwP6epDaJN6Q==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "3.0.3", + "magic-string": "^0.30.17", + "pathe": "^2.0.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.3.tgz", + "integrity": "sha512-7/dgux8ZBbF7lEIKNnEqQlyRaER9nkAL9eTmdKJkDO3hS8p59ATGwKOCUDHcBLKr7h/oi/6hP+7djQk8049T2A==", + "dev": true, + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.3.tgz", + "integrity": "sha512-f+s8CvyzPtMFY1eZKkIHGhPsQgYo5qCm6O8KZoim9qm1/jT64qBgGpO5tHscNH6BzRHM+edLNOP+3vO8+8pE/A==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "3.0.3", + "loupe": "^3.1.2", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@wooorm/starry-night": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/@wooorm/starry-night/-/starry-night-3.5.0.tgz", @@ -1690,6 +1798,15 @@ "node": ">= 0.4" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -1809,6 +1926,15 @@ "ieee754": "^1.2.1" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1818,6 +1944,22 @@ "node": ">=6" } }, + "node_modules/chai": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.2.tgz", + "integrity": "sha512-aGtmf24DW6MLHHG5gCx4zaI3uBq3KRtxeVs0DjFH6Z0rDNbsvTxFASFvdj79pxjxZ8/5u3PIiN3IwEIQkiiuPw==", + "dev": true, + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/chalk": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", @@ -1830,6 +1972,15 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.2.tgz", @@ -1934,6 +2085,15 @@ } } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1967,6 +2127,12 @@ "@types/trusted-types": "^2.0.7" } }, + "node_modules/es-module-lexer": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz", + "integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==", + "dev": true + }, "node_modules/esbuild": { "version": "0.24.0", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.0.tgz", @@ -2248,6 +2414,15 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2283,6 +2458,15 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/expect-type": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.1.0.tgz", + "integrity": "sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extend-shallow": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", @@ -2573,6 +2757,19 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "node_modules/happy-dom": { + "version": "16.7.2", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-16.7.2.tgz", + "integrity": "sha512-zOzw0xyYlDaF/ylwbAsduYZZVRTd5u7IwlFkGbEathIeJMLp3vrN3cHm3RS7PZpD9gr/IO16bHEswcgNyWTsqw==", + "dev": true, + "dependencies": { + "webidl-conversions": "^7.0.0", + "whatwg-mimetype": "^3.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2929,6 +3126,12 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/loupe": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.2.tgz", + "integrity": "sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==", + "dev": true + }, "node_modules/magic-string": { "version": "0.30.17", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", @@ -3226,6 +3429,21 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.2.tgz", + "integrity": "sha512-15Ztpk+nov8DR524R4BF7uEuzESgzUEAV4Ah7CUMNGXdE5ELuvxElxGXndBl32vMSsWa1jpNf22Z+Er3sKwq+w==", + "dev": true + }, + "node_modules/pathval": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", + "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", + "dev": true, + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3651,6 +3869,12 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -3672,6 +3896,18 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, + "node_modules/std-env": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.8.0.tgz", + "integrity": "sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w==", + "dev": true + }, "node_modules/strip-bom-string": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", @@ -3836,6 +4072,45 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true + }, + "node_modules/tinypool": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.2.tgz", + "integrity": "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -4050,6 +4325,28 @@ } } }, + "node_modules/vite-node": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.3.tgz", + "integrity": "sha512-0sQcwhwAEw/UJGojbhOrnq3HtiZ3tC7BzpAa0lx3QaTX0S3YX70iGcik25UBdB96pmdwjyY2uyKNYruxCDmiEg==", + "dev": true, + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.0", + "es-module-lexer": "^1.6.0", + "pathe": "^2.0.1", + "vite": "^5.0.0 || ^6.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/vite/node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -4078,6 +4375,71 @@ } } }, + "node_modules/vitest": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.3.tgz", + "integrity": "sha512-dWdwTFUW9rcnL0LyF2F+IfvNQWB0w9DERySCk8VMG75F8k25C7LsZoh6XfCjPvcR8Nb+Lqi9JKr6vnzH7HSrpQ==", + "dev": true, + "dependencies": { + "@vitest/expect": "3.0.3", + "@vitest/mocker": "3.0.3", + "@vitest/pretty-format": "^3.0.3", + "@vitest/runner": "3.0.3", + "@vitest/snapshot": "3.0.3", + "@vitest/spy": "3.0.3", + "@vitest/utils": "3.0.3", + "chai": "^5.1.2", + "debug": "^4.4.0", + "expect-type": "^1.1.0", + "magic-string": "^0.30.17", + "pathe": "^2.0.1", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinypool": "^1.0.2", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0", + "vite-node": "3.0.3", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.0.3", + "@vitest/ui": "3.0.3", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/vscode-oniguruma": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-2.0.1.tgz", @@ -4119,6 +4481,24 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "engines": { + "node": ">=12" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4134,6 +4514,22 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index a918d4be3..65458c761 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "check": "scripts/check-js && scripts/check-rs", "check-js": "scripts/check-js", "check-rs": "scripts/check-rs", + "test:unit": "TZ='UTC' vitest run", "test:e2e": "TZ='UTC' playwright test", "format": "npx prettier '**/*.@(ts|js|svelte|json|css|html|yml)' --write", "generate-types": "cargo test --manifest-path ./crates/radicle-types/Cargo.toml", @@ -52,6 +53,7 @@ "eslint-plugin-svelte": "^2.46.1", "execa": "^9.5.2", "get-port": "^7.1.0", + "happy-dom": "^16.7.2", "hast-util-to-dom": "^4.0.0", "lodash": "^4.17.21", "marked": "^15.0.4", @@ -69,6 +71,7 @@ "typescript": "^5.7.2", "typescript-eslint": "^8.18.1", "vite": "^6.0.3", + "vitest": "^3.0.3", "wait-on": "^8.0.1" } } diff --git a/src/App.svelte b/src/App.svelte index 4cd783006..ce8d1ca42 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -13,11 +13,12 @@ import AuthenticationError from "@app/views/AuthenticationError.svelte"; import CreateIssue from "@app/views/repo/CreateIssue.svelte"; - import Home from "@app/views/Home.svelte"; + import Inbox from "./views/home/Inbox.svelte"; import Issue from "@app/views/repo/Issue.svelte"; import Issues from "@app/views/repo/Issues.svelte"; import Patch from "@app/views/repo/Patch.svelte"; import Patches from "@app/views/repo/Patches.svelte"; + import Repos from "./views/home/Repos.svelte"; import { dynamicInterval, checkAuth } from "./lib/auth"; const activeRouteStore = router.activeRouteStore; @@ -71,7 +72,9 @@ {#if $activeRouteStore.resource === "booting"} {:else if $activeRouteStore.resource === "home"} - + +{:else if $activeRouteStore.resource === "inbox"} + {:else if $activeRouteStore.resource === "repo.createIssue"} {:else if $activeRouteStore.resource === "repo.issue"} diff --git a/src/components/Avatar.svelte b/src/components/Avatar.svelte index e866252a4..c732ede59 100644 --- a/src/components/Avatar.svelte +++ b/src/components/Avatar.svelte @@ -1,11 +1,7 @@ + + + +
+
+
+ {#if activeTab.type === "inbox"} + + + +
router.push({ resource: "inbox" })}> +
Inbox
+
+ {sum(Array.from(notificationCount.values()).map(c => c.count))} +
+
+ {#each notificationCount.entries() as [_, n]} + + +
+ router.push({ + resource: "inbox", + activeTab: n, + })} + class:active={activeTab.repo?.rid === n.rid}> +
+ {n.name} +
+
+ {n.count} +
+
+ {/each} +
+ {:else} + + +
router.push({ resource: "inbox" })} + style:color="var(--color-foreground-contrast)" + style:padding-left="12px"> +
Inbox
+
+ {sum(Array.from(notificationCount.values()).map(c => c.count))} +
+
+ {/if} +
+ + {#if activeTab.type === "repos"} + + + +
router.push({ resource: "home" })}> +
Repositories
+
+ {repoCount.total} +
+
+ + +
+ router.push({ + resource: "home", + activeTab: "delegate", + })}> +
+ +
Delegate
+
+
{repoCount.delegate}
+
+ + +
+ router.push({ + resource: "home", + activeTab: "private", + })}> +
+ +
Private
+
+
{repoCount.private}
+
+
+ {:else} + + +
+
+ + Repositories +
+
+ {repoCount.total} +
+
+ +
+ {/if} +
+ + +
diff --git a/src/components/Icon.svelte b/src/components/Icon.svelte index be1684e2d..c50f934b2 100644 --- a/src/components/Icon.svelte +++ b/src/components/Icon.svelte @@ -3,7 +3,7 @@ interface Props { size?: "16" | "32"; - onclick?: () => void; + onclick?: (e: MouseEvent) => void; disabled?: boolean; styleDisplay?: string; styleVerticalAlign?: string; @@ -12,6 +12,9 @@ | "arrow-right" | "arrow-right-hollow" | "attachment" + | "branch" + | "broom" + | "broom-double" | "checkmark" | "chevron-down" | "chevron-right" @@ -95,9 +98,9 @@ class:hoverable={onclick} class:disabled role="img" - onclick={() => { + onclick={e => { if (onclick && !disabled) { - onclick(); + onclick(e); } }} aria-label={`icon-${name}`} @@ -156,6 +159,47 @@ + {:else if name === "branch"} + + + {:else if name === "broom"} + + + + + + + + + + + + + {:else if name === "broom-double"} + + + + + + + + + + + + + + + + + + + + + + + {:else if name === "checkmark"} diff --git a/src/components/Id.svelte b/src/components/Id.svelte index d90e94b9f..32749505a 100644 --- a/src/components/Id.svelte +++ b/src/components/Id.svelte @@ -1,7 +1,7 @@ + + + + +
{ + if (route) void push(route); + }}> +
+
+
+ +
+
+ {#if title} + + {/if} +
+ {#each uniqueActions as action} +
+ + {@html action.summary} + {formatTimestamp(action.items[0].timestamp)} +
+ {/each} +
+
+
+
+
+ { + e.stopPropagation(); + void clearByIds( + rid, + notificationItems.map(n => n.rowId), + ); + }} + name={clearIcon} /> +
+
+
+
diff --git a/src/components/RepoNotifications.svelte b/src/components/RepoNotifications.svelte new file mode 100644 index 000000000..8ad13949b --- /dev/null +++ b/src/components/RepoNotifications.svelte @@ -0,0 +1,74 @@ + + + + +{#if Object.entries(items).length > 0} +
+
+ + {repo.name} + + {repo.count} +
+ clearByRepo(repo.rid)} name="broom-double" /> +
+{/if} + +
+ {#each Object.entries(items).sort((a, b) => b[1][0].timestamp - a[1][0].timestamp) as [_, notificationItems]} + + {/each} +
+{#if all === false && more} +
+
+ +
+
+{/if} diff --git a/src/lib/notification.ts b/src/lib/notification.ts new file mode 100644 index 000000000..deb6553b1 --- /dev/null +++ b/src/lib/notification.ts @@ -0,0 +1,188 @@ +import type { ActionWithAuthor } from "@bindings/cob/inbox/ActionWithAuthor"; +import type { Action as IssueAction } from "@bindings/cob/issue/Action"; +import type { Action as PatchAction } from "@bindings/cob/patch/Action"; + +import { pluralize, formatOid } from "@app/lib/utils"; + +export type Action = + | ActionWithAuthor + | ActionWithAuthor; + +// N.b. I have taken the `%` char as indicator for a `global-oid` class +export function createSummary( + a: Action[], + kind: "issue" | "patch", + oid: string, + count: number, +) { + const lastAction = a[a.length - 1]; + let summary = `${lastAction.type} not implemented!`; + + function times(count: number) { + return count > 1 ? `${count} times` : ""; + } + + if (lastAction.oid === oid) { + summary = `opened ${kind} %${formatOid(lastAction.oid)}%`; + } else if (lastAction.type === "comment") { + summary = `left ${count > 1 ? count : "a"} ${pluralize("comment", count)}`; + } else if (lastAction.type === "revision") { + const revisions = a.map(i => `%${formatOid(i.oid)}%`).slice(0, 10); + summary = `created ${pluralize("revision", count)} ${[ + ...revisions, + ...(a.length >= 11 ? ["%…%"] : []), + ].join(", ")}`; + } else if (lastAction.type === "merge") { + summary = `merged ${pluralize("revision", count)} %${formatOid(lastAction.revision)}%`; + } else if (lastAction.type === "edit" && kind === "issue") { + summary = `edited issue${count ? times(count) : ""}`; + } else if (lastAction.type === "edit" && kind === "patch") { + summary = `edited ${pluralize("revision", count)} %${formatOid(lastAction.oid)}%`; + } else if (lastAction.type === "revision.edit") { + summary = `edited ${pluralize("revision", count)} %${formatOid(lastAction.revision)}%`; + } else if (lastAction.type === "lifecycle" && count > 1) { + summary = `changed to ${lastAction.state.status} and ${count} more changes`; + } else if ( + lastAction.type === "lifecycle" && + lastAction.state.status === "draft" + ) { + summary = `changed to ${lastAction.state.status}`; + } else if ( + lastAction.type === "lifecycle" && + lastAction.state.status === "open" + ) { + summary = `reopened ${kind}`; + } else if ( + lastAction.type === "lifecycle" && + lastAction.state.status === "archived" + ) { + summary = `archived ${kind}`; + } else if ( + lastAction.type === "lifecycle" && + lastAction.state.status === "closed" && + lastAction.state.reason === "solved" + ) { + summary = `closed ${kind} as solved`; + } else if ( + lastAction.type === "lifecycle" && + lastAction.state.status === "closed" + ) { + summary = `closed ${kind}`; + } else if (lastAction.type === "revision.comment") { + summary = `left ${count > 1 ? count : "a"} review ${pluralize("comment", count)}`; + } else if (lastAction.type === "review.comment") { + summary = `left ${count > 1 ? count : "a"} review ${pluralize("comment", count)}`; + } else if (a.every(e => e.type === "comment.react")) { + const reactions = a.map(i => i.reaction).slice(0, 10); + summary = `reacted with ${[ + ...reactions, + ...(a.length >= 11 ? ["%…%"] : []), + ].join(", ")} to ${count > 1 ? count : "a"} ${pluralize("comment", count)}`; + } else if (lastAction.type === "comment.edit") { + summary = `edited ${count > 1 ? count : "a"} ${pluralize("comment", count)}`; + } else if (lastAction.type === "review" && lastAction.verdict) { + summary = `${lastAction.verdict}ed revision %${formatOid(lastAction.revision)}% with a review`; + } else if (lastAction.type === "review") { + summary = `left a review with a comment on revision %${formatOid(lastAction.revision)}%`; + } else if (lastAction.type === "assign") { + summary = "changed assignes"; + } else if (lastAction.type === "revision.comment.edit") { + summary = `edited ${count > 1 ? count : "a"} ${pluralize("comment", count)}`; + } else if (lastAction.type === "comment.redact") { + summary = `redacted ${count > 1 ? count : "a"} ${pluralize("comment", count)}`; + } else if (lastAction.type === "label") { + summary = `added ${pluralize("label", count)}`; + } else if (lastAction.type === "review.comment.edit") { + summary = `edited ${count > 1 ? count : "a"} review ${pluralize("comment", count)}`; + } else if (lastAction.type === "review.comment.react") { + summary = `reacted to ${count > 1 ? count : "a"} review ${pluralize("comment", count)}`; + } else if (lastAction.type === "review.comment.redact") { + summary = `redacted ${count > 1 ? count : "a"} review ${pluralize("comment", count)}`; + } else if (lastAction.type === "review.comment.resolve") { + summary = `resolved ${count > 1 ? count : "a"} review ${pluralize("comment", count)}`; + } else if (lastAction.type === "review.comment.unresolve") { + summary = `unresolved ${count > 1 ? count : "a"} review ${pluralize("comment", count)}`; + } else if (lastAction.type === "review.edit") { + summary = `edited ${count > 1 ? count : "a"} ${pluralize("review", count)}`; + } else if (lastAction.type === "review.redact") { + summary = `redacted ${count > 1 ? count : "a"} ${pluralize("review", count)}`; + } else if (lastAction.type === "revision.comment.react") { + summary = `reacted to ${count > 1 ? count : "a"} revision ${pluralize("comment", count)}`; + } else if (lastAction.type === "revision.comment.redact") { + summary = `redacted ${count > 1 ? count : "a"} revision ${pluralize("comment", count)}`; + } else if (lastAction.type === "revision.react") { + summary = `reacted to ${count > 1 ? count : "a"} ${pluralize("revision", count)}`; + } else if (lastAction.type === "revision.redact") { + summary = `redacted ${count > 1 ? count : "a"} ${pluralize("revision", count)}`; + } + + return summary.replaceAll( + /[%](\S+)[%]/g, + '$1', + ); +} + +export function compressActions( + actions: Action[], + kind: "issue" | "patch", + oid: string, +) { + const result: { + summary: string; + oid: string; + items: Action[]; + }[] = []; + + let currentGroup: Action[] = []; + + for (const action of actions) { + if (currentGroup.length === 0) { + currentGroup.push(action); + continue; + } + + const last = currentGroup[currentGroup.length - 1]; + const sameAuthorDid = last.author.did === action.author.did; + const sameType = + last.type === action.type || + (last.type === "revision.edit" && action.type === "edit") || + (action.type === "revision.edit" && last.type === "edit") || + action.oid === oid; + + if (sameAuthorDid && sameType) { + currentGroup.push(action); + } else { + const summaryStr = createSummary( + currentGroup, + kind, + oid, + currentGroup.length, + ); + + result.push({ + summary: summaryStr, + oid, + items: currentGroup, + }); + + currentGroup = [action]; + } + } + + // Summarize any open actions. + if (currentGroup.length > 0) { + const summaryStr = createSummary( + currentGroup, + kind, + oid, + currentGroup.length, + ); + result.push({ + summary: summaryStr, + oid, + items: currentGroup, + }); + } + + return result; +} diff --git a/src/lib/router.ts b/src/lib/router.ts index 504dbd0ca..b9a2cae3b 100644 --- a/src/lib/router.ts +++ b/src/lib/router.ts @@ -103,7 +103,12 @@ function urlToRoute(url: URL): Route | null { switch (resource) { case "": { - return { resource: "home" }; + return { + resource: "home", + }; + } + case "inbox": { + return { resource: "inbox" }; } case "repos": { return repoUrlToRoute(segments, url.searchParams); @@ -120,6 +125,8 @@ function urlToRoute(url: URL): Route | null { export function routeToPath(route: Route): string { if (route.resource === "home") { return "/"; + } else if (route.resource === "inbox") { + return "/inbox"; } else if (route.resource === "authenticationError") { return "/authenticationError"; } else if ( diff --git a/src/lib/router/definitions.ts b/src/lib/router/definitions.ts index b94cd2cfe..9acc7416d 100644 --- a/src/lib/router/definitions.ts +++ b/src/lib/router/definitions.ts @@ -1,8 +1,13 @@ import type { Config } from "@bindings/config/Config"; +import type { NotificationItem } from "@bindings/cob/inbox/NotificationItem"; +import type { PaginatedQuery } from "@bindings/cob/PaginatedQuery"; +import type { NotificationCount } from "@bindings/cob/inbox/NotificationCount"; import type { RepoInfo } from "@bindings/repo/RepoInfo"; +import type { RepoCount } from "@bindings/repo/RepoCount"; import type { LoadedRepoRoute, RepoRoute } from "@app/views/repo/router"; import { invoke } from "@app/lib/invoke"; +import { SvelteMap } from "svelte/reactivity"; import { loadCreateIssue, @@ -12,6 +17,14 @@ import { loadPatches, } from "@app/views/repo/router"; +export type HomeReposTab = "delegate" | "private"; + +export interface HomeInboxTab { + rid: string; + name: string; + count: number; +} + interface BootingRoute { resource: "booting"; } @@ -26,21 +39,53 @@ interface AuthenticationErrorRoute { interface HomeRoute { resource: "home"; + activeTab?: HomeReposTab; +} + +interface InboxRoute { + resource: "inbox"; + activeTab?: HomeInboxTab; +} + +interface LoadedInboxRoute { + resource: "inbox"; + params: { + activeTab?: HomeInboxTab; + repoCount: RepoCount; + notificationCount: SvelteMap; + notifications: SvelteMap< + string, + { + repo: HomeInboxTab; + items: Record; + pagination: { cursor: number; more: boolean }; + } + >; + config: Config; + }; } interface LoadedHomeRoute { resource: "home"; - params: { repos: RepoInfo[]; config: Config }; + params: { + activeTab?: HomeReposTab; + repoCount: RepoCount; + notificationCount: Map; + repos: RepoInfo[]; + config: Config; + }; } export type Route = | AuthenticationErrorRoute + | InboxRoute | BootingRoute | HomeRoute | RepoRoute; export type LoadedRoute = | AuthenticationErrorRoute + | LoadedInboxRoute | BootingRoute | LoadedHomeRoute | LoadedRepoRoute; @@ -49,12 +94,77 @@ export async function loadRoute( route: Route, _previousLoaded: LoadedRoute, ): Promise { + const [count, repoCount, config] = await Promise.all([ + invoke>("count_notifications_by_repo"), + invoke("repo_count"), + invoke("config"), + ]); + const notificationCount = new SvelteMap(Object.entries(count)); if (route.resource === "home") { - const [config, repos] = await Promise.all([ - invoke("config"), - invoke("list_repos", { show: "all" }), - ]); - return { resource: "home", params: { repos, config } }; + let show = "all"; + + if (route.resource === "home") { + if (route.activeTab === "delegate") { + show = "delegate"; + } else if (route.activeTab === "private") { + show = "private"; + } + } + + const repos = await invoke("list_repos", { show }); + return { + resource: "home", + params: { + activeTab: route.activeTab, + repoCount, + notificationCount, + repos, + config, + }, + }; + } else if (route.resource === "inbox") { + const notifications: LoadedInboxRoute["params"]["notifications"] = + new SvelteMap(); + if (route.activeTab) { + const items = await invoke< + PaginatedQuery> + >("list_notifications", { + params: { + repo: route.activeTab.rid, + }, + }); + notifications.set(route.activeTab.rid, { + repo: route.activeTab, + items: items.content, + pagination: { cursor: items.cursor, more: items.more }, + }); + } else { + for (const [rid, item] of notificationCount) { + const result = await invoke< + PaginatedQuery> + >("list_notifications", { + params: { + repo: rid, + }, + }); + notifications.set(item.rid, { + repo: { name: item.name, rid: item.rid, count: item.count }, + items: result.content, + pagination: { cursor: result.cursor, more: result.more }, + }); + } + } + + return { + resource: "inbox", + params: { + activeTab: route.activeTab, + repoCount, + notifications, + notificationCount, + config, + }, + }; } else if (route.resource === "repo.issue") { return loadIssue(route); } else if (route.resource === "repo.createIssue") { diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 677c810f3..5319c9bbc 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -47,6 +47,10 @@ export function truncateDid(did: string): string { return `did:key:${truncateId(publicKeyFromDid(did))}`; } +export function didFromPublicKey(publicKey: string) { + return `did:key:${publicKey}`; +} + export function publicKeyFromDid(did: string) { return did.replace("did:key:", ""); } @@ -165,6 +169,10 @@ export function formatEditedCaption(author: Author, timestamp: number) { return `${author.alias ? author.alias : truncateDid(author.did)} edited ${absoluteTimestamp(timestamp)}`; } +export function pluralize(singular: string, count: number): string { + return count === 1 ? singular : `${singular}s`; +} + export function isMac() { if ( (navigator.platform && navigator.platform.includes("Mac")) || diff --git a/src/views/Home.svelte b/src/views/Home.svelte deleted file mode 100644 index e11ac9803..000000000 --- a/src/views/Home.svelte +++ /dev/null @@ -1,70 +0,0 @@ - - - - -
-
-
- {#snippet center()} - - {/snippet} - {#snippet settingsButton()} - - {/snippet} -
-
-
-
- Repositories -
-
- {#each repos as repo} - {#if repo.payloads["xyz.radicle.project"]} - { - void router.push({ - resource: "repo.issues", - rid: repo.rid, - status: "open", - }); - }} /> - {/if} - {/each} -
-
-
diff --git a/src/views/home/Inbox.svelte b/src/views/home/Inbox.svelte new file mode 100644 index 000000000..2a164d3b4 --- /dev/null +++ b/src/views/home/Inbox.svelte @@ -0,0 +1,215 @@ + + + + + { + if (activeTab) { + await loadMoreContent(); + } + }} + hideSidebar + styleSecondColumnOverflow="visible" + publicKey={config.publicKey}> + {#snippet headerCenter()} + + {/snippet} + {#snippet secondColumn()} + + {/snippet} +
+
+
Inbox
+ {#if notifications.size > 0} + + {/if} +
+ {#each notifications.values() as { repo, pagination, items }} + + {:else} + +
+
+ + No notifications. +
+
+
+ {/each} +
+
diff --git a/src/views/home/Repos.svelte b/src/views/home/Repos.svelte new file mode 100644 index 000000000..a5d368ff2 --- /dev/null +++ b/src/views/home/Repos.svelte @@ -0,0 +1,99 @@ + + + + + + {#snippet headerCenter()} + + {/snippet} + {#snippet secondColumn()} + + {/snippet} +
+
Repositories
+
+ {#each repos as repo} + {#if repo.payloads["xyz.radicle.project"]} + { + void router.push({ + resource: "repo.issues", + rid: repo.rid, + status: "open", + }); + }} /> + {/if} + {:else} + +
+
+ + No repositories. +
+
+
+ {/each} +
+
+
diff --git a/src/views/repo/Layout.svelte b/src/views/repo/Layout.svelte index c72f30c6d..f3a942c8f 100644 --- a/src/views/repo/Layout.svelte +++ b/src/views/repo/Layout.svelte @@ -35,7 +35,7 @@ publicKey: string; headerCenter?: Snippet; secondColumn: Snippet; - sidebar: Snippet; + sidebar?: Snippet; loadMoreContent?: () => Promise; loadMoreSecondColumn?: () => Promise; hideSidebar?: boolean; @@ -47,7 +47,7 @@ publicKey, headerCenter = undefined, secondColumn, - sidebar, + sidebar = undefined, loadMoreContent = undefined, loadMoreSecondColumn = undefined, hideSidebar = false, @@ -65,7 +65,7 @@ if ( contentContainer && contentContainer.scrollTop + contentContainer.clientHeight >= - contentContainer.scrollHeight - 600 && + contentContainer.scrollHeight / 2 && loadingContent === false ) { loadingContent = true; @@ -80,7 +80,7 @@ secondColumnContainer && secondColumnContainer.scrollTop + secondColumnContainer.clientHeight >= - secondColumnContainer.scrollHeight - 600 && + secondColumnContainer.scrollHeight / 2 && loadingSecondColumn === false ) { loadingSecondColumn = true; @@ -135,12 +135,14 @@
- + {#if sidebar} + + {/if}
{ await page.goto("/"); await expect( - page.getByText("did:key:z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S"), + page.getByText("z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S"), ).toBeVisible(); await peer.logOut(); @@ -16,6 +16,6 @@ test("removing identities from ssh-agent and re-adding them", async ({ await peer.authenticate(); await expect( - page.getByText("did:key:z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S"), + page.getByText("z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S"), ).toBeVisible(); }); diff --git a/tests/unit/notifications.test.ts b/tests/unit/notifications.test.ts new file mode 100644 index 000000000..5ef035cf1 --- /dev/null +++ b/tests/unit/notifications.test.ts @@ -0,0 +1,205 @@ +import type { Action as NotificationAction } from "@app/lib/notification"; +import type { Action as IssueAction } from "@bindings/cob/issue/Action"; +import type { Action as PatchAction } from "@bindings/cob/patch/Action"; + +import { describe, expect, test } from "vitest"; +import { compressActions, createSummary } from "@app/lib/notification"; +import { formatOid } from "@app/lib/utils"; + +const oid = "e8f95f5082a8e99c290ab3908a926e1de5c97d6c"; +const revision = "eb3d92ddbd4394bbd6896a99152afb1f8647d6ca"; +const actionOid = "22d3ec4b78314f83a43add9b72382c6fbc44c8b6"; +const timestamp = 1737622257; +const author = { + did: "did:key:z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx", + alias: "rudolfs", +}; + +const createAction = ( + action: IssueAction | PatchAction, + oid = actionOid, +): NotificationAction => ({ + oid, + timestamp, + author, + ...action, +}); + +describe("Action summaries", () => { + test.each([ + { + summary: "Review without verdict", + input: [createAction({ type: "review", revision })], + output: `left a review with a comment on revision ${formatOid(revision)}`, + }, + { + summary: "Review with accepted verdict", + input: [createAction({ type: "review", verdict: "accept", revision })], + output: `accepted revision ${formatOid(revision)} with a review`, + }, + { + summary: "Review with rejected verdict", + input: [createAction({ type: "review", verdict: "reject", revision })], + output: `rejected revision ${formatOid(revision)} with a review`, + }, + { + summary: "Add multiple labels", + input: [ + createAction({ + type: "label", + labels: ["bug", "ux"], + }), + createAction({ + type: "label", + labels: ["design"], + }), + ], + output: "added labels", + }, + { + summary: "Leave multiple review comments", + input: [ + createAction({ + type: "review.comment", + body: "A review comment", + review: oid, + }), + createAction({ + type: "review.comment", + body: "Next review comment", + review: oid, + }), + ], + output: "left 2 review comments", + }, + ])( + "$summary => $output", + ({ input, output }: { input: NotificationAction[]; output: string }) => { + expect(compressActions(input, "patch", oid)[0].summary).toEqual(output); + }, + ); + + test.each([ + { + summary: "Create new revision", + input: [ + createAction({ + type: "revision", + oid: revision, + description: "", + base: oid, + }), + ], + output: `created revision ${formatOid(revision)}`, + }, + { + summary: "Merge revision", + input: [ + createAction({ + type: "merge", + revision, + commit: actionOid, + }), + ], + output: `merged revision ${formatOid(revision)}`, + }, + ])( + "$summary => $output", + ({ input, output }: { input: NotificationAction[]; output: string }) => { + expect(compressActions(input, "patch", oid)[0].summary).toEqual(output); + }, + ); + + test.each([ + { + summary: "Close patch", + input: [ + createAction({ + type: "lifecycle", + state: { status: "closed", reason: "other" }, + }), + ], + output: "closed patch", + }, + { + summary: "Close patch as solved", + input: [ + createAction({ + type: "lifecycle", + state: { status: "closed", reason: "solved" }, + }), + ], + output: "closed patch as solved", + }, + { + summary: "Archive patch", + input: [ + createAction({ type: "lifecycle", state: { status: "archived" } }), + ], + output: "archived patch", + }, + { + summary: "Reopen patch", + input: [createAction({ type: "lifecycle", state: { status: "open" } })], + output: "reopened patch", + }, + { + summary: "Change patch to draft", + input: [createAction({ type: "lifecycle", state: { status: "draft" } })], + output: "changed to draft", + }, + { + summary: "More than one lifecycle change", + input: [ + createAction({ type: "lifecycle", state: { status: "draft" } }), + createAction({ type: "lifecycle", state: { status: "open" } }), + ], + output: "changed to open and 2 more changes", + }, + ])( + "$summary => $output", + ({ input, output }: { input: NotificationAction[]; output: string }) => { + expect(createSummary(input, "patch", oid, input.length)).toEqual(output); + }, + ); + + test.each([ + { + summary: "Open patch with an edit and a comment action", + type: "patch" as const, + input: [ + createAction({ type: "edit", title: "Lorem ipsum" }, oid), + createAction({ type: "comment", body: "A patch title" }, oid), + ], + output: `opened patch ${formatOid(oid)}`, + }, + { + summary: "Open issue where the action has the same oid than the cob", + type: "issue" as const, + input: [createAction({ type: "edit", title: "Lorem ipsum" }, oid)], + output: `opened issue ${formatOid(oid)}`, + }, + { + summary: "Leave two comments in one operation", + type: "issue" as const, + input: [ + createAction({ type: "comment", body: "Lorem ipsum" }), + createAction({ type: "comment", body: "A patch title" }), + ], + output: `left 2 comments`, + }, + ])( + "$summary => $output", + ({ + type, + input, + output, + }: { + type: "patch" | "issue"; + input: NotificationAction[]; + output: string; + }) => { + expect(createSummary(input, type, oid, input.length)).toEqual(output); + }, + ); +}); diff --git a/vite.config.ts b/vite.config.ts index 265948c09..b8c906344 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,6 +4,11 @@ import path from "node:path"; // https://vitejs.dev/config/ export default defineConfig({ + test: { + environment: "happy-dom", + include: ["tests/unit/**/*.test.ts"], + reporters: "verbose", + }, plugins: [svelte()], build: { outDir: "build",