mirror of
https://seed.radicle.dev/rad:z4D5UCArafTzTQpDZNQRuqswh3ury.git
synced 2026-10-18 19:10:45 +08:00
Add global inbox
This commit is contained in:
@@ -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
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod cob;
|
pub mod cob;
|
||||||
pub mod diff;
|
pub mod diff;
|
||||||
|
pub mod inbox;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
pub mod repo;
|
pub mod repo;
|
||||||
pub mod thread;
|
pub mod thread;
|
||||||
|
|||||||
@@ -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<AppState>,
|
||||||
|
sqlite_service: tauri::State<Service<Sqlite>>,
|
||||||
|
params: notification::RepoGroupParams,
|
||||||
|
) -> Result<
|
||||||
|
PaginatedQuery<BTreeMap<git::Qualified<'static>, Vec<notification::NotificationItem>>>,
|
||||||
|
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::<Vec<_>>();
|
||||||
|
|
||||||
|
(qualified, items)
|
||||||
|
})
|
||||||
|
.filter(|(_, v)| !v.is_empty())
|
||||||
|
.collect::<BTreeMap<git::Qualified<'static>, Vec<notification::NotificationItem>>>();
|
||||||
|
|
||||||
|
Ok(PaginatedQuery {
|
||||||
|
cursor,
|
||||||
|
more,
|
||||||
|
content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn count_notifications_by_repo(
|
||||||
|
ctx: tauri::State<AppState>,
|
||||||
|
inbox: tauri::State<Service<Sqlite>>,
|
||||||
|
) -> Result<BTreeMap<identity::RepoId, notification::NotificationCount>, 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::<BTreeMap<identity::RepoId, notification::NotificationCount>>();
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn clear_notifications(
|
||||||
|
ctx: tauri::State<AppState>,
|
||||||
|
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(())
|
||||||
|
}
|
||||||
@@ -15,6 +15,11 @@ pub fn list_repos(
|
|||||||
ctx.list_repos(show)
|
ctx.list_repos(show)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn repo_count(ctx: tauri::State<AppState>) -> Result<types::repo::RepoCount, Error> {
|
||||||
|
ctx.repo_count()
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn repo_by_id(
|
pub fn repo_by_id(
|
||||||
ctx: tauri::State<AppState>,
|
ctx: tauri::State<AppState>,
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
mod commands;
|
mod commands;
|
||||||
|
|
||||||
use tauri::Emitter;
|
use tauri::{Emitter, Manager};
|
||||||
use tauri::Manager;
|
|
||||||
|
|
||||||
use radicle::node::Handle;
|
use radicle::node::{Handle, NOTIFICATIONS_DB_FILE};
|
||||||
use radicle::Node;
|
use radicle::Node;
|
||||||
|
|
||||||
|
use radicle_types::domain;
|
||||||
use radicle_types::error::Error;
|
use radicle_types::error::Error;
|
||||||
use radicle_types::AppState;
|
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)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
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 events_handler = app.handle().clone();
|
||||||
let node_handler = app.handle().clone();
|
let node_handler = app.handle().clone();
|
||||||
|
|
||||||
let node = Node::new(profile.socket());
|
let node = Node::new(profile.socket());
|
||||||
let node_status = node.clone();
|
let node_status = node.clone();
|
||||||
|
|
||||||
|
app.manage(inbox_service);
|
||||||
app.manage(AppState { profile });
|
app.manage(AppState { profile });
|
||||||
|
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
@@ -71,11 +78,15 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||||
.invoke_handler(tauri::generate_handler. Do not edit this file manually.
|
||||||
|
import type { Author } from "../Author";
|
||||||
|
|
||||||
|
export type ActionWithAuthor<T> = {
|
||||||
|
oid: string;
|
||||||
|
timestamp: number;
|
||||||
|
author: Author;
|
||||||
|
} & T;
|
||||||
@@ -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<ActionWithAuthor<Action>>;
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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;
|
||||||
@@ -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<ActionWithAuthor<Action>>;
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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<number> }
|
||||||
|
| { "type": "repo"; "content": string }
|
||||||
|
| { "type": "all" };
|
||||||
@@ -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 };
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -11,7 +11,7 @@ pub mod patch;
|
|||||||
pub mod stream;
|
pub mod stream;
|
||||||
pub mod thread;
|
pub mod thread;
|
||||||
|
|
||||||
#[derive(Debug, Serialize, TS)]
|
#[derive(Debug, Clone, Serialize, TS, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[ts(export)]
|
#[ts(export)]
|
||||||
#[ts(export_to = "cob/")]
|
#[ts(export_to = "cob/")]
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ impl Issue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default, Serialize, Deserialize, TS)]
|
#[derive(Debug, Default, Serialize, Deserialize, TS)]
|
||||||
#[serde(rename_all = "camelCase", tag = "status")]
|
#[serde(rename_all = "camelCase", tag = "status")]
|
||||||
#[ts(export)]
|
#[ts(export)]
|
||||||
#[ts(export_to = "cob/issue/")]
|
#[ts(export_to = "cob/issue/")]
|
||||||
@@ -88,7 +88,7 @@ impl From<issue::State> for State {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, TS)]
|
#[derive(Debug, Serialize, Deserialize, TS)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[ts(export)]
|
#[ts(export)]
|
||||||
#[ts(export_to = "cob/issue/")]
|
#[ts(export_to = "cob/issue/")]
|
||||||
@@ -130,7 +130,7 @@ pub struct NewIssue {
|
|||||||
pub embeds: Vec<cobs::thread::Embed>,
|
pub embeds: Vec<cobs::thread::Embed>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, TS)]
|
#[derive(Debug, Serialize, Deserialize, TS)]
|
||||||
#[serde(tag = "type", rename_all = "camelCase")]
|
#[serde(tag = "type", rename_all = "camelCase")]
|
||||||
#[ts(export)]
|
#[ts(export)]
|
||||||
#[ts(export_to = "cob/issue/")]
|
#[ts(export_to = "cob/issue/")]
|
||||||
|
|||||||
@@ -294,7 +294,7 @@ impl From<Verdict> for cob::patch::Verdict {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, TS)]
|
#[derive(Debug, Serialize, Deserialize, TS)]
|
||||||
#[serde(tag = "type", rename_all = "camelCase")]
|
#[serde(tag = "type", rename_all = "camelCase")]
|
||||||
#[ts(export)]
|
#[ts(export)]
|
||||||
#[ts(export_to = "cob/patch/")]
|
#[ts(export_to = "cob/patch/")]
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
mod error;
|
pub mod error;
|
||||||
mod iter;
|
mod iter;
|
||||||
|
|
||||||
pub use iter::ActionsIter;
|
pub use iter::ActionsIter;
|
||||||
use iter::Walk;
|
use iter::Walk;
|
||||||
|
|
||||||
|
use std::fmt::Debug;
|
||||||
use std::marker::PhantomData;
|
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 serde::Deserialize;
|
||||||
|
|
||||||
use radicle::cob::{ObjectId, TypeName};
|
use crate::domain::inbox::models::notification::ActionWithAuthor;
|
||||||
use radicle::git::raw as git2;
|
|
||||||
use radicle::git::Oid;
|
|
||||||
|
|
||||||
/// Helper trait for anything can provide its initial commit. Generally, this is
|
/// Helper trait for anything can provide its initial commit. Generally, this is
|
||||||
/// the root of a COB object.
|
/// the root of a COB object.
|
||||||
@@ -92,7 +95,8 @@ impl HasRoot for CobRange {
|
|||||||
///
|
///
|
||||||
/// To construct a `Stream`, use [`Stream::new`].
|
/// To construct a `Stream`, use [`Stream::new`].
|
||||||
pub struct Stream<'a, A> {
|
pub struct Stream<'a, A> {
|
||||||
repo: &'a git2::Repository,
|
repo: &'a Repository,
|
||||||
|
aliases: &'a Aliases,
|
||||||
range: CobRange,
|
range: CobRange,
|
||||||
typename: TypeName,
|
typename: TypeName,
|
||||||
marker: PhantomData<A>,
|
marker: PhantomData<A>,
|
||||||
@@ -101,11 +105,17 @@ pub struct Stream<'a, A> {
|
|||||||
impl<'a, A> Stream<'a, A> {
|
impl<'a, A> Stream<'a, A> {
|
||||||
/// Construct a new stream providing the underlying `repo`, a [`CobRange`],
|
/// Construct a new stream providing the underlying `repo`, a [`CobRange`],
|
||||||
/// and the [`TypeName`] of the COB that is being streamed.
|
/// 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 {
|
Self {
|
||||||
repo,
|
repo,
|
||||||
range,
|
range,
|
||||||
typename,
|
typename,
|
||||||
|
aliases,
|
||||||
marker: PhantomData,
|
marker: PhantomData,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,9 +130,10 @@ impl<'a, A> HasRoot for Stream<'a, A> {
|
|||||||
impl<'a, A> CobStream for Stream<'a, A>
|
impl<'a, A> CobStream for Stream<'a, A>
|
||||||
where
|
where
|
||||||
A: for<'de> Deserialize<'de>,
|
A: for<'de> Deserialize<'de>,
|
||||||
|
A: Debug,
|
||||||
{
|
{
|
||||||
type IterError = error::Actions;
|
type IterError = error::Actions;
|
||||||
type Action = A;
|
type Action = ActionWithAuthor<A>;
|
||||||
type Iter = ActionsIter<'a, A>;
|
type Iter = ActionsIter<'a, A>;
|
||||||
|
|
||||||
fn all(&self) -> Result<Self::Iter, error::Stream> {
|
fn all(&self) -> Result<Self::Iter, error::Stream> {
|
||||||
@@ -131,6 +142,8 @@ where
|
|||||||
.iter(self.repo)
|
.iter(self.repo)
|
||||||
.map_err(error::Stream::new)?,
|
.map_err(error::Stream::new)?,
|
||||||
self.typename.clone(),
|
self.typename.clone(),
|
||||||
|
self.repo,
|
||||||
|
self.aliases,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +154,8 @@ where
|
|||||||
.iter(self.repo)
|
.iter(self.repo)
|
||||||
.map_err(error::Stream::new)?,
|
.map_err(error::Stream::new)?,
|
||||||
self.typename.clone(),
|
self.typename.clone(),
|
||||||
|
self.repo,
|
||||||
|
self.aliases,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +166,8 @@ where
|
|||||||
.iter(self.repo)
|
.iter(self.repo)
|
||||||
.map_err(error::Stream::new)?,
|
.map_err(error::Stream::new)?,
|
||||||
self.typename.clone(),
|
self.typename.clone(),
|
||||||
|
self.repo,
|
||||||
|
self.aliases,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,6 +177,8 @@ where
|
|||||||
.iter(self.repo)
|
.iter(self.repo)
|
||||||
.map_err(error::Stream::new)?,
|
.map_err(error::Stream::new)?,
|
||||||
self.typename.clone(),
|
self.typename.clone(),
|
||||||
|
self.repo,
|
||||||
|
self.aliases,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
|
use std::fmt::Debug;
|
||||||
use std::marker::PhantomData;
|
use std::marker::PhantomData;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json as json;
|
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::raw as git2;
|
||||||
use radicle::git::{Oid, PatternString};
|
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::error;
|
||||||
use super::CobRange;
|
use super::CobRange;
|
||||||
@@ -42,7 +49,7 @@ impl From<PatternString> for Until {
|
|||||||
/// from.
|
/// from.
|
||||||
pub(super) struct WalkIter<'a> {
|
pub(super) struct WalkIter<'a> {
|
||||||
/// Git repository for looking up the commit object during the revwalk.
|
/// 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.
|
/// The root commit that is being walked from.
|
||||||
///
|
///
|
||||||
/// N.b. This is required since ranges are non-inclusive in Git, and if the
|
/// 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.
|
/// Get the iterator for the walk.
|
||||||
pub(super) fn iter(self, repo: &git2::Repository) -> Result<WalkIter<'_>, git2::Error> {
|
pub(super) fn iter(self, repo: &Repository) -> Result<WalkIter<'_>, git2::Error> {
|
||||||
let mut walk = repo.revwalk()?;
|
let mut walk = repo.backend.revwalk()?;
|
||||||
// N.b. ensure that we start from the `self.from` commit.
|
// N.b. ensure that we start from the `self.from` commit.
|
||||||
walk.set_sorting(git2::Sort::TOPOLOGICAL.union(git2::Sort::REVERSE))?;
|
walk.set_sorting(git2::Sort::TOPOLOGICAL.union(git2::Sort::REVERSE))?;
|
||||||
match self.until {
|
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
|
// N.b. ensure that we start using the `from` commit and use the revwalk
|
||||||
// after that.
|
// after that.
|
||||||
if let Some(from) = self.from.take() {
|
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()?;
|
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
|
/// The walk can iterate over other COBs, e.g. an Identity COB, so this is
|
||||||
/// used to filter for the correct type.
|
/// used to filter for the correct type.
|
||||||
typename: TypeName,
|
typename: TypeName,
|
||||||
|
repo: &'a Repository,
|
||||||
|
aliases: &'a Aliases,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a, A> ActionsIter<'a, A> {
|
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 {
|
Self {
|
||||||
walk,
|
walk,
|
||||||
tree: None,
|
tree: None,
|
||||||
typename,
|
typename,
|
||||||
|
repo,
|
||||||
|
aliases,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +163,7 @@ impl<'a, A> ActionsIter<'a, A> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let object = entry
|
let object = entry
|
||||||
.to_object(self.walk.repo)
|
.to_object(&self.walk.repo.backend)
|
||||||
.map_err(|err| error::TreeAction::InvalidEntry { err })?;
|
.map_err(|err| error::TreeAction::InvalidEntry { err })?;
|
||||||
let blob = object
|
let blob = object
|
||||||
.into_blob()
|
.into_blob()
|
||||||
@@ -169,8 +185,9 @@ impl<'a, A> ActionsIter<'a, A> {
|
|||||||
impl<'a, A> Iterator for ActionsIter<'a, A>
|
impl<'a, A> Iterator for ActionsIter<'a, A>
|
||||||
where
|
where
|
||||||
A: for<'de> Deserialize<'de>,
|
A: for<'de> Deserialize<'de>,
|
||||||
|
A: Debug,
|
||||||
{
|
{
|
||||||
type Item = Result<A, error::Actions>;
|
type Item = Result<ActionWithAuthor<A>, error::Actions>;
|
||||||
|
|
||||||
fn next(&mut self) -> Option<Self::Item> {
|
fn next(&mut self) -> Option<Self::Item> {
|
||||||
// Are we currently iterating over a tree?
|
// 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 commit {}", commit.id());
|
||||||
log::trace!(target: "radicle", "Iterating over tree {}", tree.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
|
// 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
|
// Hide this commit so we do not double process it
|
||||||
self.walk.inner.hide(commit.id()).ok();
|
self.walk.inner.hide(commit.id()).ok();
|
||||||
self.next()
|
self.next()
|
||||||
@@ -227,9 +249,11 @@ where
|
|||||||
struct TreeActionsIter<'a, A> {
|
struct TreeActionsIter<'a, A> {
|
||||||
/// The repository is required to get the underlying object of the tree
|
/// The repository is required to get the underlying object of the tree
|
||||||
/// entry.
|
/// entry.
|
||||||
repo: &'a git2::Repository,
|
repo: &'a Repository,
|
||||||
/// The Git tree from which the actions are being extracted.
|
/// The Git tree from which the actions are being extracted.
|
||||||
tree: git2::Tree<'a>,
|
tree: git2::Tree<'a>,
|
||||||
|
op: Op<Vec<u8>>,
|
||||||
|
author: Author,
|
||||||
/// Use an index to keep track of which entry is being processed. Note that
|
/// 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.
|
/// `TreeIter` is *not* used since it poses many borrow-checker challenge.
|
||||||
/// Instead, `self.tree.iter()` is called and the iterator is indexed into.
|
/// 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> {
|
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<Vec<u8>>, author: Author) -> Self
|
||||||
where
|
where
|
||||||
A: for<'de> Deserialize<'de>,
|
A: for<'de> Deserialize<'de>,
|
||||||
{
|
{
|
||||||
Self {
|
Self {
|
||||||
repo,
|
repo,
|
||||||
tree,
|
tree,
|
||||||
|
op,
|
||||||
|
author,
|
||||||
index: 0,
|
index: 0,
|
||||||
marker: PhantomData,
|
marker: PhantomData,
|
||||||
}
|
}
|
||||||
@@ -256,14 +282,15 @@ impl<'a, A> Iterator for TreeActionsIter<'a, A>
|
|||||||
where
|
where
|
||||||
A: for<'de> Deserialize<'de>,
|
A: for<'de> Deserialize<'de>,
|
||||||
{
|
{
|
||||||
type Item = Result<A, error::TreeAction>;
|
type Item = Result<ActionWithAuthor<A>, error::TreeAction>;
|
||||||
|
|
||||||
fn next(&mut self) -> Option<Self::Item> {
|
fn next(&mut self) -> Option<Self::Item> {
|
||||||
let entry = self.tree.iter().nth(self.index)?;
|
let entry = self.tree.iter().nth(self.index)?;
|
||||||
self.index += 1;
|
self.index += 1;
|
||||||
// N.b. if `from_tree_entry` is `None` we have filtered the entry so we
|
// N.b. if `from_tree_entry` is `None` we have filtered the entry so we
|
||||||
// go the `next` entry
|
// 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.
|
/// The entry is only an action if it is a blob and its name is numerical.
|
||||||
fn from_tree_entry<A>(
|
fn from_tree_entry<A>(
|
||||||
repo: &git2::Repository,
|
repo: &Repository,
|
||||||
entry: git2::TreeEntry,
|
entry: git2::TreeEntry,
|
||||||
) -> Option<Result<A, error::TreeAction>>
|
op: Op<Vec<u8>>,
|
||||||
|
author: Author,
|
||||||
|
) -> Option<Result<ActionWithAuthor<A>, error::TreeAction>>
|
||||||
where
|
where
|
||||||
A: for<'de> Deserialize<'de>,
|
A: for<'de> Deserialize<'de>,
|
||||||
{
|
{
|
||||||
let as_action = |entry: git2::TreeEntry| -> Result<A, error::TreeAction> {
|
let as_action = |entry: git2::TreeEntry| -> Result<ActionWithAuthor<A>, error::TreeAction> {
|
||||||
let object = entry
|
let object = entry
|
||||||
.to_object(repo)
|
.to_object(&repo.backend)
|
||||||
.map_err(|err| error::TreeAction::InvalidEntry { err })?;
|
.map_err(|err| error::TreeAction::InvalidEntry { err })?;
|
||||||
let blob = object
|
let blob = object
|
||||||
.into_blob()
|
.into_blob()
|
||||||
@@ -289,7 +318,7 @@ where
|
|||||||
.kind()
|
.kind()
|
||||||
.map_or("unknown".to_string(), |kind| kind.to_string()),
|
.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()?;
|
let name = entry.name()?;
|
||||||
// An entry is only considered an action if it:
|
// An entry is only considered an action if it:
|
||||||
@@ -301,10 +330,22 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Helper to deserialize an action from a blob's contents.
|
/// Helper to deserialize an action from a blob's contents.
|
||||||
fn action<A>(blob: &git2::Blob) -> Result<A, error::Action>
|
fn action<A>(
|
||||||
|
blob: &git2::Blob,
|
||||||
|
op: Op<Vec<u8>>,
|
||||||
|
author: Author,
|
||||||
|
) -> Result<ActionWithAuthor<A>, error::Action>
|
||||||
where
|
where
|
||||||
A: for<'de> Deserialize<'de>,
|
A: for<'de> Deserialize<'de>,
|
||||||
{
|
{
|
||||||
log::trace!(target: "radicle", "Deserializing action {}", blob.id());
|
log::trace!(target: "radicle", "Deserializing action {}", blob.id());
|
||||||
json::from_slice::<A>(blob.content()).map_err(|err| error::Action::new(blob.id().into(), err))
|
let action = json::from_slice::<A>(blob.content())
|
||||||
|
.map_err(|err| error::Action::new(blob.id().into(), err))?;
|
||||||
|
|
||||||
|
Ok(ActionWithAuthor {
|
||||||
|
author,
|
||||||
|
timestamp: op.timestamp,
|
||||||
|
oid: op.id,
|
||||||
|
action,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ pub struct NewPatchComment {
|
|||||||
pub embeds: Vec<Embed>,
|
pub embeds: Vec<Embed>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, TS, Serialize, Deserialize)]
|
#[derive(Debug, Clone, TS, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[ts(export)]
|
#[ts(export)]
|
||||||
#[ts(export_to = "cob/thread/")]
|
#[ts(export_to = "cob/thread/")]
|
||||||
@@ -239,7 +239,7 @@ impl From<CodeLocation> for cob::CodeLocation {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, TS, Serialize, Deserialize)]
|
#[derive(Debug, Clone, TS, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase", tag = "type")]
|
#[serde(rename_all = "camelCase", tag = "type")]
|
||||||
#[ts(export)]
|
#[ts(export)]
|
||||||
#[ts(export_to = "cob/thread/")]
|
#[ts(export_to = "cob/thread/")]
|
||||||
@@ -273,7 +273,7 @@ impl From<CodeRange> for cob::CodeRange {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(TS, Clone, Deserialize, Serialize)]
|
#[derive(Debug, TS, Clone, Deserialize, Serialize)]
|
||||||
#[ts(export)]
|
#[ts(export)]
|
||||||
#[ts(export_to = "cob/thread/")]
|
#[ts(export_to = "cob/thread/")]
|
||||||
pub struct Embed {
|
pub struct Embed {
|
||||||
|
|||||||
@@ -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<NotificationId>),
|
||||||
|
Repo(#[ts(as = "String")] identity::RepoId),
|
||||||
|
All,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Eq, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, PartialEq, Eq, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
#[serde(rename = "camelCase")]
|
#[serde(rename = "camelCase")]
|
||||||
pub struct NotificationRow {
|
pub struct NotificationRow {
|
||||||
pub row_id: node::notifications::NotificationId,
|
pub row_id: node::notifications::NotificationId,
|
||||||
pub timestamp: localtime::LocalTime,
|
pub timestamp: localtime::LocalTime,
|
||||||
pub remote: Option<storage::RemoteId>,
|
/// Node Id that provided us with this notification.
|
||||||
|
pub remote: storage::RemoteId,
|
||||||
pub old: Option<git::Oid>,
|
pub old: Option<git::Oid>,
|
||||||
pub new: Option<git::Oid>,
|
pub new: Option<git::Oid>,
|
||||||
}
|
}
|
||||||
@@ -18,9 +40,11 @@ pub struct CountsByRepoParams {
|
|||||||
pub repo: identity::RepoId,
|
pub repo: identity::RepoId,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug, serde::Deserialize)]
|
||||||
pub struct RepoGroupParams {
|
pub struct RepoGroupParams {
|
||||||
pub repo: identity::RepoId,
|
pub repo: identity::RepoId,
|
||||||
|
pub skip: Option<usize>,
|
||||||
|
pub take: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
@@ -34,6 +58,9 @@ pub enum ListNotificationsError {
|
|||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
Sqlite(#[from] sqlite::Error),
|
Sqlite(#[from] sqlite::Error),
|
||||||
|
|
||||||
|
#[error(transparent)]
|
||||||
|
CobStream(#[from] stream::error::Stream),
|
||||||
|
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
NotificationKindError(#[from] node::notifications::NotificationKindError),
|
NotificationKindError(#[from] node::notifications::NotificationKindError),
|
||||||
|
|
||||||
@@ -41,3 +68,249 @@ pub enum ListNotificationsError {
|
|||||||
Unknown(#[from] anyhow::Error),
|
Unknown(#[from] anyhow::Error),
|
||||||
// to be extended as new error scenarios are introduced
|
// to be extended as new error scenarios are introduced
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn actions<A>(
|
||||||
|
typename: cob::TypeName,
|
||||||
|
oid: cob::ObjectId,
|
||||||
|
from: Option<git::Oid>,
|
||||||
|
until: Option<git::Oid>,
|
||||||
|
repo: &storage::git::Repository,
|
||||||
|
aliases: &Aliases,
|
||||||
|
) -> Result<Vec<ActionWithAuthor<A>>, ListNotificationsError>
|
||||||
|
where
|
||||||
|
A: serde::Serialize,
|
||||||
|
A: for<'de> serde::Deserialize<'de>,
|
||||||
|
A: Debug,
|
||||||
|
{
|
||||||
|
let history = stream::CobRange::new(&typename, &oid);
|
||||||
|
let stream = stream::Stream::<A>::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::<Vec<_>>())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<ActionWithAuthor<cobs::issue::Action>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, TS, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export_to = "cob/inbox/")]
|
||||||
|
pub struct ActionWithAuthor<T> {
|
||||||
|
#[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<ActionWithAuthor<cobs::patch::Action>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<node::notifications::NotificationKind> 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<NotificationKind> 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<cob::TypedId> for TypedId {
|
||||||
|
fn from(value: cob::TypedId) -> Self {
|
||||||
|
Self {
|
||||||
|
id: value.id,
|
||||||
|
type_name: value.type_name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<TypedId> 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<storage::RefUpdate> 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<RefUpdate> 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<git::Oid>, Option<git::Oid>)> for RefUpdate {
|
||||||
|
fn from((name, new, old): (git::RefString, Option<git::Oid>, Option<git::Oid>)) -> 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()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,12 +3,20 @@ use axum::http::{Response, StatusCode};
|
|||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::cobs::stream;
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum Error {
|
pub enum Error {
|
||||||
/// Profile error.
|
/// Profile error.
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
Profile(#[from] radicle::profile::Error),
|
Profile(#[from] radicle::profile::Error),
|
||||||
|
|
||||||
|
/// List notification error.
|
||||||
|
#[error(transparent)]
|
||||||
|
ListNotificationsError(
|
||||||
|
#[from] crate::domain::inbox::models::notification::ListNotificationsError,
|
||||||
|
),
|
||||||
|
|
||||||
/// CobStore error.
|
/// CobStore error.
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
CobStore(#[from] radicle::cob::store::Error),
|
CobStore(#[from] radicle::cob::store::Error),
|
||||||
@@ -21,10 +29,22 @@ pub enum Error {
|
|||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
Io(#[from] std::io::Error),
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
/// Io error.
|
/// Sqlite error.
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
Sqlite(#[from] sqlite::Error),
|
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.
|
/// Crypto error.
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
Crypto(#[from] radicle::crypto::ssh::keystore::Error),
|
Crypto(#[from] radicle::crypto::ssh::keystore::Error),
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ use std::path::Path;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time;
|
use std::time;
|
||||||
|
|
||||||
use radicle::git::Qualified;
|
|
||||||
use radicle::{git, identity};
|
use radicle::{git, identity};
|
||||||
use sqlite as sql;
|
use sqlite as sql;
|
||||||
|
|
||||||
@@ -38,8 +37,9 @@ impl InboxStorage for Sqlite {
|
|||||||
notification::ListNotificationsError,
|
notification::ListNotificationsError,
|
||||||
> {
|
> {
|
||||||
let stmt = self.db.prepare(
|
let stmt = self.db.prepare(
|
||||||
"SELECT repo, COUNT(*) as count
|
"SELECT ref, repo, COUNT(*) as count
|
||||||
FROM `repository-notifications`
|
FROM `repository-notifications`
|
||||||
|
WHERE ref LIKE '%cobs%'
|
||||||
GROUP BY repo",
|
GROUP BY repo",
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ impl InboxStorage for Sqlite {
|
|||||||
Ok((reference.to_owned(), items))
|
Ok((reference.to_owned(), items))
|
||||||
})
|
})
|
||||||
.collect::<Result<
|
.collect::<Result<
|
||||||
BTreeMap<Qualified<'static>, Vec<notification::NotificationRow>>,
|
BTreeMap<git::Qualified<'static>, Vec<notification::NotificationRow>>,
|
||||||
notification::ListNotificationsError,
|
notification::ListNotificationsError,
|
||||||
>>()
|
>>()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,17 @@ use radicle::{git, identity, issue, patch};
|
|||||||
use crate::cobs::Author;
|
use crate::cobs::Author;
|
||||||
use crate::error;
|
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)]
|
#[derive(Serialize, TS)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[ts(export)]
|
#[ts(export)]
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use radicle::{git, identity};
|
|||||||
use crate::cobs;
|
use crate::cobs;
|
||||||
use crate::diff::Diff;
|
use crate::diff::Diff;
|
||||||
use crate::error::Error;
|
use crate::error::Error;
|
||||||
use crate::repo;
|
use crate::repo::{self, RepoCount};
|
||||||
use crate::syntax::{Highlighter, ToPretty};
|
use crate::syntax::{Highlighter, ToPretty};
|
||||||
use crate::traits::Profile;
|
use crate::traits::Profile;
|
||||||
|
|
||||||
@@ -22,6 +22,7 @@ pub enum Show {
|
|||||||
Delegate,
|
Delegate,
|
||||||
All,
|
All,
|
||||||
Seeded,
|
Seeded,
|
||||||
|
Private,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait Repo: Profile {
|
pub trait Repo: Profile {
|
||||||
@@ -41,6 +42,10 @@ pub trait Repo: Profile {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !doc.is_private() && show == Show::Private {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if !doc.delegates().contains(&profile.public_key.into()) && show == Show::Delegate {
|
if !doc.delegates().contains(&profile.public_key.into()) && show == Show::Delegate {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -56,6 +61,42 @@ pub trait Repo: Profile {
|
|||||||
Ok::<_, Error>(entries)
|
Ok::<_, Error>(entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn repo_count(&self) -> Result<repo::RepoCount, Error> {
|
||||||
|
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<repo::RepoInfo, Error> {
|
fn repo_by_id(&self, rid: identity::RepoId) -> Result<repo::RepoInfo, Error> {
|
||||||
let profile = self.profile();
|
let profile = self.profile();
|
||||||
let repo = profile.storage.repository(rid)?;
|
let repo = profile.storage.repository(rid)?;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use axum::routing::post;
|
|||||||
use axum::Router;
|
use axum::Router;
|
||||||
use hyper::header::CONTENT_TYPE;
|
use hyper::header::CONTENT_TYPE;
|
||||||
use hyper::Method;
|
use hyper::Method;
|
||||||
|
use radicle_types::domain::inbox::models::notification::NotificationCount;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tower_http::cors::{self, CorsLayer};
|
use tower_http::cors::{self, CorsLayer};
|
||||||
|
|
||||||
@@ -55,6 +56,11 @@ pub fn router(ctx: Context) -> Router {
|
|||||||
Router::new()
|
Router::new()
|
||||||
.route("/config", post(config_handler))
|
.route("/config", post(config_handler))
|
||||||
.route("/authenticate", post(auth_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("/list_repos", post(repo_root_handler))
|
||||||
.route("/repo_by_id", post(repo_handler))
|
.route("/repo_by_id", post(repo_handler))
|
||||||
.route("/diff_stats", post(diff_stats_handler))
|
.route("/diff_stats", post(diff_stats_handler))
|
||||||
@@ -116,6 +122,15 @@ async fn repo_root_handler(
|
|||||||
Ok::<_, Error>(Json(repos))
|
Ok::<_, Error>(Json(repos))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn repo_count_handler(State(ctx): State<Context>) -> impl IntoResponse {
|
||||||
|
let repos = ctx.repo_count()?;
|
||||||
|
Ok::<_, Error>(Json(repos))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn repo_count_notifications_handler() -> impl IntoResponse {
|
||||||
|
Ok::<_, Error>(Json(Vec::<NotificationCount>::new()))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
struct RepoBody {
|
struct RepoBody {
|
||||||
pub rid: identity::RepoId,
|
pub rid: identity::RepoId,
|
||||||
|
|||||||
Generated
+396
@@ -38,6 +38,7 @@
|
|||||||
"eslint-plugin-svelte": "^2.46.1",
|
"eslint-plugin-svelte": "^2.46.1",
|
||||||
"execa": "^9.5.2",
|
"execa": "^9.5.2",
|
||||||
"get-port": "^7.1.0",
|
"get-port": "^7.1.0",
|
||||||
|
"happy-dom": "^16.7.2",
|
||||||
"hast-util-to-dom": "^4.0.0",
|
"hast-util-to-dom": "^4.0.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"marked": "^15.0.4",
|
"marked": "^15.0.4",
|
||||||
@@ -55,6 +56,7 @@
|
|||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"typescript-eslint": "^8.18.1",
|
"typescript-eslint": "^8.18.1",
|
||||||
"vite": "^6.0.3",
|
"vite": "^6.0.3",
|
||||||
|
"vitest": "^3.0.3",
|
||||||
"wait-on": "^8.0.1"
|
"wait-on": "^8.0.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1598,6 +1600,112 @@
|
|||||||
"url": "https://opencollective.com/typescript-eslint"
|
"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": {
|
"node_modules/@wooorm/starry-night": {
|
||||||
"version": "3.5.0",
|
"version": "3.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@wooorm/starry-night/-/starry-night-3.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@wooorm/starry-night/-/starry-night-3.5.0.tgz",
|
||||||
@@ -1690,6 +1798,15 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/asynckit": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
@@ -1809,6 +1926,15 @@
|
|||||||
"ieee754": "^1.2.1"
|
"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": {
|
"node_modules/callsites": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||||
@@ -1818,6 +1944,22 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/chalk": {
|
||||||
"version": "5.3.0",
|
"version": "5.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz",
|
||||||
@@ -1830,6 +1972,15 @@
|
|||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"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": {
|
"node_modules/chokidar": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.2.tgz",
|
"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": {
|
"node_modules/deep-is": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||||
@@ -1967,6 +2127,12 @@
|
|||||||
"@types/trusted-types": "^2.0.7"
|
"@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": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.24.0",
|
"version": "0.24.0",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.0.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.0.tgz",
|
||||||
@@ -2248,6 +2414,15 @@
|
|||||||
"node": ">=4.0"
|
"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": {
|
"node_modules/esutils": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
||||||
@@ -2283,6 +2458,15 @@
|
|||||||
"url": "https://github.com/sindresorhus/execa?sponsor=1"
|
"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": {
|
"node_modules/extend-shallow": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
|
||||||
@@ -2573,6 +2757,19 @@
|
|||||||
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
|
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/has-flag": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
@@ -2929,6 +3126,12 @@
|
|||||||
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
|
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.17",
|
"version": "0.30.17",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
|
||||||
@@ -3226,6 +3429,21 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -3651,6 +3869,12 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/signal-exit": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||||
@@ -3672,6 +3896,18 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/strip-bom-string": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz",
|
||||||
@@ -3836,6 +4072,45 @@
|
|||||||
"url": "https://opencollective.com/eslint"
|
"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": {
|
"node_modules/to-regex-range": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"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": {
|
"node_modules/vite/node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"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": {
|
"node_modules/vscode-oniguruma": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-2.0.1.tgz",
|
||||||
@@ -4119,6 +4481,24 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"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": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@@ -4134,6 +4514,22 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"check": "scripts/check-js && scripts/check-rs",
|
"check": "scripts/check-js && scripts/check-rs",
|
||||||
"check-js": "scripts/check-js",
|
"check-js": "scripts/check-js",
|
||||||
"check-rs": "scripts/check-rs",
|
"check-rs": "scripts/check-rs",
|
||||||
|
"test:unit": "TZ='UTC' vitest run",
|
||||||
"test:e2e": "TZ='UTC' playwright test",
|
"test:e2e": "TZ='UTC' playwright test",
|
||||||
"format": "npx prettier '**/*.@(ts|js|svelte|json|css|html|yml)' --write",
|
"format": "npx prettier '**/*.@(ts|js|svelte|json|css|html|yml)' --write",
|
||||||
"generate-types": "cargo test --manifest-path ./crates/radicle-types/Cargo.toml",
|
"generate-types": "cargo test --manifest-path ./crates/radicle-types/Cargo.toml",
|
||||||
@@ -52,6 +53,7 @@
|
|||||||
"eslint-plugin-svelte": "^2.46.1",
|
"eslint-plugin-svelte": "^2.46.1",
|
||||||
"execa": "^9.5.2",
|
"execa": "^9.5.2",
|
||||||
"get-port": "^7.1.0",
|
"get-port": "^7.1.0",
|
||||||
|
"happy-dom": "^16.7.2",
|
||||||
"hast-util-to-dom": "^4.0.0",
|
"hast-util-to-dom": "^4.0.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"marked": "^15.0.4",
|
"marked": "^15.0.4",
|
||||||
@@ -69,6 +71,7 @@
|
|||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"typescript-eslint": "^8.18.1",
|
"typescript-eslint": "^8.18.1",
|
||||||
"vite": "^6.0.3",
|
"vite": "^6.0.3",
|
||||||
|
"vitest": "^3.0.3",
|
||||||
"wait-on": "^8.0.1"
|
"wait-on": "^8.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-2
@@ -13,11 +13,12 @@
|
|||||||
|
|
||||||
import AuthenticationError from "@app/views/AuthenticationError.svelte";
|
import AuthenticationError from "@app/views/AuthenticationError.svelte";
|
||||||
import CreateIssue from "@app/views/repo/CreateIssue.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 Issue from "@app/views/repo/Issue.svelte";
|
||||||
import Issues from "@app/views/repo/Issues.svelte";
|
import Issues from "@app/views/repo/Issues.svelte";
|
||||||
import Patch from "@app/views/repo/Patch.svelte";
|
import Patch from "@app/views/repo/Patch.svelte";
|
||||||
import Patches from "@app/views/repo/Patches.svelte";
|
import Patches from "@app/views/repo/Patches.svelte";
|
||||||
|
import Repos from "./views/home/Repos.svelte";
|
||||||
import { dynamicInterval, checkAuth } from "./lib/auth";
|
import { dynamicInterval, checkAuth } from "./lib/auth";
|
||||||
|
|
||||||
const activeRouteStore = router.activeRouteStore;
|
const activeRouteStore = router.activeRouteStore;
|
||||||
@@ -71,7 +72,9 @@
|
|||||||
{#if $activeRouteStore.resource === "booting"}
|
{#if $activeRouteStore.resource === "booting"}
|
||||||
<!-- Don't show anything -->
|
<!-- Don't show anything -->
|
||||||
{:else if $activeRouteStore.resource === "home"}
|
{:else if $activeRouteStore.resource === "home"}
|
||||||
<Home {...$activeRouteStore.params} />
|
<Repos {...$activeRouteStore.params} />
|
||||||
|
{:else if $activeRouteStore.resource === "inbox"}
|
||||||
|
<Inbox {...$activeRouteStore.params} />
|
||||||
{:else if $activeRouteStore.resource === "repo.createIssue"}
|
{:else if $activeRouteStore.resource === "repo.createIssue"}
|
||||||
<CreateIssue {...$activeRouteStore.params} />
|
<CreateIssue {...$activeRouteStore.params} />
|
||||||
{:else if $activeRouteStore.resource === "repo.issue"}
|
{:else if $activeRouteStore.resource === "repo.issue"}
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createIcon } from "@app/lib/blockies";
|
import { createIcon } from "@app/lib/blockies";
|
||||||
|
|
||||||
const {
|
const { publicKey }: { publicKey: string } = $props();
|
||||||
publicKey,
|
|
||||||
}: {
|
|
||||||
publicKey: string;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
function createContainer(source: string) {
|
function createContainer(source: string) {
|
||||||
const seed = source.toLowerCase();
|
const seed = source.toLowerCase();
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import type { Modification } from "@bindings/diff/Modification";
|
import type { Modification } from "@bindings/diff/Modification";
|
||||||
|
|
||||||
import File from "@app/components/File.svelte";
|
import File from "@app/components/File.svelte";
|
||||||
import { escape } from "lodash";
|
import escape from "lodash/escape";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
filePath: string;
|
filePath: string;
|
||||||
|
|||||||
@@ -55,9 +55,7 @@
|
|||||||
<NakedButton
|
<NakedButton
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
void router.push({
|
void router.push({ resource: "home" });
|
||||||
resource: "home",
|
|
||||||
});
|
|
||||||
}}>
|
}}>
|
||||||
<Avatar {publicKey} />
|
<Avatar {publicKey} />
|
||||||
</NakedButton>
|
</NakedButton>
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HomeInboxTab, HomeReposTab } from "@app/lib/router/definitions";
|
||||||
|
import type { NotificationCount } from "@bindings/cob/inbox/NotificationCount";
|
||||||
|
import type { RepoCount } from "@bindings/repo/RepoCount";
|
||||||
|
|
||||||
|
import sum from "lodash/sum";
|
||||||
|
|
||||||
|
import * as router from "@app/lib/router";
|
||||||
|
import Border from "./Border.svelte";
|
||||||
|
import Icon from "@app/components/Icon.svelte";
|
||||||
|
import Link from "./Link.svelte";
|
||||||
|
import Settings from "@app/components/Settings.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
activeTab:
|
||||||
|
| { type: "inbox"; repo?: HomeInboxTab }
|
||||||
|
| { type: "repos"; filter?: HomeReposTab };
|
||||||
|
notificationCount: Map<string, NotificationCount>;
|
||||||
|
repoCount: RepoCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { notificationCount, repoCount, activeTab }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.tab {
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background-color: var(--color-background-float);
|
||||||
|
clip-path: var(--1px-corner-fill);
|
||||||
|
display: flex;
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 4px 8px 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.tab:not(.active) {
|
||||||
|
color: var(--color-foreground-dim);
|
||||||
|
}
|
||||||
|
.tab:not(.active):hover {
|
||||||
|
background-color: var(--color-fill-float-hover);
|
||||||
|
}
|
||||||
|
.active {
|
||||||
|
background-color: var(--color-background-default);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
}
|
||||||
|
.highlight {
|
||||||
|
color: var(--color-foreground-contrast);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div>
|
||||||
|
<div style:margin-bottom="0.5rem">
|
||||||
|
{#if activeTab.type === "inbox"}
|
||||||
|
<Border
|
||||||
|
styleCursor="pointer"
|
||||||
|
variant="ghost"
|
||||||
|
styleFlexDirection="column"
|
||||||
|
styleGap="2px"
|
||||||
|
styleBackgroundColor="var(--color-background-float)">
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="tab"
|
||||||
|
class:active={!activeTab.repo}
|
||||||
|
onclick={() => router.push({ resource: "inbox" })}>
|
||||||
|
<div class="global-flex"><Icon name="inbox" />Inbox</div>
|
||||||
|
<div class="global-counter">
|
||||||
|
{sum(Array.from(notificationCount.values()).map(c => c.count))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#each notificationCount.entries() as [_, n]}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="tab"
|
||||||
|
onclick={() =>
|
||||||
|
router.push({
|
||||||
|
resource: "inbox",
|
||||||
|
activeTab: n,
|
||||||
|
})}
|
||||||
|
class:active={activeTab.repo?.rid === n.rid}>
|
||||||
|
<div class="global-flex">
|
||||||
|
<Icon name="repo" />{n.name}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="global-counter"
|
||||||
|
class:highlight={activeTab.repo?.rid === n.rid}>
|
||||||
|
{n.count}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</Border>
|
||||||
|
{:else}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<div
|
||||||
|
class="tab"
|
||||||
|
style:cursor="pointer"
|
||||||
|
onclick={() => router.push({ resource: "inbox" })}
|
||||||
|
style:color="var(--color-foreground-contrast)"
|
||||||
|
style:padding-left="12px">
|
||||||
|
<div class="global-flex"><Icon name="inbox" />Inbox</div>
|
||||||
|
<div class="global-counter">
|
||||||
|
{sum(Array.from(notificationCount.values()).map(c => c.count))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if activeTab.type === "repos"}
|
||||||
|
<Border
|
||||||
|
styleCursor="pointer"
|
||||||
|
variant="ghost"
|
||||||
|
styleFlexDirection="column"
|
||||||
|
styleGap="2px"
|
||||||
|
styleBackgroundColor="var(--color-background-float)">
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="tab txt-small"
|
||||||
|
class:active={!activeTab.filter}
|
||||||
|
onclick={() => router.push({ resource: "home" })}>
|
||||||
|
<div class="global-flex"><Icon name="repo" />Repositories</div>
|
||||||
|
<div class="global-counter">
|
||||||
|
{repoCount.total}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="tab"
|
||||||
|
class:active={activeTab.filter === "delegate"}
|
||||||
|
onclick={() =>
|
||||||
|
router.push({
|
||||||
|
resource: "home",
|
||||||
|
activeTab: "delegate",
|
||||||
|
})}>
|
||||||
|
<div class="global-flex">
|
||||||
|
<Icon name="delegate" />
|
||||||
|
<div>Delegate</div>
|
||||||
|
</div>
|
||||||
|
<div class="global-counter">{repoCount.delegate}</div>
|
||||||
|
</div>
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="tab"
|
||||||
|
class:active={activeTab.filter === "private"}
|
||||||
|
onclick={() =>
|
||||||
|
router.push({
|
||||||
|
resource: "home",
|
||||||
|
activeTab: "private",
|
||||||
|
})}>
|
||||||
|
<div class="global-flex">
|
||||||
|
<Icon name="lock" />
|
||||||
|
<div>Private</div>
|
||||||
|
</div>
|
||||||
|
<div class="global-counter">{repoCount.private}</div>
|
||||||
|
</div>
|
||||||
|
</Border>
|
||||||
|
{:else}
|
||||||
|
<Border
|
||||||
|
styleBackgroundColor="var(--color-background-float)"
|
||||||
|
variant="float">
|
||||||
|
<Link
|
||||||
|
styleWidth="100%"
|
||||||
|
underline={false}
|
||||||
|
route={{
|
||||||
|
resource: "home",
|
||||||
|
}}>
|
||||||
|
<div
|
||||||
|
style:justify-content="space-between"
|
||||||
|
style:width="100%"
|
||||||
|
class="tab">
|
||||||
|
<div class="global-flex">
|
||||||
|
<Icon name="repo" />
|
||||||
|
Repositories
|
||||||
|
</div>
|
||||||
|
<div class="global-counter">
|
||||||
|
{repoCount.total}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</Border>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Settings
|
||||||
|
compact={false}
|
||||||
|
popoverProps={{
|
||||||
|
popoverPositionBottom: "3rem",
|
||||||
|
popoverPositionLeft: "0",
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
size?: "16" | "32";
|
size?: "16" | "32";
|
||||||
onclick?: () => void;
|
onclick?: (e: MouseEvent) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
styleDisplay?: string;
|
styleDisplay?: string;
|
||||||
styleVerticalAlign?: string;
|
styleVerticalAlign?: string;
|
||||||
@@ -12,6 +12,9 @@
|
|||||||
| "arrow-right"
|
| "arrow-right"
|
||||||
| "arrow-right-hollow"
|
| "arrow-right-hollow"
|
||||||
| "attachment"
|
| "attachment"
|
||||||
|
| "branch"
|
||||||
|
| "broom"
|
||||||
|
| "broom-double"
|
||||||
| "checkmark"
|
| "checkmark"
|
||||||
| "chevron-down"
|
| "chevron-down"
|
||||||
| "chevron-right"
|
| "chevron-right"
|
||||||
@@ -95,9 +98,9 @@
|
|||||||
class:hoverable={onclick}
|
class:hoverable={onclick}
|
||||||
class:disabled
|
class:disabled
|
||||||
role="img"
|
role="img"
|
||||||
onclick={() => {
|
onclick={e => {
|
||||||
if (onclick && !disabled) {
|
if (onclick && !disabled) {
|
||||||
onclick();
|
onclick(e);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
aria-label={`icon-${name}`}
|
aria-label={`icon-${name}`}
|
||||||
@@ -156,6 +159,47 @@
|
|||||||
<path d="M4 8H5V9L4 9V8Z" />
|
<path d="M4 8H5V9L4 9V8Z" />
|
||||||
<path d="M5 6H11V7H5V6Z" />
|
<path d="M5 6H11V7H5V6Z" />
|
||||||
<path d="M4 7H5L5 8H4L4 7Z" />
|
<path d="M4 7H5L5 8H4L4 7Z" />
|
||||||
|
{:else if name === "branch"}
|
||||||
|
<path d="M11 5L10 5V2L13 2V5L12 5V8L11 8V5ZM11 3H12V4H11V3Z" />
|
||||||
|
<path
|
||||||
|
d="M11 9L5 9L5 11H6L6 14H3L3 11H4L4 5H3L3 2L6 2L6 5H5L5 8H11V9ZM4 4L5 4V3H4L4 4ZM4 13V12H5L5 13H4Z" />
|
||||||
|
{:else if name === "broom"}
|
||||||
|
<path d="M11 13H12V14H11V13Z" />
|
||||||
|
<path d="M11 13H12V14H11V13Z" />
|
||||||
|
<path d="M11 12H12V13H11V12Z" />
|
||||||
|
<path d="M10 12H11V14H10V12Z" />
|
||||||
|
<path d="M8 12H9V14H8V12Z" />
|
||||||
|
<path d="M4 12H11V14H4V12Z" />
|
||||||
|
<path d="M9 12H10V13H9V12Z" />
|
||||||
|
<path d="M7 12H8V13H7V12Z" />
|
||||||
|
<path d="M4 10H12V11H4V10Z" />
|
||||||
|
<path d="M7 3H8V10H7V3Z" />
|
||||||
|
<path d="M8 3H9V10H8V3Z" />
|
||||||
|
<path d="M7 2H9V3H7V2Z" />
|
||||||
|
{:else if name === "broom-double"}
|
||||||
|
<path d="M9 13H10V14H9V13Z" />
|
||||||
|
<path d="M9 13H10V14H9V13Z" />
|
||||||
|
<path d="M9 12H10V13H9V12Z" />
|
||||||
|
<path d="M8 12H9V14H8V12Z" />
|
||||||
|
<path d="M6 12H7V14H6V12Z" />
|
||||||
|
<path d="M2 12H9V14H2V12Z" />
|
||||||
|
<path d="M7 12H8V13H7V12Z" />
|
||||||
|
<path d="M5 12H6V13H5V12Z" />
|
||||||
|
<path d="M2 10H10V11H2V10Z" />
|
||||||
|
<path d="M5 3H6V10H5V3Z" />
|
||||||
|
<path d="M6 3H7V10H6V3Z" />
|
||||||
|
<path d="M5 2H7V3H5V2Z" />
|
||||||
|
<path d="M13 13H14V14H13V13Z" />
|
||||||
|
<path d="M13 13H14V14H13V13Z" />
|
||||||
|
<path d="M13 12H14V13H13V12Z" />
|
||||||
|
<path d="M12 12H13V14H12V12Z" />
|
||||||
|
<path d="M11 12H13V14H11V12Z" />
|
||||||
|
<path d="M11 12H12V13H11V12Z" />
|
||||||
|
<path d="M9 12H10V13H9V12Z" />
|
||||||
|
<path d="M11 10H14V11H11V10Z" />
|
||||||
|
<path d="M9 3H10V9H9V3Z" />
|
||||||
|
<path d="M10 3H11V9H10V3Z" />
|
||||||
|
<path d="M9 2H11V3H9V2Z" />
|
||||||
{:else if name === "checkmark"}
|
{:else if name === "checkmark"}
|
||||||
<path d="M7 11V12H6V11H7Z" />
|
<path d="M7 11V12H6V11H7Z" />
|
||||||
<path d="M8 10V11L7 11L7 10H8Z" />
|
<path d="M8 10V11L7 11L7 10H8Z" />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { ComponentProps, Snippet } from "svelte";
|
import type { ComponentProps, Snippet } from "svelte";
|
||||||
|
|
||||||
import { debounce } from "lodash";
|
import debounce from "lodash/debounce";
|
||||||
import { writeToClipboard } from "@app/lib/invoke";
|
import { writeToClipboard } from "@app/lib/invoke";
|
||||||
|
|
||||||
import { formatOid } from "@app/lib/utils";
|
import { formatOid } from "@app/lib/utils";
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
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 type { ComponentProps } from "svelte";
|
||||||
|
import type { RepoRoute } from "@app/views/repo/router";
|
||||||
|
import type { NotificationItem } from "@bindings/cob/inbox/NotificationItem";
|
||||||
|
|
||||||
|
import {
|
||||||
|
authorForNodeId,
|
||||||
|
formatTimestamp,
|
||||||
|
issueStatusBackgroundColor,
|
||||||
|
issueStatusColor,
|
||||||
|
patchStatusBackgroundColor,
|
||||||
|
patchStatusColor,
|
||||||
|
} from "@app/lib/utils";
|
||||||
|
import { compressActions } from "@app/lib/notification";
|
||||||
|
import { push } from "@app/lib/router";
|
||||||
|
import uniqWith from "lodash/uniqWith";
|
||||||
|
|
||||||
|
import Icon from "./Icon.svelte";
|
||||||
|
import InlineTitle from "./InlineTitle.svelte";
|
||||||
|
import NodeId from "./NodeId.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
rid: string;
|
||||||
|
kind: "issue" | "patch";
|
||||||
|
oid: string;
|
||||||
|
clearByIds: (rid: string, ids: string[]) => Promise<void>;
|
||||||
|
notificationItems: NotificationItem[];
|
||||||
|
selected?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
clearByIds,
|
||||||
|
notificationItems,
|
||||||
|
rid,
|
||||||
|
oid,
|
||||||
|
kind,
|
||||||
|
selected = false,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
type Action = ActionWithAuthor<IssueAction> | ActionWithAuthor<PatchAction>;
|
||||||
|
|
||||||
|
const uniqueActions = $derived.by(() => {
|
||||||
|
return compressActions(
|
||||||
|
uniqWith(
|
||||||
|
notificationItems.flatMap<Action>(n => n.actions),
|
||||||
|
(a, b) =>
|
||||||
|
Boolean(
|
||||||
|
a.oid === b.oid &&
|
||||||
|
a.type === b.type &&
|
||||||
|
a.author.did &&
|
||||||
|
b.author.did,
|
||||||
|
),
|
||||||
|
).sort((a, b) => b.timestamp - a.timestamp),
|
||||||
|
kind,
|
||||||
|
oid,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const clearIcon = $derived(
|
||||||
|
uniqueActions.length > 1 ? "broom-double" : "broom",
|
||||||
|
);
|
||||||
|
|
||||||
|
const title = $derived.by(() => {
|
||||||
|
const lastDetail = notificationItems.at(-1);
|
||||||
|
if (lastDetail && "title" in lastDetail) {
|
||||||
|
return lastDetail.title;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const icon: ComponentProps<typeof Icon>["name"] = $derived.by(() => {
|
||||||
|
const lastDetail = notificationItems.at(-1);
|
||||||
|
if (lastDetail?.type === "issue" && lastDetail.status.status !== "open") {
|
||||||
|
return `issue-${lastDetail.status.status}` as const;
|
||||||
|
} else if (lastDetail?.type === "issue") {
|
||||||
|
return "issue" as const;
|
||||||
|
} else if (
|
||||||
|
lastDetail?.type === "patch" &&
|
||||||
|
lastDetail.status.status !== "open"
|
||||||
|
) {
|
||||||
|
return `patch-${lastDetail.status.status}` as const;
|
||||||
|
} else {
|
||||||
|
return "patch" as const;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusColor = $derived.by(() => {
|
||||||
|
const lastDetail = notificationItems.at(-1);
|
||||||
|
if (lastDetail?.type === "patch") {
|
||||||
|
return {
|
||||||
|
color: patchStatusColor[lastDetail.status.status],
|
||||||
|
background: patchStatusBackgroundColor[lastDetail.status.status],
|
||||||
|
};
|
||||||
|
} else if (lastDetail?.type === "issue") {
|
||||||
|
return {
|
||||||
|
color: issueStatusColor[lastDetail.status.status],
|
||||||
|
background: issueStatusBackgroundColor[lastDetail.status.status],
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
color: "var(--color-foreground-dim)",
|
||||||
|
background: "var(--color-fill-ghost)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const route = $derived.by(() => {
|
||||||
|
const lastDetail = notificationItems.at(-1);
|
||||||
|
switch (lastDetail?.type) {
|
||||||
|
case "patch":
|
||||||
|
return {
|
||||||
|
resource: "repo.patch",
|
||||||
|
rid,
|
||||||
|
patch: lastDetail.id,
|
||||||
|
status: undefined,
|
||||||
|
} as RepoRoute;
|
||||||
|
case "issue":
|
||||||
|
return {
|
||||||
|
resource: "repo.issue",
|
||||||
|
rid,
|
||||||
|
issue: lastDetail.id,
|
||||||
|
status: "all",
|
||||||
|
} as RepoRoute;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.notification-teaser {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.25rem;
|
||||||
|
min-height: 5rem;
|
||||||
|
background-color: var(--color-background-float);
|
||||||
|
padding: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--font-size-regular);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.selected {
|
||||||
|
background-color: var(--color-fill-float-hover);
|
||||||
|
}
|
||||||
|
.notification-teaser:hover {
|
||||||
|
background-color: var(--color-fill-float-hover);
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
padding: 0;
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
.icon {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.notification-teaser:first-of-type {
|
||||||
|
clip-path: var(--3px-top-corner-fill);
|
||||||
|
}
|
||||||
|
.notification-teaser:last-of-type {
|
||||||
|
clip-path: var(--3px-bottom-corner-fill);
|
||||||
|
}
|
||||||
|
.notification-teaser:only-of-type {
|
||||||
|
clip-path: var(--3px-corner-fill);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<div
|
||||||
|
tabindex="0"
|
||||||
|
role="button"
|
||||||
|
class:selected
|
||||||
|
class="notification-teaser"
|
||||||
|
onclick={() => {
|
||||||
|
if (route) void push(route);
|
||||||
|
}}>
|
||||||
|
<div
|
||||||
|
class="global-flex"
|
||||||
|
style:justify-content="space-between"
|
||||||
|
style:align-items="flex-start"
|
||||||
|
style:width="100%">
|
||||||
|
<div class="global-flex">
|
||||||
|
<div
|
||||||
|
class="global-counter status"
|
||||||
|
style:align-self="flex-start"
|
||||||
|
style:color={statusColor.color}
|
||||||
|
style:background-color={statusColor.background}>
|
||||||
|
<Icon name={icon} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="global-flex"
|
||||||
|
style:flex-direction="column"
|
||||||
|
style:align-items="flex-start">
|
||||||
|
{#if title}
|
||||||
|
<InlineTitle content={title} />
|
||||||
|
{/if}
|
||||||
|
<div class="txt-small">
|
||||||
|
{#each uniqueActions as action}
|
||||||
|
<div class="global-flex" style:gap="0.25rem" style:height="2rem">
|
||||||
|
<NodeId {...authorForNodeId(action.items[0].author)} />
|
||||||
|
<span>{@html action.summary}</span>
|
||||||
|
<span>{formatTimestamp(action.items[0].timestamp)}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="global-flex">
|
||||||
|
<div class="icon">
|
||||||
|
<Icon
|
||||||
|
onclick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void clearByIds(
|
||||||
|
rid,
|
||||||
|
notificationItems.map(n => n.rowId),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
name={clearIcon} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HomeInboxTab } from "@app/lib/router/definitions";
|
||||||
|
import type { NotificationItem } from "@bindings/cob/inbox/NotificationItem";
|
||||||
|
|
||||||
|
import * as router from "@app/lib/router";
|
||||||
|
import Button from "./Button.svelte";
|
||||||
|
import NotificationTeaser from "./NotificationTeaser.svelte";
|
||||||
|
import Icon from "./Icon.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
all?: boolean;
|
||||||
|
clearByIds: (rid: string, ids: string[]) => Promise<void>;
|
||||||
|
clearByRepo: (rid: string) => Promise<void>;
|
||||||
|
repo: HomeInboxTab;
|
||||||
|
items: Record<string, NotificationItem[]>;
|
||||||
|
more: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
all = false,
|
||||||
|
more,
|
||||||
|
clearByRepo,
|
||||||
|
clearByIds,
|
||||||
|
repo,
|
||||||
|
items,
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding-right: 1.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
{#if Object.entries(items).length > 0}
|
||||||
|
<div class="header">
|
||||||
|
<div class="global-flex" style:margin="1rem 0">
|
||||||
|
<span class="txt-bold">
|
||||||
|
{repo.name}
|
||||||
|
</span>
|
||||||
|
{repo.count}
|
||||||
|
</div>
|
||||||
|
<Icon onclick={() => clearByRepo(repo.rid)} name="broom-double" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{#each Object.entries(items).sort((a, b) => b[1][0].timestamp - a[1][0].timestamp) as [_, notificationItems]}
|
||||||
|
<NotificationTeaser
|
||||||
|
{clearByIds}
|
||||||
|
rid={repo.rid}
|
||||||
|
kind={notificationItems[0].type}
|
||||||
|
oid={notificationItems[0].id}
|
||||||
|
{notificationItems} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if all === false && more}
|
||||||
|
<div style:width="100%" style:margin-top="1rem">
|
||||||
|
<div style:width="7rem" style:margin="auto">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onclick={() =>
|
||||||
|
router.push({
|
||||||
|
resource: "inbox",
|
||||||
|
activeTab: repo,
|
||||||
|
})}>
|
||||||
|
<div style:width="100%" style:text-align="center">See all</div>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -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<IssueAction>
|
||||||
|
| ActionWithAuthor<PatchAction>;
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
'<span class="global-oid">$1</span>',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
+8
-1
@@ -103,7 +103,12 @@ function urlToRoute(url: URL): Route | null {
|
|||||||
|
|
||||||
switch (resource) {
|
switch (resource) {
|
||||||
case "": {
|
case "": {
|
||||||
return { resource: "home" };
|
return {
|
||||||
|
resource: "home",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "inbox": {
|
||||||
|
return { resource: "inbox" };
|
||||||
}
|
}
|
||||||
case "repos": {
|
case "repos": {
|
||||||
return repoUrlToRoute(segments, url.searchParams);
|
return repoUrlToRoute(segments, url.searchParams);
|
||||||
@@ -120,6 +125,8 @@ function urlToRoute(url: URL): Route | null {
|
|||||||
export function routeToPath(route: Route): string {
|
export function routeToPath(route: Route): string {
|
||||||
if (route.resource === "home") {
|
if (route.resource === "home") {
|
||||||
return "/";
|
return "/";
|
||||||
|
} else if (route.resource === "inbox") {
|
||||||
|
return "/inbox";
|
||||||
} else if (route.resource === "authenticationError") {
|
} else if (route.resource === "authenticationError") {
|
||||||
return "/authenticationError";
|
return "/authenticationError";
|
||||||
} else if (
|
} else if (
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import type { Config } from "@bindings/config/Config";
|
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 { RepoInfo } from "@bindings/repo/RepoInfo";
|
||||||
|
import type { RepoCount } from "@bindings/repo/RepoCount";
|
||||||
import type { LoadedRepoRoute, RepoRoute } from "@app/views/repo/router";
|
import type { LoadedRepoRoute, RepoRoute } from "@app/views/repo/router";
|
||||||
|
|
||||||
import { invoke } from "@app/lib/invoke";
|
import { invoke } from "@app/lib/invoke";
|
||||||
|
import { SvelteMap } from "svelte/reactivity";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
loadCreateIssue,
|
loadCreateIssue,
|
||||||
@@ -12,6 +17,14 @@ import {
|
|||||||
loadPatches,
|
loadPatches,
|
||||||
} from "@app/views/repo/router";
|
} from "@app/views/repo/router";
|
||||||
|
|
||||||
|
export type HomeReposTab = "delegate" | "private";
|
||||||
|
|
||||||
|
export interface HomeInboxTab {
|
||||||
|
rid: string;
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface BootingRoute {
|
interface BootingRoute {
|
||||||
resource: "booting";
|
resource: "booting";
|
||||||
}
|
}
|
||||||
@@ -26,21 +39,53 @@ interface AuthenticationErrorRoute {
|
|||||||
|
|
||||||
interface HomeRoute {
|
interface HomeRoute {
|
||||||
resource: "home";
|
resource: "home";
|
||||||
|
activeTab?: HomeReposTab;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InboxRoute {
|
||||||
|
resource: "inbox";
|
||||||
|
activeTab?: HomeInboxTab;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoadedInboxRoute {
|
||||||
|
resource: "inbox";
|
||||||
|
params: {
|
||||||
|
activeTab?: HomeInboxTab;
|
||||||
|
repoCount: RepoCount;
|
||||||
|
notificationCount: SvelteMap<string, NotificationCount>;
|
||||||
|
notifications: SvelteMap<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
repo: HomeInboxTab;
|
||||||
|
items: Record<string, NotificationItem[]>;
|
||||||
|
pagination: { cursor: number; more: boolean };
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
config: Config;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LoadedHomeRoute {
|
interface LoadedHomeRoute {
|
||||||
resource: "home";
|
resource: "home";
|
||||||
params: { repos: RepoInfo[]; config: Config };
|
params: {
|
||||||
|
activeTab?: HomeReposTab;
|
||||||
|
repoCount: RepoCount;
|
||||||
|
notificationCount: Map<string, NotificationCount>;
|
||||||
|
repos: RepoInfo[];
|
||||||
|
config: Config;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Route =
|
export type Route =
|
||||||
| AuthenticationErrorRoute
|
| AuthenticationErrorRoute
|
||||||
|
| InboxRoute
|
||||||
| BootingRoute
|
| BootingRoute
|
||||||
| HomeRoute
|
| HomeRoute
|
||||||
| RepoRoute;
|
| RepoRoute;
|
||||||
|
|
||||||
export type LoadedRoute =
|
export type LoadedRoute =
|
||||||
| AuthenticationErrorRoute
|
| AuthenticationErrorRoute
|
||||||
|
| LoadedInboxRoute
|
||||||
| BootingRoute
|
| BootingRoute
|
||||||
| LoadedHomeRoute
|
| LoadedHomeRoute
|
||||||
| LoadedRepoRoute;
|
| LoadedRepoRoute;
|
||||||
@@ -49,12 +94,77 @@ export async function loadRoute(
|
|||||||
route: Route,
|
route: Route,
|
||||||
_previousLoaded: LoadedRoute,
|
_previousLoaded: LoadedRoute,
|
||||||
): Promise<LoadedRoute> {
|
): Promise<LoadedRoute> {
|
||||||
|
const [count, repoCount, config] = await Promise.all([
|
||||||
|
invoke<Record<string, NotificationCount>>("count_notifications_by_repo"),
|
||||||
|
invoke<RepoCount>("repo_count"),
|
||||||
|
invoke<Config>("config"),
|
||||||
|
]);
|
||||||
|
const notificationCount = new SvelteMap(Object.entries(count));
|
||||||
if (route.resource === "home") {
|
if (route.resource === "home") {
|
||||||
const [config, repos] = await Promise.all([
|
let show = "all";
|
||||||
invoke<Config>("config"),
|
|
||||||
invoke<RepoInfo[]>("list_repos", { show: "all" }),
|
if (route.resource === "home") {
|
||||||
]);
|
if (route.activeTab === "delegate") {
|
||||||
return { resource: "home", params: { repos, config } };
|
show = "delegate";
|
||||||
|
} else if (route.activeTab === "private") {
|
||||||
|
show = "private";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const repos = await invoke<RepoInfo[]>("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<Record<string, NotificationItem[]>>
|
||||||
|
>("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<Record<string, NotificationItem[]>>
|
||||||
|
>("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") {
|
} else if (route.resource === "repo.issue") {
|
||||||
return loadIssue(route);
|
return loadIssue(route);
|
||||||
} else if (route.resource === "repo.createIssue") {
|
} else if (route.resource === "repo.createIssue") {
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ export function truncateDid(did: string): string {
|
|||||||
return `did:key:${truncateId(publicKeyFromDid(did))}`;
|
return `did:key:${truncateId(publicKeyFromDid(did))}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function didFromPublicKey(publicKey: string) {
|
||||||
|
return `did:key:${publicKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function publicKeyFromDid(did: string) {
|
export function publicKeyFromDid(did: string) {
|
||||||
return did.replace("did:key:", "");
|
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)}`;
|
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() {
|
export function isMac() {
|
||||||
if (
|
if (
|
||||||
(navigator.platform && navigator.platform.includes("Mac")) ||
|
(navigator.platform && navigator.platform.includes("Mac")) ||
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { Config } from "@bindings/config/Config";
|
|
||||||
import type { RepoInfo } from "@bindings/repo/RepoInfo";
|
|
||||||
|
|
||||||
import * as router from "@app/lib/router";
|
|
||||||
|
|
||||||
import CopyableId from "@app/components/CopyableId.svelte";
|
|
||||||
import Header from "@app/components/Header.svelte";
|
|
||||||
import RepoCard from "@app/components/RepoCard.svelte";
|
|
||||||
import Settings from "@app/components/Settings.svelte";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
repos: RepoInfo[];
|
|
||||||
config: Config;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { repos, config }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.header {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
.repo-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(21rem, 1fr));
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<div style:height="fit-content">
|
|
||||||
<div class="header">
|
|
||||||
<Header publicKey={config.publicKey}>
|
|
||||||
{#snippet center()}
|
|
||||||
<CopyableId id={`did:key:${config.publicKey}`} />
|
|
||||||
{/snippet}
|
|
||||||
{#snippet settingsButton()}
|
|
||||||
<Settings
|
|
||||||
styleHeight="32px"
|
|
||||||
popoverProps={{
|
|
||||||
popoverPositionRight: "0",
|
|
||||||
popoverPositionTop: "2.5rem",
|
|
||||||
}} />
|
|
||||||
{/snippet}
|
|
||||||
</Header>
|
|
||||||
</div>
|
|
||||||
<div style:padding="1rem">
|
|
||||||
<div class="txt-semibold" style:margin="0.5rem 0 1.5rem 1rem">
|
|
||||||
Repositories
|
|
||||||
</div>
|
|
||||||
<div class="repo-grid">
|
|
||||||
{#each repos as repo}
|
|
||||||
{#if repo.payloads["xyz.radicle.project"]}
|
|
||||||
<RepoCard
|
|
||||||
{repo}
|
|
||||||
selfDid={`did:key:${config.publicKey}`}
|
|
||||||
onclick={() => {
|
|
||||||
void router.push({
|
|
||||||
resource: "repo.issues",
|
|
||||||
rid: repo.rid,
|
|
||||||
status: "open",
|
|
||||||
});
|
|
||||||
}} />
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HomeInboxTab } from "@app/lib/router/definitions";
|
||||||
|
import type { Config } from "@bindings/config/Config";
|
||||||
|
import type { NotificationCount } from "@bindings/cob/inbox/NotificationCount";
|
||||||
|
import type { NotificationItem } from "@bindings/cob/inbox/NotificationItem";
|
||||||
|
import type { PaginatedQuery } from "@bindings/cob/PaginatedQuery";
|
||||||
|
import type { RepoCount } from "@bindings/repo/RepoCount";
|
||||||
|
|
||||||
|
import { SvelteMap } from "svelte/reactivity";
|
||||||
|
import { invoke } from "@app/lib/invoke";
|
||||||
|
import * as router from "@app/lib/router";
|
||||||
|
|
||||||
|
import CopyableId from "@app/components/CopyableId.svelte";
|
||||||
|
import HomeSidebar from "@app/components/HomeSidebar.svelte";
|
||||||
|
import Icon from "@app/components/Icon.svelte";
|
||||||
|
import Layout from "@app/views/repo/Layout.svelte";
|
||||||
|
import RepoNotifications from "@app/components/RepoNotifications.svelte";
|
||||||
|
import Border from "@app/components/Border.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
activeTab?: HomeInboxTab;
|
||||||
|
notificationCount: SvelteMap<string, NotificationCount>;
|
||||||
|
notifications: SvelteMap<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
repo: HomeInboxTab;
|
||||||
|
items: Record<string, NotificationItem[]>;
|
||||||
|
pagination: { cursor: number; more: boolean };
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
repoCount: RepoCount;
|
||||||
|
config: Config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* eslint-disable prefer-const */
|
||||||
|
let {
|
||||||
|
notificationCount,
|
||||||
|
repoCount,
|
||||||
|
activeTab,
|
||||||
|
config,
|
||||||
|
notifications,
|
||||||
|
}: Props = $props();
|
||||||
|
/* eslint-enable prefer-const */
|
||||||
|
|
||||||
|
let cursor: number | undefined = undefined;
|
||||||
|
let more: boolean | undefined = undefined;
|
||||||
|
|
||||||
|
// If we are focused on a repo populate the pagination vars.
|
||||||
|
$effect(() => {
|
||||||
|
if (activeTab && notifications.has(activeTab.rid)) {
|
||||||
|
const n = notifications.get(activeTab.rid);
|
||||||
|
cursor = n!.pagination.cursor;
|
||||||
|
more = n!.pagination.more;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function clearAll() {
|
||||||
|
try {
|
||||||
|
await invoke("clear_notifications", {
|
||||||
|
params: { type: "all" },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Clearing notifications failed", error);
|
||||||
|
} finally {
|
||||||
|
notificationCount.clear();
|
||||||
|
notifications.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearByRepo(rid: string) {
|
||||||
|
try {
|
||||||
|
await invoke("clear_notifications", {
|
||||||
|
params: { type: "repo", content: rid },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Clearing notifications failed", error);
|
||||||
|
} finally {
|
||||||
|
await reload([rid]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearByIds(rid: string, ids: string[]) {
|
||||||
|
try {
|
||||||
|
await invoke("clear_notifications", {
|
||||||
|
params: { type: "ids", content: ids },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Clearing notifications failed", error);
|
||||||
|
} finally {
|
||||||
|
await reload([rid]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reload(rids: string[]) {
|
||||||
|
for (const rid of rids) {
|
||||||
|
const [n, count] = await Promise.all([
|
||||||
|
invoke<PaginatedQuery<Record<string, NotificationItem[]>>>(
|
||||||
|
"list_notifications",
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
repo: rid,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
invoke<Record<string, NotificationCount>>(
|
||||||
|
"count_notifications_by_repo",
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
notificationCount = new SvelteMap(Object.entries(count));
|
||||||
|
|
||||||
|
notifications.set(rid, {
|
||||||
|
repo: notificationCount.get(rid)!,
|
||||||
|
items: n.content,
|
||||||
|
pagination: { cursor: n.cursor, more: n.more },
|
||||||
|
});
|
||||||
|
|
||||||
|
// If we are looking at a single repo and there are no more notifications left after a reload, push the user to the general inbox
|
||||||
|
if (activeTab && Object.values(n.content).length === 0) {
|
||||||
|
void router.push({ resource: "inbox" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMoreContent() {
|
||||||
|
if (more && activeTab) {
|
||||||
|
const c = cursor ? cursor : 20;
|
||||||
|
const p = await invoke<
|
||||||
|
PaginatedQuery<Record<string, NotificationItem[]>>
|
||||||
|
>("list_notifications", {
|
||||||
|
params: {
|
||||||
|
repo: activeTab.rid,
|
||||||
|
skip: more ? c + 20 : c,
|
||||||
|
take: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
cursor = p.cursor;
|
||||||
|
more = p.more;
|
||||||
|
|
||||||
|
const currentNotifications = notifications.get(activeTab.rid);
|
||||||
|
notifications.set(activeTab.rid, {
|
||||||
|
repo: currentNotifications!.repo,
|
||||||
|
items: { ...currentNotifications!.items, ...p.content },
|
||||||
|
pagination: { cursor: p.cursor, more: p.more },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.container {
|
||||||
|
padding: 1rem 1rem 1rem 0;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
font-size: var(--font-size-medium);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-right: 1.5rem;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<Layout
|
||||||
|
loadMoreContent={async () => {
|
||||||
|
if (activeTab) {
|
||||||
|
await loadMoreContent();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
hideSidebar
|
||||||
|
styleSecondColumnOverflow="visible"
|
||||||
|
publicKey={config.publicKey}>
|
||||||
|
{#snippet headerCenter()}
|
||||||
|
<CopyableId id={config.publicKey} />
|
||||||
|
{/snippet}
|
||||||
|
{#snippet secondColumn()}
|
||||||
|
<HomeSidebar
|
||||||
|
activeTab={{ type: "inbox", repo: activeTab }}
|
||||||
|
{notificationCount}
|
||||||
|
{repoCount} />
|
||||||
|
{/snippet}
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<div>Inbox</div>
|
||||||
|
{#if notifications.size > 0}
|
||||||
|
<Icon onclick={clearAll} name="broom-double" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#each notifications.values() as { repo, pagination, items }}
|
||||||
|
<RepoNotifications
|
||||||
|
all={Boolean(activeTab)}
|
||||||
|
{clearByIds}
|
||||||
|
{clearByRepo}
|
||||||
|
{repo}
|
||||||
|
more={pagination.more}
|
||||||
|
{items} />
|
||||||
|
{:else}
|
||||||
|
<Border
|
||||||
|
variant="ghost"
|
||||||
|
styleAlignItems="center"
|
||||||
|
styleJustifyContent="center">
|
||||||
|
<div
|
||||||
|
class="global-flex"
|
||||||
|
style:height="74px"
|
||||||
|
style:justify-content="center">
|
||||||
|
<div class="txt-missing txt-small global-flex" style:gap="0.25rem">
|
||||||
|
<Icon name="none" />
|
||||||
|
No notifications.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Border>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HomeReposTab } from "@app/lib/router/definitions";
|
||||||
|
import type { Config } from "@bindings/config/Config";
|
||||||
|
import type { NotificationCount } from "@bindings/cob/inbox/NotificationCount";
|
||||||
|
import type { RepoCount } from "@bindings/repo/RepoCount";
|
||||||
|
import type { RepoInfo } from "@bindings/repo/RepoInfo";
|
||||||
|
|
||||||
|
import * as router from "@app/lib/router";
|
||||||
|
import { didFromPublicKey } from "@app/lib/utils";
|
||||||
|
|
||||||
|
import CopyableId from "@app/components/CopyableId.svelte";
|
||||||
|
import HomeSidebar from "@app/components/HomeSidebar.svelte";
|
||||||
|
import Layout from "@app/views/repo/Layout.svelte";
|
||||||
|
import RepoCard from "@app/components/RepoCard.svelte";
|
||||||
|
import Border from "@app/components/Border.svelte";
|
||||||
|
import Icon from "@app/components/Icon.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
activeTab?: HomeReposTab;
|
||||||
|
config: Config;
|
||||||
|
notificationCount: Map<string, NotificationCount>;
|
||||||
|
repoCount: RepoCount;
|
||||||
|
repos: RepoInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* eslint-disable prefer-const */
|
||||||
|
let { config, repos, notificationCount, repoCount, activeTab }: Props =
|
||||||
|
/* eslint-enable prefer-const */
|
||||||
|
$props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.container {
|
||||||
|
padding: 1rem 1rem 1rem 0;
|
||||||
|
}
|
||||||
|
.repo-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(21rem, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
font-size: var(--font-size-medium);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-right: 1.5rem;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<Layout
|
||||||
|
hideSidebar
|
||||||
|
styleSecondColumnOverflow="visible"
|
||||||
|
publicKey={config.publicKey}>
|
||||||
|
{#snippet headerCenter()}
|
||||||
|
<CopyableId id={config.publicKey} />
|
||||||
|
{/snippet}
|
||||||
|
{#snippet secondColumn()}
|
||||||
|
<HomeSidebar
|
||||||
|
activeTab={{ type: "repos", filter: activeTab }}
|
||||||
|
{repoCount}
|
||||||
|
{notificationCount} />
|
||||||
|
{/snippet}
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">Repositories</div>
|
||||||
|
<div class="repo-grid">
|
||||||
|
{#each repos as repo}
|
||||||
|
{#if repo.payloads["xyz.radicle.project"]}
|
||||||
|
<RepoCard
|
||||||
|
{repo}
|
||||||
|
selfDid={didFromPublicKey(config.publicKey)}
|
||||||
|
onclick={() => {
|
||||||
|
void router.push({
|
||||||
|
resource: "repo.issues",
|
||||||
|
rid: repo.rid,
|
||||||
|
status: "open",
|
||||||
|
});
|
||||||
|
}} />
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<Border
|
||||||
|
variant="ghost"
|
||||||
|
styleAlignItems="center"
|
||||||
|
styleJustifyContent="center">
|
||||||
|
<div
|
||||||
|
class="global-flex"
|
||||||
|
style:height="74px"
|
||||||
|
style:justify-content="center">
|
||||||
|
<div class="txt-missing txt-small global-flex" style:gap="0.25rem">
|
||||||
|
<Icon name="none" />
|
||||||
|
No repositories.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Border>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
publicKey: string;
|
publicKey: string;
|
||||||
headerCenter?: Snippet;
|
headerCenter?: Snippet;
|
||||||
secondColumn: Snippet;
|
secondColumn: Snippet;
|
||||||
sidebar: Snippet;
|
sidebar?: Snippet;
|
||||||
loadMoreContent?: () => Promise<void>;
|
loadMoreContent?: () => Promise<void>;
|
||||||
loadMoreSecondColumn?: () => Promise<void>;
|
loadMoreSecondColumn?: () => Promise<void>;
|
||||||
hideSidebar?: boolean;
|
hideSidebar?: boolean;
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
publicKey,
|
publicKey,
|
||||||
headerCenter = undefined,
|
headerCenter = undefined,
|
||||||
secondColumn,
|
secondColumn,
|
||||||
sidebar,
|
sidebar = undefined,
|
||||||
loadMoreContent = undefined,
|
loadMoreContent = undefined,
|
||||||
loadMoreSecondColumn = undefined,
|
loadMoreSecondColumn = undefined,
|
||||||
hideSidebar = false,
|
hideSidebar = false,
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
if (
|
if (
|
||||||
contentContainer &&
|
contentContainer &&
|
||||||
contentContainer.scrollTop + contentContainer.clientHeight >=
|
contentContainer.scrollTop + contentContainer.clientHeight >=
|
||||||
contentContainer.scrollHeight - 600 &&
|
contentContainer.scrollHeight / 2 &&
|
||||||
loadingContent === false
|
loadingContent === false
|
||||||
) {
|
) {
|
||||||
loadingContent = true;
|
loadingContent = true;
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
secondColumnContainer &&
|
secondColumnContainer &&
|
||||||
secondColumnContainer.scrollTop +
|
secondColumnContainer.scrollTop +
|
||||||
secondColumnContainer.clientHeight >=
|
secondColumnContainer.clientHeight >=
|
||||||
secondColumnContainer.scrollHeight - 600 &&
|
secondColumnContainer.scrollHeight / 2 &&
|
||||||
loadingSecondColumn === false
|
loadingSecondColumn === false
|
||||||
) {
|
) {
|
||||||
loadingSecondColumn = true;
|
loadingSecondColumn = true;
|
||||||
@@ -135,12 +135,14 @@
|
|||||||
<Header {publicKey} center={headerCenter}></Header>
|
<Header {publicKey} center={headerCenter}></Header>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
{#if sidebar}
|
||||||
class="sidebar"
|
<div
|
||||||
style:display={hideSidebar ? "none" : "flex"}
|
class="sidebar"
|
||||||
style:padding-right="1rem">
|
style:display={hideSidebar ? "none" : "flex"}
|
||||||
{@render sidebar()}
|
style:padding-right="1rem">
|
||||||
</div>
|
{@render sidebar()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="secondColumn"
|
class="secondColumn"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ test("removing identities from ssh-agent and re-adding them", async ({
|
|||||||
}) => {
|
}) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText("did:key:z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S"),
|
page.getByText("z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S"),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
||||||
await peer.logOut();
|
await peer.logOut();
|
||||||
@@ -16,6 +16,6 @@ test("removing identities from ssh-agent and re-adding them", async ({
|
|||||||
|
|
||||||
await peer.authenticate();
|
await peer.authenticate();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText("did:key:z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S"),
|
page.getByText("z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S"),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 <span class="global-oid">${formatOid(revision)}</span>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
summary: "Review with accepted verdict",
|
||||||
|
input: [createAction({ type: "review", verdict: "accept", revision })],
|
||||||
|
output: `accepted revision <span class="global-oid">${formatOid(revision)}</span> with a review`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
summary: "Review with rejected verdict",
|
||||||
|
input: [createAction({ type: "review", verdict: "reject", revision })],
|
||||||
|
output: `rejected revision <span class="global-oid">${formatOid(revision)}</span> 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 <span class="global-oid">${formatOid(revision)}</span>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
summary: "Merge revision",
|
||||||
|
input: [
|
||||||
|
createAction({
|
||||||
|
type: "merge",
|
||||||
|
revision,
|
||||||
|
commit: actionOid,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
output: `merged revision <span class="global-oid">${formatOid(revision)}</span>`,
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
"$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 <span class="global-oid">${formatOid(oid)}</span>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 <span class="global-oid">${formatOid(oid)}</span>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -4,6 +4,11 @@ import path from "node:path";
|
|||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: "happy-dom",
|
||||||
|
include: ["tests/unit/**/*.test.ts"],
|
||||||
|
reporters: "verbose",
|
||||||
|
},
|
||||||
plugins: [svelte()],
|
plugins: [svelte()],
|
||||||
build: {
|
build: {
|
||||||
outDir: "build",
|
outDir: "build",
|
||||||
|
|||||||
Reference in New Issue
Block a user