Radish alpha
r
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
Radicle desktop app
Radicle
Git
Update css to improve contrast
Merged did:key:z6MkfgZK...5YMm opened 1 year ago
44 files changed +754 -370 0cac6319 ea2982fa
modified Cargo.lock
@@ -4245,6 +4245,7 @@ version = "0.0.0"
dependencies = [
 "anyhow",
 "base64 0.22.1",
+
 "either",
 "log",
 "radicle",
 "radicle-surf",
modified crates/radicle-tauri/Cargo.toml
@@ -17,6 +17,7 @@ tauri-build = { version = "2.0.1", features = ["isolation"] }
[dependencies]
anyhow = { version = "1.0.90" }
base64 = { version = "0.22.1" }
+
either = { version = "1.15" }
log = { version = "0.4.22" }
radicle = { git = "https://ash.radicle.garden/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git", package = "radicle", rev = "7c902b6905724345ba850eb6cca8f8becc9a9c72" }
radicle-types = { version = "0.1.0", path = "../radicle-types" }
modified crates/radicle-tauri/src/commands/cob/patch.rs
@@ -15,6 +15,8 @@ use radicle_types::traits::Profile;

use crate::AppState;

+
use either::Either;
+

#[tauri::command]
pub async fn list_patches(
    ctx: tauri::State<'_, AppState>,
@@ -28,18 +30,15 @@ pub async fn list_patches(
    let profile = ctx.profile();
    let cursor = skip.unwrap_or(0);
    let aliases = profile.aliases();
-

-
    let patches = match status {
-
        None => sqlite_service.list(rid)?.collect::<Vec<_>>(),
-
        Some(s) => sqlite_service
-
            .list_by_status(rid, s.into())?
-
            .collect::<Vec<_>>(),
+
    let counts = sqlite_service.counts(rid)?;
+
    let patches = match status.clone() {
+
        None => Either::Left(sqlite_service.list(rid)?),
+
        Some(s) => Either::Right(sqlite_service.list_by_status(rid, s.into())?),
    };

    match take {
        None => {
            let content = patches
-
                .into_iter()
                .map(|(id, patch)| models::patch::Patch::new(id, &patch, &aliases))
                .collect::<Vec<_>>();

@@ -50,10 +49,10 @@ pub async fn list_patches(
            })
        }
        Some(take) => {
-
            let more = cursor + take < patches.len();
+
            let total = status.map_or_else(|| counts.total(), |status| counts[status.into()]);
+
            let more = cursor + take < total;

            let content = patches
-
                .into_iter()
                .map(|(id, patch)| models::patch::Patch::new(id, &patch, &aliases))
                .skip(cursor)
                .take(take)
modified crates/radicle-tauri/src/commands/inbox.rs
@@ -1,14 +1,13 @@
use std::collections::BTreeMap;

-
use radicle::identity::DocAt;
+
use radicle::identity;
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::models::notification::{self, RepoGroupByItem};
use radicle_types::domain::inbox::service::Service;
use radicle_types::domain::inbox::traits::InboxService;
use radicle_types::error::Error;
@@ -20,10 +19,7 @@ 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,
-
> {
+
) -> Result<PaginatedQuery<RepoGroupByItem>, Error> {
    let profile = &ctx.profile;
    let aliases = profile.aliases();
    let cursor = params.skip.unwrap_or(0);
@@ -127,7 +123,7 @@ pub fn list_notifications(
            (qualified, items)
        })
        .filter(|(_, v)| !v.is_empty())
-
        .collect::<BTreeMap<git::Qualified<'static>, Vec<notification::NotificationItem>>>();
+
        .collect::<RepoGroupByItem>();

    Ok(PaginatedQuery {
        cursor,
@@ -147,7 +143,7 @@ pub fn count_notifications_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 identity::DocAt { doc, .. } = repo.identity_doc().ok()?;
            let project = doc.project().ok()?;

            Some((
modified crates/radicle-types/src/domain/inbox/models/notification.rs
@@ -33,7 +33,8 @@ pub struct NotificationRow {
    pub new: Option<git::Oid>,
}

-
pub type RepoGroup = std::collections::BTreeMap<git::Qualified<'static>, Vec<NotificationRow>>;
+
pub type RepoGroup = Vec<(git::Qualified<'static>, Vec<NotificationRow>)>;
+
pub type RepoGroupByItem = Vec<(git::Qualified<'static>, Vec<NotificationItem>)>;
pub type CountByRepo = (identity::RepoId, usize);

#[derive(Clone, Debug)]
modified crates/radicle-types/src/domain/inbox/service.rs
@@ -1,7 +1,5 @@
-
use radicle::git;
-

use crate::domain::inbox::models::notification::{
-
    CountByRepo, ListNotificationsError, NotificationRow, RepoGroupParams,
+
    CountByRepo, ListNotificationsError, RepoGroup, RepoGroupParams,
};
use crate::domain::inbox::traits::{InboxService, InboxStorage};

@@ -35,13 +33,7 @@ where
        self.inbox.counts_by_repo()
    }

-
    fn repo_group(
-
        &self,
-
        params: RepoGroupParams,
-
    ) -> Result<
-
        std::collections::BTreeMap<git::Qualified<'static>, Vec<NotificationRow>>,
-
        ListNotificationsError,
-
    > {
+
    fn repo_group(&self, params: RepoGroupParams) -> Result<RepoGroup, ListNotificationsError> {
        self.inbox.repo_group(params)
    }
}
modified crates/radicle-types/src/domain/patch/models/patch.rs
@@ -1,7 +1,9 @@
use std::collections::BTreeMap;
use std::collections::BTreeSet;
+
use std::ops::Index;

use radicle::node::AliasStore;
+
use radicle::patch::Status;
use radicle::profile::Aliases;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
@@ -668,3 +670,52 @@ impl FromRadicleAction<radicle::patch::Action> for Action {
        }
    }
}
+

+
#[derive(Debug, Default, TS, Serialize)]
+
#[ts(export)]
+
#[ts(export_to = "cob/patch/")]
+
#[serde(rename_all = "camelCase")]
+
pub struct PatchCounts {
+
    pub(crate) open: usize,
+
    pub(crate) draft: usize,
+
    pub(crate) archived: usize,
+
    pub(crate) merged: usize,
+
}
+

+
impl Index<Status> for PatchCounts {
+
    type Output = usize;
+

+
    fn index(&self, status: Status) -> &Self::Output {
+
        match status {
+
            Status::Draft => &self.draft,
+
            Status::Open => &self.open,
+
            Status::Archived => &self.archived,
+
            Status::Merged => &self.merged,
+
        }
+
    }
+
}
+

+
impl PatchCounts {
+
    pub fn new(open: usize, draft: usize, archived: usize, merged: usize) -> Self {
+
        Self {
+
            open,
+
            draft,
+
            archived,
+
            merged,
+
        }
+
    }
+

+
    pub fn total(&self) -> usize {
+
        self.open + self.draft + self.archived + self.merged
+
    }
+
}
+

+
#[derive(Debug, thiserror::Error)]
+
pub enum CountsError {
+
    #[error(transparent)]
+
    Sqlite(#[from] sqlite::Error),
+

+
    #[error(transparent)]
+
    Unknown(#[from] anyhow::Error),
+
    // to be extended as new error scenarios are introduced
+
}
modified crates/radicle-types/src/domain/patch/service.rs
@@ -5,6 +5,8 @@ use radicle::patch::PatchId;

use crate::domain::patch::traits::{PatchService, PatchStorage};

+
use super::models::patch::PatchCounts;
+

#[derive(Debug, Clone)]
pub struct Service<I>
where
@@ -42,4 +44,11 @@ where
    {
        self.patches.list_by_status(rid, status)
    }
+

+
    fn counts(
+
        &self,
+
        rid: identity::RepoId,
+
    ) -> Result<PatchCounts, super::models::patch::CountsError> {
+
        self.patches.counts(rid)
+
    }
}
modified crates/radicle-types/src/domain/patch/traits.rs
@@ -16,6 +16,11 @@ pub trait PatchStorage {
        rid: identity::RepoId,
        status: patch::Status,
    ) -> Result<impl Iterator<Item = (PatchId, Patch)>, models::patch::ListPatchesError>;
+

+
    fn counts(
+
        &self,
+
        rid: identity::RepoId,
+
    ) -> Result<models::patch::PatchCounts, models::patch::CountsError>;
}

pub trait PatchService {
@@ -29,4 +34,9 @@ pub trait PatchService {
        rid: identity::RepoId,
        status: patch::Status,
    ) -> Result<impl Iterator<Item = (PatchId, Patch)>, models::patch::ListPatchesError>;
+

+
    fn counts(
+
        &self,
+
        rid: identity::RepoId,
+
    ) -> Result<models::patch::PatchCounts, models::patch::CountsError>;
}
modified crates/radicle-types/src/error.rs
@@ -49,6 +49,9 @@ pub enum Error {
    #[error(transparent)]
    ListPatchesError(#[from] crate::domain::patch::models::patch::ListPatchesError),

+
    #[error(transparent)]
+
    PatchCountsError(#[from] crate::domain::patch::models::patch::CountsError),
+

    /// CobStore error.
    #[error(transparent)]
    CobStore(#[from] radicle::cob::store::Error),
modified crates/radicle-types/src/outbound/sqlite.rs
@@ -1,4 +1,3 @@
-
use std::collections::BTreeMap;
use std::path::Path;
use std::str::FromStr;
use std::sync::Arc;
@@ -10,7 +9,7 @@ use sqlite as sql;

use crate::domain::inbox::models::notification;
use crate::domain::inbox::traits::InboxStorage;
-
use crate::domain::patch::models::patch::ListPatchesError;
+
use crate::domain::patch::models::patch::{CountsError, ListPatchesError, PatchCounts, State};
use crate::domain::patch::traits::PatchStorage;
use crate::error::Error;

@@ -34,6 +33,33 @@ impl Sqlite {
}

impl PatchStorage for Sqlite {
+
    fn counts(&self, rid: identity::RepoId) -> Result<PatchCounts, CountsError> {
+
        let mut stmt = self.db.prepare(
+
            "SELECT
+
                 patch->'$.state' AS state,
+
                 COUNT(*) AS count
+
             FROM patches
+
             WHERE repo = ?1
+
             GROUP BY patch->'$.state.status'",
+
        )?;
+
        stmt.bind((1, &rid))?;
+

+
        stmt.into_iter()
+
            .try_fold(PatchCounts::default(), |mut counts, row| {
+
                let row = row?;
+
                let count = row.read::<i64, _>("count") as usize;
+
                let status = serde_json::from_str::<State>(row.read::<&str, _>("state"))
+
                    .map_err(|err| CountsError::Unknown(err.into()))?;
+
                match status {
+
                    State::Draft => counts.draft += count,
+
                    State::Open { .. } => counts.open += count,
+
                    State::Archived => counts.archived += count,
+
                    State::Merged { .. } => counts.merged += count,
+
                }
+
                Ok(counts)
+
            })
+
    }
+

    fn list(
        &self,
        rid: identity::RepoId,
@@ -110,17 +136,24 @@ impl InboxStorage for Sqlite {
    fn repo_group(
        &self,
        params: notification::RepoGroupParams,
-
    ) -> Result<
-
        std::collections::BTreeMap<git::Qualified<'static>, Vec<notification::NotificationRow>>,
-
        notification::ListNotificationsError,
-
    > {
+
    ) -> Result<notification::RepoGroup, notification::ListNotificationsError> {
        let mut stmt = self.db.prepare(
-
        "SELECT ref, substr(ref, 66) ref_without_namespace, json_group_array(json_object('row_id', rowid, 'timestamp', timestamp, 'remote', substr(ref, 17, 48), 'old', old, 'new', new)) as value
-
        FROM 'repository-notifications'
-
        WHERE repo = ?
-
        GROUP BY ref_without_namespace
-
        ORDER BY timestamp DESC"
-
    )?;
+
            "SELECT ref, substr(ref, 66) ref_without_namespace,
+
                json_group_array(
+
                    json_object(
+
                        'row_id', rowid,
+
                        'timestamp', timestamp,
+
                        'remote', substr(ref, 17, 48),
+
                        'old', old,
+
                        'new', new
+
                    )
+
                ) as value,
+
                MAX(timestamp) AS latest_timestamp
+
            FROM 'repository-notifications'
+
            WHERE repo = ?
+
            GROUP BY ref_without_namespace
+
            ORDER BY latest_timestamp DESC",
+
        )?;
        stmt.bind((1, &params.repo))?;

        stmt.into_iter()
@@ -133,9 +166,6 @@ impl InboxStorage for Sqlite {

                Ok((reference.to_owned(), items))
            })
-
            .collect::<Result<
-
                BTreeMap<git::Qualified<'static>, Vec<notification::NotificationRow>>,
-
                notification::ListNotificationsError,
-
            >>()
+
            .collect::<Result<notification::RepoGroup, notification::ListNotificationsError>>()
    }
}
modified flake.lock
@@ -167,11 +167,11 @@
    },
    "nixpkgs_3": {
      "locked": {
-
        "lastModified": 1742268799,
-
        "narHash": "sha256-IhnK4LhkBlf14/F8THvUy3xi/TxSQkp9hikfDZRD4Ic=",
+
        "lastModified": 1743975612,
+
        "narHash": "sha256-o4FjFOUmjSRMK7dn0TFdAT0RRWUWD+WsspPHa+qEQT8=",
        "owner": "NixOS",
        "repo": "nixpkgs",
-
        "rev": "da044451c6a70518db5b730fe277b70f494188f1",
+
        "rev": "a880f49904d68b5e53338d1e8c7bf80f59903928",
        "type": "github"
      },
      "original": {
@@ -218,11 +218,11 @@
        ]
      },
      "locked": {
-
        "lastModified": 1742351546,
-
        "narHash": "sha256-GPubFcOXyi8TVm1xpltHYPcfGr+iO+if2u/EtzFVnHQ=",
+
        "lastModified": 1744079607,
+
        "narHash": "sha256-5cog6Qd6w/bINdLO5mOysAHOHey8PwFXk4IWo+y+Czg=",
        "owner": "oxalica",
        "repo": "rust-overlay",
-
        "rev": "b0a7450168c62a46f87d204280e6d9d1c0292671",
+
        "rev": "f6b62cc99c25e79a1c17e9fca91dc6b6faebec6c",
        "type": "github"
      },
      "original": {
modified flake.nix
@@ -100,7 +100,7 @@
            npmDeps = fetchNpmDeps {
              name = pname + "-npm-deps-" + version;
              inherit src;
-
              hash = "sha256-jCz7gyjTXo9QOR8KGMGCnKjoODwHCFEmMtndrLeta0M="; # npmDepsHash : Update canary, don't touch!
+
              hash = "sha256-I5kIZ5o700M02PEiLiEa8uwtPQNqpkQp3JFZ2rTmPqg="; # npmDepsHash : Update canary, don't touch!
            };

            nativeBuildInputs = [
modified public/colors.css
@@ -1,12 +1,12 @@
:root {
-
  --color-background-default: #f5f5ff;
-
  --color-background-float: #fafaff;
-
  --color-background-dip: #f5f5ff;
+
  --color-background-default: #ebebff;
+
  --color-background-float: #f5f5ff;
+
  --color-background-dip: #dbdbff;
  --color-foreground-contrast: #232563;
-
  --color-foreground-dim: #6a6a81;
-
  --color-foreground-emphasized: #8585ff;
+
  --color-foreground-dim: #5c5c70;
+
  --color-foreground-emphasized: #7070ff;
  --color-foreground-emphasized-hover: #7070ff;
-
  --color-foreground-match-background: #f5f5ff;
+
  --color-foreground-match-background: #ebebff;
  --color-foreground-white: #ffffff;
  --color-foreground-black: #000000;
  --color-foreground-primary: #ff55ff;
@@ -16,21 +16,22 @@
  --color-foreground-yellow: #b29401;
  --color-foreground-disabled: #9b9bb1;
  --color-border-hint: #dbdbff;
-
  --color-border-default: #dbdbff;
+
  --color-border-default: #ccceff;
  --color-border-focus: #7070ff;
-
  --color-border-contrast: #24252d;
+
  --color-border-contrast: #25262d;
  --color-border-error: #ce97af;
  --color-border-merged: #ffe5ff;
-
  --color-border-match-background: #f5f5ff;
+
  --color-border-match-background: #ebebff;
  --color-border-primary: #ff1aff;
  --color-border-primary-hover: #ff55ff;
  --color-border-selected: #dbdbff;
  --color-border-warning: #ffe609;
  --color-border-success: #97ceb0;
-
  --color-fill-secondary: #7070ff;
-
  --color-fill-secondary-hover: #8585ff;
-
  --color-fill-ghost: #ebebff;
-
  --color-fill-ghost-hover: #f5f5ff;
+
  --boxShadow-lg: 0 0 64px 0 #0000001a;
+
  --color-fill-secondary: #5555ff;
+
  --color-fill-secondary-hover: #7070ff;
+
  --color-fill-ghost: #dbdbff;
+
  --color-fill-ghost-hover: #ebebff;
  --color-fill-separator: #dbdbff;
  --color-fill-primary: #ff55ff;
  --color-fill-primary-hover: #ff70ff;
@@ -42,7 +43,7 @@
  --color-fill-yellow: #ffe609;
  --color-fill-yellow-iconic: #ffff55;
  --color-fill-gray: #9b9bb1;
-
  --color-fill-secondary-shade: #5555ff;
+
  --color-fill-secondary-shade: #4545ef;
  --color-fill-diff-red: #efdce4;
  --color-fill-diff-red-light: #f7eef2;
  --color-fill-success-counter: #97ceb0;
@@ -51,55 +52,56 @@
  --color-fill-success-shade: #408760;
  --color-fill-diff-green: #badeca;
  --color-fill-diff-green-light: #dcefe5;
-
  --color-fill-float: #fafaff;
-
  --color-fill-float-hover: #dbdbff;
+
  --color-fill-float: #f5f5ff;
+
  --color-fill-float-hover: #fafaff;
  --color-fill-merged: #ffeeff;
  --color-fill-selected: #ebebff;
  --color-fill-warning: #ffffe5;
-
  --color-fill-counter: #fafaff;
-
  --color-fill-counter-emphasized: #dbdbff;
+
  --color-fill-counter: #dbdbff;
+
  --color-fill-counter-emphasized: #ebebff;
  --color-fill-delegate: #ffe5ff;
  --color-fill-private: #fff5d6;
-
  --color-fill-secondary-counter: #9497ff;
+
  --color-fill-secondary-counter: #b2b5ff;
  --color-fill-primary-counter: #ff8fff;
-
  --color-fill-ghost-shade: #dbdbff;
+
  --color-fill-ghost-shade: #ccceff;
}

:root[data-theme="dark"] {
-
  --color-background-default: #0a0d10;
-
  --color-background-float: #14151a;
+
  --color-background-default: #0a0e0f;
+
  --color-background-float: #25262d;
  --color-background-dip: #000000;
  --color-foreground-contrast: #f9f9fb;
  --color-foreground-dim: #9b9bb1;
  --color-foreground-emphasized: #7070ff;
-
  --color-foreground-emphasized-hover: #8585ff;
-
  --color-foreground-match-background: #0a0d10;
+
  --color-foreground-emphasized-hover: #b2b5ff;
+
  --color-foreground-match-background: #0a0e0f;
  --color-foreground-white: #ffffff;
  --color-foreground-black: #000000;
  --color-foreground-primary: #ff55ff;
  --color-foreground-primary-hover: #ff8fff;
  --color-foreground-success: #4fa877;
-
  --color-foreground-red: #aa5078;
+
  --color-foreground-red: #be7495;
  --color-foreground-yellow: #e5c001;
-
  --color-foreground-disabled: #6a6a81;
-
  --color-border-hint: #24252d;
-
  --color-border-default: #2e2f38;
+
  --color-foreground-disabled: #5c5c70;
+
  --color-border-hint: #2e2f38;
+
  --color-border-default: #393a46;
  --color-border-focus: #7070ff;
  --color-border-contrast: #ebebff;
  --color-border-error: #6b2b42;
  --color-border-merged: #6b006b;
-
  --color-border-match-background: #0a0d10;
+
  --color-border-match-background: #0a0e0f;
  --color-border-primary: #ff1aff;
  --color-border-primary-hover: #ff55ff;
  --color-border-selected: #232563;
  --color-border-warning: #4c4000;
  --color-border-success: #2a5a40;
+
  --boxShadow-lg: 0 0 64px 0 #00000099;
  --color-fill-secondary: #7070ff;
-
  --color-fill-secondary-hover: #8585ff;
+
  --color-fill-secondary-hover: #b2b5ff;
  --color-fill-secondary-shade: #5555ff;
-
  --color-fill-ghost: #24252d;
-
  --color-fill-ghost-hover: #2e2f38;
-
  --color-fill-separator: #24252d;
+
  --color-fill-ghost: #2e2f38;
+
  --color-fill-ghost-hover: #393a46;
+
  --color-fill-separator: #2e2f38;
  --color-fill-primary: #ff1aff;
  --color-fill-primary-hover: #ff4dff;
  --color-fill-primary-shade: #e500e5;
@@ -118,16 +120,16 @@
  --color-fill-success-shade: #408760;
  --color-fill-diff-green: #183425;
  --color-fill-diff-green-light: #142a1d;
-
  --color-fill-float: #14151a;
-
  --color-fill-float-hover: #1b1c22;
+
  --color-fill-float: #25262d;
+
  --color-fill-float-hover: #2e2f38;
  --color-fill-merged: #1a001a;
  --color-fill-selected: #16173d;
  --color-fill-warning: #191500;
  --color-fill-counter: #393a46;
-
  --color-fill-counter-emphasized: #16173d;
+
  --color-fill-counter-emphasized: #5c5c70;
  --color-fill-delegate: #3d003d;
  --color-fill-private: #4c4000;
-
  --color-fill-secondary-counter: #9497ff;
+
  --color-fill-secondary-counter: #ccceff;
  --color-fill-primary-counter: #ff8fff;
-
  --color-fill-ghost-shade: #1b1c22;
+
  --color-fill-ghost-shade: #25262d;
}
modified public/index.css
@@ -67,7 +67,7 @@ body {
  display: flex;
  align-items: center;
  justify-content: center;
-
  background-color: var(--color-fill-ghost-hover);
+
  background-color: var(--color-fill-counter);
  clip-path: var(--1px-corner-fill);
  height: 1.5rem;
  padding: 0 0.5rem;
modified src/components/Changes.svelte
@@ -33,6 +33,9 @@

    hideChanges = false;
    filesExpanded = true;
+
    selectedCommit = undefined;
+
    base = revision.base;
+
    head = revision.head;
  });

  function selectRevision({
@@ -90,22 +93,26 @@
  .commit-dot.active {
    background-color: var(--color-border-focus);
  }
-
  .commit:hover .commit-dot:not(.active) {
+
  .commit:hover:not(.single-commit) .commit-dot:not(.active) {
    background-color: var(--color-foreground-contrast);
  }
-
  .commit:hover {
+
  .commit:hover:not(.single-commit) {
    background-color: var(--color-background-float);
  }
  .disabled {
    color: var(--color-foreground-disabled) !important;
  }
  .summary {
+
    cursor: pointer;
    padding: 0.25rem 0;
  }
-
  .summary:hover {
+
  .summary:hover:not(.single-commit) {
    background-color: var(--color-background-float);
    color: var(--color-foreground-contrast) !important;
  }
+
  .single-commit {
+
    cursor: default !important;
+
  }
</style>

<div
@@ -144,13 +151,15 @@
          <!-- svelte-ignore a11y_click_events_have_key_events -->
          <div
            class="global-flex txt-small summary"
+
            class:single-commit={commits.length === 1}
            class:disabled={selectedCommit}
-
            style:cursor="pointer"
-
            onclick={() =>
+
            onclick={() => {
+
              if (commits.length === 1) return;
              selectRevision({
                headId: revision.head,
                baseId: revision.base,
-
              })}>
+
              });
+
            }}>
            <Icon name="branch" />
            {commits.length}
            {pluralize("commit", commits.length)} on base
@@ -161,20 +170,26 @@
          </div>
          <div class="commits">
            {#each commits.reverse() as commit}
-
              <div class="commit" style:position="relative">
+
              <div
+
                class="commit"
+
                class:single-commit={commits.length === 1}
+
                style:position="relative">
                <div class="commit-dot"></div>
                <div
                  class="commit-dot"
                  class:active={isActiveCommit(commit.id)}>
                </div>
                <CobCommitTeaser
+
                  hoverable={commits.length > 1}
                  disabled={isTeaserDisabled(commit.id)}
-
                  onclick={() =>
+
                  onclick={() => {
+
                    if (commits.length === 1) return;
                    selectRevision({
                      headId: commit.id,
                      baseId: commit.parents[0],
                      commitId: commit.id,
-
                    })}
+
                    });
+
                  }}
                  {commit} />
              </div>
            {/each}
added src/components/Clipboard.svelte
@@ -0,0 +1,41 @@
+
<script lang="ts">
+
  import debounce from "lodash/debounce";
+

+
  import Icon from "@app/components/Icon.svelte";
+
  import { writeToClipboard } from "@app/lib/invoke";
+

+
  interface Props {
+
    text: string;
+
  }
+

+
  const { text }: Props = $props();
+

+
  let icon: "copy" | "checkmark" = $state("copy");
+

+
  const restoreIcon = debounce(() => {
+
    icon = "copy";
+
  }, 800);
+

+
  export async function copy() {
+
    await writeToClipboard(text);
+
    icon = "checkmark";
+
    restoreIcon();
+
  }
+
</script>
+

+
<style>
+
  .clipboard {
+
    width: 1.5rem;
+
    height: 1.5rem;
+
    cursor: pointer;
+
    display: inline-flex;
+
    justify-content: center;
+
    align-items: center;
+
    user-select: none;
+
  }
+
</style>
+

+
<!-- svelte-ignore a11y_click_events_have_key_events -->
+
<span role="button" tabindex="0" class="clipboard" onclick={copy}>
+
  <Icon name={icon} />
+
</span>
modified src/components/CobCommitTeaser.svelte
@@ -13,9 +13,10 @@
    disabled: boolean;
    commit: Commit;
    onclick: () => void;
+
    hoverable?: boolean;
  }

-
  const { disabled, commit, onclick }: Props = $props();
+
  const { disabled, hoverable = false, commit, onclick }: Props = $props();

  let commitMessageVisible = $state(false);
</script>
@@ -67,9 +68,6 @@
    white-space: pre-wrap;
    word-wrap: break-word;
  }
-
  .summary {
-
    cursor: pointer;
-
  }
</style>

<!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -77,7 +75,7 @@
<div class="teaser" class:disabled {onclick} aria-label="commit-teaser">
  <div class="left">
    <div class="message">
-
      <div class="summary" use:twemoji>
+
      <div style:cursor={hoverable ? "pointer" : "default"} use:twemoji>
        <InlineTitle fontSize="small" content={commit.summary} />
      </div>
      {#if commit.message.trim() !== commit.summary.trim()}
added src/components/Command.svelte
@@ -0,0 +1,37 @@
+
<script lang="ts">
+
  import Clipboard from "@app/components/Clipboard.svelte";
+
  import Border from "./Border.svelte";
+

+
  interface Props {
+
    command: string;
+
    styleWidth: string;
+
  }
+

+
  const { command, styleWidth }: Props = $props();
+

+
  let clipboard: Clipboard;
+
</script>
+

+
<style>
+
  .cmd {
+
    color: var(--color-foreground-dim);
+
  }
+
  .cmd:hover {
+
    color: var(--color-foreground-contrast);
+
  }
+
</style>
+

+
<div class="cmd txt-monospace" style:width={styleWidth}>
+
  <Border
+
    hoverable
+
    onclick={() => clipboard.copy()}
+
    styleBackgroundColor="var(--color-background-float)"
+
    styleCursor="pointer"
+
    styleJustifyContent="space-between"
+
    stylePadding="0.25rem 0.5rem"
+
    {styleWidth}
+
    variant="ghost">
+
    $ {command}
+
    <Clipboard bind:this={clipboard} text={command} />
+
  </Border>
+
</div>
modified src/components/Comment.svelte
@@ -35,6 +35,7 @@
    editComment?: (body: string, embeds: Embed[]) => Promise<void>;
    reactOnComment?: (authors: Author[], reaction: string) => Promise<void>;
    styleWidth?: string;
+
    allowAttachments?: boolean;
  }

  /* eslint-disable prefer-const */
@@ -55,6 +56,7 @@
    reactOnComment,
    styleWidth,
    emptyBodyTooltip,
+
    allowAttachments = true,
  }: Props = $props();
  /* eslint-enable prefer-const */

@@ -191,6 +193,7 @@
            {embeds}
            {disallowEmptyBody}
            {emptyBodyTooltip}
+
            {allowAttachments}
            borderVariant="ghost"
            submitInProgress={state === "submit"}
            submitCaption="Save"
modified src/components/CommentToggleInput.svelte
@@ -47,6 +47,7 @@
    {disallowEmptyBody}
    {rid}
    {placeholder}
+
    borderVariant="ghost"
    submitInProgress={state === "submit"}
    {focus}
    stylePadding="0.5rem 0.75rem"
@@ -69,8 +70,8 @@
  <Border
    hoverable
    styleCursor="text"
-
    variant="float"
-
    styleHeight="2.5rem"
+
    variant="ghost"
+
    styleHeight="40px"
    styleWidth="100%"
    onclick={e => {
      e.preventDefault();
added src/components/ConfirmClear.svelte
@@ -0,0 +1,43 @@
+
<script lang="ts">
+
  import { closeFocused } from "@app/components/Popover.svelte";
+

+
  import Border from "./Border.svelte";
+
  import Button from "./Button.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import NakedButton from "@app/components/NakedButton.svelte";
+
  import OutlineButton from "./OutlineButton.svelte";
+
  import Popover from "./Popover.svelte";
+

+
  interface Props {
+
    clear: () => void;
+
    subject: string;
+
  }
+

+
  /* eslint-disable prefer-const */
+
  let { clear, subject }: Props = $props();
+
  /* eslint-enable prefer-const */
+
</script>
+

+
<Popover popoverPositionRight="0" popoverPositionTop="2.5rem">
+
  {#snippet toggle(onclick)}
+
    <NakedButton stylePadding="0 4px" variant="ghost" {onclick}>
+
      <Icon name="broom-double" />
+
    </NakedButton>
+
  {/snippet}
+

+
  {#snippet popover()}
+
    <Border variant="ghost" stylePadding="1rem">
+
      <div class="global-flex txt-small">
+
        <div style:white-space="nowrap" style:margin-right="1rem">
+
          Clear all {subject} notifications?
+
        </div>
+
        <div class="global-flex" style:justify-content="space-between">
+
          <OutlineButton variant="ghost" onclick={closeFocused}>
+
            Cancel
+
          </OutlineButton>
+
          <Button variant="ghost" onclick={clear}>Clear all</Button>
+
        </div>
+
      </div>
+
    </Border>
+
  {/snippet}
+
</Popover>
modified src/components/CopyableId.svelte
@@ -1,28 +1,9 @@
<script lang="ts">
-
  import type { ComponentProps } from "svelte";
+
  import Clipboard from "./Clipboard.svelte";

-
  import debounce from "lodash/debounce";
-
  import { writeText } from "@tauri-apps/plugin-clipboard-manager";
+
  const { id }: { id: string } = $props();

-
  import Icon from "./Icon.svelte";
-

-
  const {
-
    id,
-
  }: {
-
    id: string;
-
  } = $props();
-

-
  let icon: ComponentProps<typeof Icon>["name"] = $state("copy");
-

-
  const restoreIcon = debounce(() => {
-
    icon = "copy";
-
  }, 1000);
-

-
  async function copy() {
-
    await writeText(id);
-
    icon = "checkmark";
-
    restoreIcon();
-
  }
+
  let clipboard: Clipboard;
</script>

<style>
@@ -40,8 +21,8 @@
<div
  role="button"
  tabindex="0"
-
  onclick={copy}
+
  onclick={() => clipboard.copy()}
  class="copyable-id global-flex txt-small txt-monospace">
  {id}
-
  <Icon name={icon} />
+
  <Clipboard bind:this={clipboard} text={id} />
</div>
modified src/components/Diff.svelte
@@ -239,10 +239,10 @@
    cursor: default;
  }
  .addition {
-
    background-color: var(--color-fill-diff-green-light);
+
    background-color: var(--color-fill-diff-green);
  }
  .deletion {
-
    background-color: var(--color-fill-diff-red-light);
+
    background-color: var(--color-fill-diff-red);
  }
  .addition > .left,
  .addition > .right,
@@ -299,17 +299,17 @@
    align-self: flex-start;
  }
  .thread {
-
    background-color: var(--color-fill-float-hover);
+
    background-color: var(--color-background-default);
+
    box-shadow: inset 0 0 0 2px var(--color-border-hint);
    padding: 0.5rem;
-
    margin-bottom: 1rem;
  }
  .comment-form {
-
    background-color: var(--color-fill-float-hover);
+
    background-color: var(--color-background-default);
+
    box-shadow: inset 0 0 0 2px var(--color-border-default);
    font-family: var(--font-family-sans-serif);
    display: flex;
    flex-direction: column;
    padding: 1rem;
-
    margin-bottom: 1rem;
  }
  .comment-header {
    display: flex;
modified src/components/Discussion.svelte
@@ -88,7 +88,7 @@
    width: 2px;
    height: 1rem;
    margin-left: 1.25rem;
-
    background-color: var(--color-background-float);
+
    background-color: var(--color-border-hint);
  }
</style>

modified src/components/ExtendedTextarea.svelte
@@ -39,6 +39,7 @@
      embeds: Map<string, Embed>;
    }) => Promise<void>;
    close: () => void;
+
    allowAttachments?: boolean;
  }

  /* eslint-disable prefer-const */
@@ -62,6 +63,7 @@
    borderVariant = "float",
    submit,
    close,
+
    allowAttachments = true,
  }: Props = $props();
  /* eslint-enable prefer-const */

@@ -85,37 +87,39 @@

  onMount(async () => {
    if (window.__TAURI_INTERNALS__) {
-
      dragEnterUnlistenFn = await listen("tauri://drag-enter", () => {
-
        draggingOver = true;
-
      });
+
      if (allowAttachments) {
+
        dragEnterUnlistenFn = await listen("tauri://drag-enter", () => {
+
          draggingOver = true;
+
        });

-
      dragLeaveUnlistenFn = await listen("tauri://drag-leave", () => {
-
        draggingOver = false;
-
      });
+
        dragLeaveUnlistenFn = await listen("tauri://drag-leave", () => {
+
          draggingOver = false;
+
        });

-
      dragDropUnlistenFn = await listen<{
-
        paths: string[];
-
        position: { x: number; y: number };
-
      }>("tauri://drag-drop", async event => {
-
        draggingOver = false;
-
        const [preBody, afterBody] = splitBody();
+
        dragDropUnlistenFn = await listen<{
+
          paths: string[];
+
          position: { x: number; y: number };
+
        }>("tauri://drag-drop", async event => {
+
          draggingOver = false;
+
          const [preBody, afterBody] = splitBody();

-
        return Promise.all(
-
          event.payload.paths.map(async path => {
-
            const pathSegments = path.split("/");
-
            const name = pathSegments[pathSegments.length - 1];
-
            const uploadLabel = `[Uploading ${name}...]()\n`;
+
          return Promise.all(
+
            event.payload.paths.map(async path => {
+
              const pathSegments = path.split("/");
+
              const name = pathSegments[pathSegments.length - 1];
+
              const uploadLabel = `[Uploading ${name}...]()\n`;

-
            body = preBody.concat(uploadLabel, afterBody);
-
            const oid = await invoke<string>("save_embed_by_path", {
-
              rid,
-
              path,
-
            });
-
            embeds.set(oid, { name, content: `git:${oid}` });
-
            return `[${name}](${oid})\n`;
-
          }),
-
        ).then(texts => updateBodyAndSelection(texts, preBody, afterBody));
-
      });
+
              body = preBody.concat(uploadLabel, afterBody);
+
              const oid = await invoke<string>("save_embed_by_path", {
+
                rid,
+
                path,
+
              });
+
              embeds.set(oid, { name, content: `git:${oid}` });
+
              return `[${name}](${oid})\n`;
+
            }),
+
          ).then(texts => updateBodyAndSelection(texts, preBody, afterBody));
+
        });
+
      }
    }
  });

@@ -145,6 +149,10 @@
  }

  async function handlePaste(e: ClipboardEvent) {
+
    if (!allowAttachments) {
+
      return;
+
    }
+

    if (e.clipboardData?.files && e.clipboardData.files.length > 0) {
      e.preventDefault();
      const [preBody, afterBody] = splitBody();
@@ -276,8 +284,8 @@
    {#if !preview}
      <div
        class="txt-overflow txt-small txt-missing"
-
        title={`Drag and drop files to add them. Markdown is supported. Press ${utils.modifierKey()}↵ to submit.`}>
-
        Drag and drop files to add them.
+
        title={`${allowAttachments ? "Drag and drop files to add them. " : ""}Markdown is supported. Press ${utils.modifierKey()}↵ to submit.`}>
+
        {#if allowAttachments}Drag and drop files to add them.{/if}
        <Icon
          name="markdown"
          styleDisplay="inline"
@@ -286,10 +294,12 @@
      </div>
    {/if}
    <div class="buttons">
-
      <OutlineButton variant="ghost" onclick={selectFiles} disabled={preview}>
-
        <Icon name="attachment" />
-
        Attach
-
      </OutlineButton>
+
      {#if allowAttachments}
+
        <OutlineButton variant="ghost" onclick={selectFiles} disabled={preview}>
+
          <Icon name="attachment" />
+
          Attach
+
        </OutlineButton>
+
      {/if}
      <OutlineButton variant="ghost" onclick={() => (preview = !preview)}>
        <Icon name={preview ? "pen" : "eye"} />
        {preview ? "Edit" : "Preview"}
modified src/components/Icon.svelte
@@ -17,6 +17,7 @@
      | "broom"
      | "broom-double"
      | "checkmark"
+
      | "checkout"
      | "chevron-down"
      | "chevron-right"
      | "clock"
@@ -237,6 +238,20 @@
    <path d="M4 8V9H3L3 8H4Z" />
    <path d="M5 9L5 10L4 10L4 9H5Z" />
    <path d="M6 10L6 11H5L5 10L6 10Z" />
+
  {:else if name === "checkout"}
+
    <path d="M5 5H11V6H5V5Z" />
+
    <path d="M4 6L5 6L5 11H4L4 6Z" />
+
    <path d="M11 6L12 6V11H11L11 6Z" />
+
    <path d="M3 11H4L4 12H3V11Z" />
+
    <path d="M12 11L13 11V12H12V11Z" />
+
    <path d="M3 13H13V14H3V13Z" />
+
    <path d="M4 10H12V11L4 11L4 10Z" />
+
    <path d="M13 12L14 12V13L13 13V12Z" />
+
    <path d="M2 12H3L3 13H2V12Z" />
+
    <path d="M7 2L9 2V6H7V2Z" />
+
    <path d="M7 7H9V8H7V7Z" />
+
    <path d="M6 6H10V7H6V6Z" />
+
    <path d="M5 5H11V6H5V5Z" />
  {:else if name === "chevron-down"}
    <path d="M9 10V11H8V10H9Z" />
    <path d="M10 9V10L9 10V9H10Z" />
modified src/components/Id.svelte
@@ -65,8 +65,8 @@
    justify-content: center;
    z-index: 20;
    bottom: 1.5rem;
-
    background: var(--color-fill-ghost);
-
    color: var(--color-fill-gray);
+
    background: var(--color-fill-counter);
+
    color: var(--color-foreground-contrast);
    box-shadow: var(--elevation-low);
    font-family: var(--font-family-sans-serif);
    font-size: var(--font-size-small);
modified src/components/IssueSecondColumn.svelte
@@ -72,6 +72,12 @@
    align-items: center;
    min-height: 2.5rem;
    margin-bottom: 1rem;
+
    min-width: 450px;
+
  }
+
  .list {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 2px;
  }
</style>

@@ -212,30 +218,28 @@
  </div>
{/if}

-
<Border
-
  variant={searchResults.length === 1 && searchInput !== ""
-
    ? "secondary"
-
    : "float"}
-
  styleFlexDirection="column"
-
  styleOverflow="hidden"
-
  styleGap="2px"
-
  styleAlignItems="center"
-
  styleJustifyContent="center">
-
  {#each searchResults as result}
-
    <IssueTeaser
-
      compact
-
      issue={result.obj.issue}
-
      {status}
-
      rid={repo.rid}
-
      selected={result.obj.issue.id === selectedIssueId} />
-
  {/each}
-

-
  {#if searchResults.length === 0}
-
    <div
-
      class="global-flex"
-
      style:height="74px"
-
      style:justify-content="center"
-
      style:min-width="405px">
+
{#if searchResults.length > 0}
+
  <div class="list">
+
    {#each searchResults as result}
+
      <IssueTeaser
+
        selected={result.obj.issue.id === selectedIssueId}
+
        focussed={searchResults.length === 1 && searchInput !== ""}
+
        compact
+
        issue={result.obj.issue}
+
        {status}
+
        rid={repo.rid} />
+
    {/each}
+
  </div>
+
{/if}
+

+
{#if searchResults.length === 0}
+
  <Border
+
    variant="ghost"
+
    styleFlexDirection="column"
+
    styleOverflow="hidden"
+
    styleAlignItems="center"
+
    styleJustifyContent="center">
+
    <div class="global-flex" style:height="84px" style:justify-content="center">
      <div class="txt-missing txt-small global-flex" style:gap="0.25rem">
        <Icon name="none" />
        {#if issues.length > 0 && searchResults.length === 0}
@@ -245,5 +249,5 @@
        {/if}
      </div>
    </div>
-
  {/if}
-
</Border>
+
  </Border>
+
{/if}
modified src/components/IssueTeaser.svelte
@@ -42,7 +42,6 @@
    align-items: center;
    gap: 0.25rem;
    min-height: 5rem;
-
    min-width: 405px;
    background-color: var(--color-background-float);
    padding: 1rem;
    cursor: pointer;
@@ -61,13 +60,13 @@
    margin-right: 1rem;
  }
  .issue-teaser:first-of-type {
-
    clip-path: var(--1px-top-corner-fill);
+
    clip-path: var(--2px-top-corner-fill);
  }
  .issue-teaser:last-of-type {
-
    clip-path: var(--1px-bottom-corner-fill);
+
    clip-path: var(--2px-bottom-corner-fill);
  }
  .issue-teaser:only-of-type {
-
    clip-path: var(--1px-corner-fill);
+
    clip-path: var(--2px-corner-fill);
  }
</style>

@@ -80,6 +79,7 @@
    class:selected
    style:align-items="flex-start"
    style:clip-path={focussed ? "none" : undefined}
+
    style:padding={focussed ? "1rem" : "20px"}
    onclick={() => {
      void push({ resource: "repo.issue", rid, issue: issue.id, status });
    }}>
modified src/components/NotificationTeaser.svelte
@@ -20,6 +20,7 @@

  import Icon from "./Icon.svelte";
  import InlineTitle from "./InlineTitle.svelte";
+
  import NakedButton from "./NakedButton.svelte";
  import NodeId from "./NodeId.svelte";

  interface Props {
@@ -142,6 +143,12 @@
    font-size: var(--font-size-regular);
    word-break: break-word;
  }
+
  .clear-icon {
+
    display: none;
+
  }
+
  .notification-teaser:hover .clear-icon {
+
    display: flex;
+
  }
  .selected {
    background-color: var(--color-fill-float-hover);
  }
@@ -152,13 +159,6 @@
    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);
  }
@@ -210,18 +210,19 @@
        </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 class="clear-icon">
+
      <NakedButton
+
        stylePadding="0 4px"
+
        variant="ghost"
+
        onclick={e => {
+
          e.stopPropagation();
+
          void clearByIds(
+
            rid,
+
            notificationItems.map(n => n.rowId),
+
          );
+
        }}>
+
        <Icon name={clearIcon} />
+
      </NakedButton>
    </div>
  </div>
</div>
modified src/components/OutlineButton.svelte
@@ -2,21 +2,23 @@
  import type { Snippet } from "svelte";

  interface Props {
+
    active?: boolean;
    children: Snippet;
-
    variant: "primary" | "secondary" | "ghost";
-
    onclick?: () => void;
    disabled?: boolean;
+
    onclick?: () => void;
    styleHeight?: "2rem" | "2.5rem";
-
    active?: boolean;
+
    title?: string;
+
    variant: "primary" | "secondary" | "ghost";
  }

  const {
+
    active = false,
    children,
-
    variant,
-
    onclick,
    disabled = false,
+
    onclick,
    styleHeight = "2rem",
-
    active = false,
+
    title,
+
    variant,
  }: Props = $props();

  const style = $derived(
@@ -269,15 +271,16 @@

<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
-
  class="container"
-
  style:height={styleHeight}
-
  style:cursor={!disabled ? "pointer" : "default"}
-
  class:disabled
  class:active
+
  class:disabled
+
  class="container"
  onclick={!disabled ? onclick : undefined}
  role="button"
+
  style:cursor={!disabled ? "pointer" : "default"}
+
  style:height={styleHeight}
  tabindex="0"
-
  {style}>
+
  {style}
+
  {title}>
  <div class="pixel p1-1"></div>
  <div class="pixel p1-2"></div>
  <div class="pixel p1-3"></div>
modified src/components/PatchTeaser.svelte
@@ -47,7 +47,6 @@
    justify-content: space-between;
    gap: 0.25rem;
    min-height: 5rem;
-
    min-width: 440px;
    background-color: var(--color-background-float);
    padding: 1rem;
    cursor: pointer;
@@ -66,13 +65,13 @@
    margin-right: 1rem;
  }
  .patch-teaser:first-of-type {
-
    clip-path: var(--1px-top-corner-fill);
+
    clip-path: var(--2px-top-corner-fill);
  }
  .patch-teaser:last-of-type {
-
    clip-path: var(--1px-bottom-corner-fill);
+
    clip-path: var(--2px-bottom-corner-fill);
  }
  .patch-teaser:only-of-type {
-
    clip-path: var(--1px-corner-fill);
+
    clip-path: var(--2px-corner-fill);
  }
</style>

@@ -85,6 +84,7 @@
    class="patch-teaser"
    style:align-items="flex-start"
    style:clip-path={focussed ? "none" : undefined}
+
    style:padding={focussed ? "1rem" : "20px"}
    onclick={async () => {
      if (loadPatch) {
        await loadPatch(patch.id);
modified src/components/PatchesSecondColumn.svelte
@@ -54,7 +54,7 @@
    color: var(--color-fill-success);
  }
  .archived {
-
    color: var(--color-fill-yellow-iconic);
+
    color: var(--color-foreground-yellow);
  }
  .merged {
    color: var(--color-foreground-primary);
modified src/components/RepoNotifications.svelte
@@ -3,16 +3,17 @@
  import type { NotificationItem } from "@bindings/cob/inbox/NotificationItem";

  import * as router from "@app/lib/router";
+

  import Button from "./Button.svelte";
+
  import ConfirmClear from "./ConfirmClear.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[]>;
+
    items: [string, NotificationItem[]][];
    more: boolean;
  }

@@ -31,7 +32,7 @@
    display: flex;
    justify-content: space-between;
    align-items: center;
-
    padding-right: 1.5rem;
+
    padding-right: 1rem;
  }
  .container {
    display: flex;
@@ -48,12 +49,16 @@
      </span>
      {repo.count}
    </div>
-
    <Icon onclick={() => clearByRepo(repo.rid)} name="broom-double" />
+
    <ConfirmClear
+
      subject={repo.name}
+
      clear={() => {
+
        void clearByRepo(repo.rid);
+
      }} />
  </div>
{/if}

<div class="container">
-
  {#each Object.entries(items).sort((a, b) => b[1][0].timestamp - a[1][0].timestamp) as [_, notificationItems]}
+
  {#each items.sort((a, b) => b[1][0].timestamp - a[1][0].timestamp) as [_, notificationItems]}
    <NotificationTeaser
      {clearByIds}
      rid={repo.rid}
modified src/components/Review.svelte
@@ -365,6 +365,7 @@

    <div class="review-body">
      <CommentComponent
+
        allowAttachments={false}
        rid={repo.rid}
        disallowEmptyBody={review.verdict === undefined}
        emptyBodyTooltip="Summary is mandatory when verdict is None"
modified src/components/Reviews.svelte
@@ -1,20 +1,24 @@
<script lang="ts">
  import type { Config } from "@bindings/config/Config";
  import type { PatchStatus } from "@app/views/repo/router";
+
  import type { Review } from "@bindings/cob/patch/Review";
  import type { Revision } from "@bindings/cob/patch/Revision";
  import type { Verdict } from "@bindings/cob/patch/Verdict";

  import { announce } from "@app/components/AnnounceSwitch.svelte";
+
  import { closeFocused } from "./Popover.svelte";
+
  import { didFromPublicKey } from "@app/lib/utils";
  import { invoke } from "@app/lib/invoke";
  import { nodeRunning } from "@app/lib/events";
+
  import { push } from "@app/lib/router";

-
  import Button from "@app/components/Button.svelte";
+
  import Border from "@app/components/Border.svelte";
  import Icon from "@app/components/Icon.svelte";
  import NakedButton from "@app/components/NakedButton.svelte";
+
  import OutlineButton from "@app/components/OutlineButton.svelte";
+
  import Popover from "@app/components/Popover.svelte";
  import ReviewTeaser from "@app/components/ReviewTeaser.svelte";

-
  import { didFromPublicKey } from "@app/lib/utils";
-

  interface Props {
    rid: string;
    patchId: string;
@@ -47,9 +51,9 @@
      revision.reviews === undefined || revision.reviews.length === 0;
  });

-
  async function createReview(verdict?: Verdict) {
+
  async function createReview(verdict?: Verdict): Promise<Review | undefined> {
    try {
-
      await invoke("edit_patch", {
+
      return await invoke("edit_patch", {
        rid: rid,
        cobId: patchId,
        action: {
@@ -64,8 +68,6 @@
      });
    } catch (error) {
      console.error("Creating a review failed: ", error);
-
    } finally {
-
      await loadPatch();
    }
  }
</script>
@@ -94,30 +96,86 @@
    </NakedButton>

    <div class="global-flex" style:margin-left="auto">
-
      <NakedButton
-
        variant="secondary"
-
        disabled={hasOwnReview}
-
        title={hasOwnReview ? "You already published a review" : undefined}
-
        onclick={() => createReview()}>
-
        <Icon name="plus" />
-
        <span class="txt-small">Write Review</span>
-
      </NakedButton>
-
      <Button
-
        variant="danger"
-
        disabled={hasOwnReview}
-
        title={hasOwnReview ? "You already published a review" : undefined}
-
        onclick={() => createReview("reject")}>
-
        <Icon name="comment-cross" />
-
        <span class="txt-small">Reject</span>
-
      </Button>
-
      <Button
-
        variant="success"
-
        disabled={hasOwnReview}
-
        title={hasOwnReview ? "You already published a review" : undefined}
-
        onclick={() => createReview("accept")}>
-
        <Icon name="comment-checkmark" />
-
        <span class="txt-small">Accept</span>
-
      </Button>
+
      <Popover popoverPositionRight="0" popoverPositionTop="2.5rem">
+
        {#snippet toggle(onclick)}
+
          <NakedButton
+
            variant="ghost"
+
            disabled={hasOwnReview}
+
            {onclick}
+
            title={hasOwnReview ? "You already published a review" : undefined}>
+
            <Icon name="plus" />
+
            <span class="txt-small">Review</span>
+
          </NakedButton>
+
        {/snippet}
+

+
        {#snippet popover()}
+
          <Border variant="ghost" stylePadding="1rem">
+
            <OutlineButton
+
              variant="ghost"
+
              disabled={hasOwnReview}
+
              title={hasOwnReview
+
                ? "You already published a review"
+
                : undefined}
+
              onclick={async () => {
+
                const newReview = await createReview();
+
                if (newReview) {
+
                  await push({
+
                    resource: "repo.patch",
+
                    rid,
+
                    patch: patchId,
+
                    status,
+
                    reviewId: newReview.id,
+
                  });
+
                }
+
                closeFocused();
+
              }}>
+
              <span
+
                class="global-flex"
+
                style:color="var(--color-foreground-dim)">
+
                <Icon name="comment" />
+
                <span class="txt-small">Write Review</span>
+
              </span>
+
            </OutlineButton>
+
            <OutlineButton
+
              variant="ghost"
+
              disabled={hasOwnReview}
+
              title={hasOwnReview
+
                ? "You already published a review"
+
                : undefined}
+
              onclick={async () => {
+
                createReview("reject");
+
                await loadPatch();
+
                closeFocused();
+
              }}>
+
              <span
+
                class="global-flex"
+
                style:color="var(--color-foreground-red)">
+
                <Icon name="comment-cross" />
+
                <span class="txt-small">Reject</span>
+
              </span>
+
            </OutlineButton>
+
            <OutlineButton
+
              variant="ghost"
+
              disabled={hasOwnReview}
+
              title={hasOwnReview
+
                ? "You already published a review"
+
                : undefined}
+
              onclick={async () => {
+
                createReview("accept");
+
                await loadPatch();
+
                closeFocused();
+
              }}>
+
              <span
+
                class="global-flex"
+
                style:color="var(--color-foreground-success)">
+
                <Icon name="comment-checkmark" />
+
                <span class="txt-small">Accept</span>
+
                <span></span>
+
              </span>
+
            </OutlineButton>
+
          </Border>
+
        {/snippet}
+
      </Popover>
    </div>
  </div>

modified src/components/Settings.svelte
@@ -33,7 +33,7 @@
  {#snippet popover()}
    <Border variant="ghost" stylePadding="0.5rem 1rem" styleWidth="27rem">
      <div
-
        class="global-flex"
+
        class="global-flex txt-small"
        style:flex-direction="column"
        style:align-items="flex-start"
        style:gap="1rem"
modified src/components/Thread.svelte
@@ -166,11 +166,13 @@
  </div>
  {#if replies.length > 0 || (createReply && showReplyForm)}
    {#if inline}
-
      <div style:background-color="var(--color-background-float)">
+
      <div
+
        style="background-color: var(--color-background-deafult); border: 2px solid
+
        var(--color-border-hint)">
        {@render repliesSnippet()}
      </div>
    {:else}
-
      <Border variant="float" styleOverflow="hidden" flatTop={!inline}>
+
      <Border variant="ghost" styleOverflow="hidden" flatTop={!inline}>
        {@render repliesSnippet()}
      </Border>
    {/if}
modified src/lib/router/definitions.ts
@@ -49,7 +49,7 @@ interface LoadedInboxRoute {
      string,
      {
        repo: HomeInboxTab;
-
        items: Record<string, NotificationItem[]>;
+
        items: [string, NotificationItem[]][];
        pagination: { cursor: number; more: boolean };
      }
    >;
@@ -115,7 +115,7 @@ export async function loadRoute(
      new SvelteMap();
    if (route.activeTab) {
      const items = await invoke<
-
        PaginatedQuery<Record<string, NotificationItem[]>>
+
        PaginatedQuery<[string, NotificationItem[]][]>
      >("list_notifications", {
        params: {
          repo: route.activeTab.rid,
@@ -129,7 +129,7 @@ export async function loadRoute(
    } else {
      for (const [rid, item] of notificationCount) {
        const result = await invoke<
-
          PaginatedQuery<Record<string, NotificationItem[]>>
+
          PaginatedQuery<[string, NotificationItem[]][]>
        >("list_notifications", {
          params: {
            repo: rid,
modified src/views/home/Inbox.svelte
@@ -1,21 +1,22 @@
<script lang="ts">
-
  import type { HomeInboxTab } from "@app/lib/router/definitions";
  import type { Config } from "@bindings/config/Config";
+
  import type { HomeInboxTab } from "@app/lib/router/definitions";
  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 * as router from "@app/lib/router";
  import { SvelteMap } from "svelte/reactivity";
  import { invoke } from "@app/lib/invoke";
-
  import * as router from "@app/lib/router";

+
  import Border from "@app/components/Border.svelte";
+
  import ConfirmClear from "@app/components/ConfirmClear.svelte";
  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;
@@ -24,7 +25,7 @@
      string,
      {
        repo: HomeInboxTab;
-
        items: Record<string, NotificationItem[]>;
+
        items: [string, NotificationItem[]][];
        pagination: { cursor: number; more: boolean };
      }
    >;
@@ -94,7 +95,7 @@
  async function reload(rids: string[]) {
    for (const rid of rids) {
      const [n, count] = await Promise.all([
-
        invoke<PaginatedQuery<Record<string, NotificationItem[]>>>(
+
        invoke<PaginatedQuery<[string, NotificationItem[]][]>>(
          "list_notifications",
          {
            params: {
@@ -123,16 +124,17 @@

  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,
+
      const c = cursor ? cursor : 0;
+
      const p = await invoke<PaginatedQuery<[string, NotificationItem[]][]>>(
+
        "list_notifications",
+
        {
+
          params: {
+
            repo: activeTab.rid,
+
            skip: more ? c + 20 : c,
+
            take: 20,
+
          },
        },
-
      });
+
      );

      cursor = p.cursor;
      more = p.more;
@@ -156,7 +158,7 @@
    font-size: var(--font-size-medium);
    display: flex;
    justify-content: space-between;
-
    padding-right: 1.5rem;
+
    padding-right: 1rem;
    align-items: center;
    min-height: 2.5rem;
  }
@@ -184,7 +186,7 @@
    <div class="header">
      <div>Inbox</div>
      {#if notifications.size > 0}
-
        <Icon onclick={clearAll} name="broom-double" />
+
        <ConfirmClear subject="inbox" clear={clearAll} />
      {/if}
    </div>
    {#each notifications.values() as { repo, pagination, items }}
modified src/views/repo/Issues.svelte
@@ -33,6 +33,13 @@

  let searchInput = $state("");

+
  $effect(() => {
+
    // eslint-disable-next-line @typescript-eslint/no-unused-expressions
+
    status;
+

+
    searchInput = "";
+
  });
+

  const searchableIssues = $derived(
    issues
      .flatMap(i => {
@@ -62,6 +69,11 @@
  .container {
    padding: 1rem 1rem 1rem 0;
  }
+
  .list {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 2px;
+
  }
  .header {
    font-weight: var(--font-weight-medium);
    font-size: var(--font-size-medium);
@@ -136,34 +148,36 @@
      </div>
    </div>

-
    <Border
-
      variant={searchResults.length === 1 && searchInput !== ""
-
        ? "secondary"
-
        : "float"}
-
      styleFlexDirection="column"
-
      styleOverflow="hidden"
-
      styleGap="2px"
-
      styleAlignItems="center"
-
      styleJustifyContent="center">
+
    <div class="list">
      {#each searchResults as result}
-
        <IssueTeaser issue={result.obj.issue} rid={repo.rid} {status} />
+
        <IssueTeaser
+
          focussed={searchResults.length === 1 && searchInput !== ""}
+
          issue={result.obj.issue}
+
          rid={repo.rid}
+
          {status} />
      {/each}

      {#if searchResults.length === 0}
-
        <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" />
-
            {#if issues.length > 0 && searchResults.length === 0}
-
              No matching issues.
-
            {:else}
-
              No {status === "all" ? "" : status} issues.
-
            {/if}
+
        <Border
+
          variant="ghost"
+
          styleFlexDirection="column"
+
          styleAlignItems="center"
+
          styleJustifyContent="center">
+
          <div
+
            class="global-flex"
+
            style:height="84px"
+
            style:justify-content="center">
+
            <div class="txt-missing txt-small global-flex" style:gap="0.25rem">
+
              <Icon name="none" />
+
              {#if issues.length > 0 && searchResults.length === 0}
+
                No matching issues.
+
              {:else}
+
                No {status === "all" ? "" : status} issues.
+
              {/if}
+
            </div>
          </div>
-
        </div>
+
        </Border>
      {/if}
-
    </Border>
+
    </div>
  </div>
</Layout>
modified src/views/repo/Patch.svelte
@@ -28,6 +28,8 @@

  import AssigneeInput from "@app/components/AssigneeInput.svelte";
  import Border from "@app/components/Border.svelte";
+
  import Button from "@app/components/Button.svelte";
+
  import Command from "@app/components/Command.svelte";
  import CopyableId from "@app/components/CopyableId.svelte";
  import DropdownList from "@app/components/DropdownList.svelte";
  import DropdownListItem from "@app/components/DropdownListItem.svelte";
@@ -77,6 +79,7 @@
  let cursor: number = $state(0);
  let more: boolean = $state(false);
  let patchTeasers: Patch[] = $state([]);
+
  let checkoutPopoverExpanded = $state(false);

  let patches = $state(initialPatches);
  let status = $state(initialStatus);
@@ -105,6 +108,13 @@
    more = patches.more;
  });

+
  const checkoutCommand = $derived.by(() => {
+
    if (tab === "revisions" && selectedRevision.id !== patch.id) {
+
      return `rad patch checkout ${formatOid(patch.id)} --revision ${formatOid(selectedRevision.id)}`;
+
    } else {
+
      return `rad patch checkout ${formatOid(patch.id)}`;
+
    }
+
  });
  const project = $derived(repo.payloads["xyz.radicle.project"]!);

  async function editTitle(rid: string, patchId: string, title: string) {
@@ -345,6 +355,11 @@
    margin-bottom: 0.5rem;
    color: var(--color-foreground-dim);
  }
+
  .list {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 2px;
+
  }
</style>

{#snippet icons(status: PatchStatus | undefined)}
@@ -380,6 +395,7 @@

  {#snippet secondColumn()}
    <div
+
      style:min-width="450px"
      class="txt-regular txt-semibold global-flex"
      style:min-height="2.5rem"
      style:margin-bottom="1rem">
@@ -495,33 +511,35 @@
        {/if}
      </div>
    {/if}
-
    <Border
-
      variant={searchResults.length === 1 && searchInput !== ""
-
        ? "secondary"
-
        : "float"}
-
      styleFlexDirection="column"
-
      styleOverflow="hidden"
-
      styleGap="2px"
-
      styleAlignItems="center"
-
      styleJustifyContent="center">
-
      {#each searchResults as teaser}
-
        <PatchTeaser
-
          compact
-
          loadPatch={async (id: string) => {
-
            review = undefined;
-
            await loadPatch(id);
-
          }}
-
          patch={teaser.obj.patch}
-
          rid={repo.rid}
-
          {status}
-
          selected={teaser.obj.patch.id === patch.id} />
-
      {/each}
-

-
      {#if searchResults.length === 0}
+

+
    {#if searchResults.length > 0}
+
      <div class="list">
+
        {#each searchResults as teaser}
+
          <PatchTeaser
+
            selected={teaser.obj.patch.id === patch.id}
+
            focussed={searchResults.length === 1 && searchInput !== ""}
+
            compact
+
            loadPatch={async (id: string) => {
+
              review = undefined;
+
              await loadPatch(id);
+
            }}
+
            patch={teaser.obj.patch}
+
            rid={repo.rid}
+
            {status} />
+
        {/each}
+
      </div>
+
    {/if}
+

+
    {#if searchResults.length === 0}
+
      <Border
+
        variant="ghost"
+
        styleFlexDirection="column"
+
        styleOverflow="hidden"
+
        styleAlignItems="center"
+
        styleJustifyContent="center">
        <div
          class="global-flex"
-
          style:height="74px"
-
          style:min-width="440px"
+
          style:height="84px"
          style:justify-content="center">
          <div class="txt-missing txt-small global-flex" style:gap="0.25rem">
            <Icon name="none" />
@@ -532,8 +550,8 @@
            {/if}
          </div>
        </div>
-
      {/if}
-
    </Border>
+
      </Border>
+
    {/if}
  {/snippet}

  {#if review}
@@ -609,13 +627,43 @@
              </div>
              <InlineTitle content={patch.title} fontSize="medium" />
            </div>
-
            {#if roles.isDelegateOrAuthor( config.publicKey, repo.delegates.map(delegate => delegate.did), patch.author.did, )}
-
              <div class="title-icons">
-
                <Icon
-
                  name="pen"
-
                  onclick={() => (editingTitle = !editingTitle)} />
-
              </div>
-
            {/if}
+
            <div
+
              class="global-flex txt-small"
+
              style:margin-left="auto"
+
              style:z-index="40"
+
              style:gap="0.75rem">
+
              {#if roles.isDelegateOrAuthor( config.publicKey, repo.delegates.map(delegate => delegate.did), patch.author.did, )}
+
                <div class="title-icons">
+
                  <Icon
+
                    name="pen"
+
                    onclick={() => (editingTitle = !editingTitle)} />
+
                </div>
+
              {/if}
+

+
              <Popover
+
                bind:expanded={checkoutPopoverExpanded}
+
                popoverPositionRight="0"
+
                popoverPositionTop="2.5rem">
+
                {#snippet toggle(onclick)}
+
                  <Button styleHeight="2rem" variant="secondary" {onclick}>
+
                    <Icon name="checkout" />Checkout<Icon name="chevron-down" />
+
                  </Button>
+
                {/snippet}
+
                {#snippet popover()}
+
                  <Border
+
                    styleAlignItems="flex-start"
+
                    styleBackgroundColor="var(--color-background-float)"
+
                    styleFlexDirection="column"
+
                    styleGap="0.5rem"
+
                    stylePadding="1rem"
+
                    styleWidth="max-content"
+
                    variant="ghost">
+
                    To checkout this patch in your working copy, run:
+
                    <Command command={checkoutCommand} styleWidth="100%" />
+
                  </Border>
+
                {/snippet}
+
              </Popover>
+
            </div>
          </div>
        {/if}
      </div>
modified src/views/repo/Patches.svelte
@@ -40,6 +40,13 @@
    more = patches.more;
  });

+
  $effect(() => {
+
    // eslint-disable-next-line @typescript-eslint/no-unused-expressions
+
    status;
+

+
    searchInput = "";
+
  });
+

  async function loadMoreContent(all: boolean = false) {
    if (more) {
      const p = await invoke<PaginatedQuery<Patch[]>>("list_patches", {
@@ -174,23 +181,23 @@
    </div>

    <div class="list">
-
      <Border
-
        variant={searchResults.length === 1 && searchInput !== ""
-
          ? "secondary"
-
          : "float"}
-
        styleFlexDirection="column"
-
        styleOverflow="hidden"
-
        styleGap="2px"
-
        styleAlignItems="center"
-
        styleJustifyContent="center">
-
        {#each searchResults as result}
-
          <PatchTeaser patch={result.obj.patch} rid={repo.rid} {status} />
-
        {/each}
-

-
        {#if searchResults.length === 0}
+
      {#each searchResults as result}
+
        <PatchTeaser
+
          focussed={searchResults.length === 1 && searchInput !== ""}
+
          patch={result.obj.patch}
+
          rid={repo.rid}
+
          {status} />
+
      {/each}
+

+
      {#if searchResults.length === 0}
+
        <Border
+
          variant="ghost"
+
          styleFlexDirection="column"
+
          styleAlignItems="center"
+
          styleJustifyContent="center">
          <div
            class="global-flex"
-
            style:height="74px"
+
            style:height="84px"
            style:justify-content="center">
            <div class="txt-missing txt-small global-flex" style:gap="0.25rem">
              <Icon name="none" />
@@ -201,8 +208,8 @@
              {/if}
            </div>
          </div>
-
        {/if}
-
      </Border>
+
        </Border>
+
      {/if}
    </div>
  </div>
</Layout>