feat: create repository (#3)

Reviewed-on: https://git.reya.su/reya/signed/pulls/3
This commit was merged in pull request #3.
This commit is contained in:
2026-08-26 13:51:47 +00:00
parent dacfd49cdf
commit 1b0e4fc582
10 changed files with 1408 additions and 151 deletions
+1
View File
@@ -1,3 +1,4 @@
mod pixel_avatar;
mod views;
mod workspace;
+204
View File
@@ -0,0 +1,204 @@
use gpui::prelude::*;
use gpui::{App, Pixels, Window, div, px};
use gpui_component::{ActiveTheme, Colorize};
/// Number of rows and columns in the pixel grid.
const GRID_SIZE: usize = 8;
/// Probability that a cell in the left half is filled.
const FILL_PROBABILITY: f32 = 0.42;
/// Probability that a filled cell uses the accent shade instead of the main color.
const ACCENT_PROBABILITY: f32 = 0.25;
/// Minimum number of filled left-half cells, so a sparse roll still yields a
/// recognizable shape (each left-half cell is mirrored to a right-half one).
const MIN_FILLED: usize = 5;
/// A deterministic, offline "pixel art" avatar: an 8×8 grid with horizontal
/// mirror symmetry, seeded from a stable string such as the repository id and
/// owner public key. The same seed always renders the same avatar.
#[derive(IntoElement)]
pub(crate) struct PixelAvatar {
seed: u64,
size: Pixels,
}
impl PixelAvatar {
/// Create an avatar seeded from `seed`. The seed should be a stable string
/// unique to the entity the avatar represents.
pub(crate) fn new(seed: impl AsRef<str>) -> Self {
Self {
seed: fnv1a(seed.as_ref().as_bytes()),
size: px(16.),
}
}
}
impl RenderOnce for PixelAvatar {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let pattern = pattern(self.seed);
let hue = self.seed as f32 / u64::MAX as f32;
let main = theme.blue.hue(hue);
let shade = if theme.is_dark() {
main.lightness((main.l * 1.6).min(0.95))
} else {
main.lightness((main.l * 0.45).max(0.18))
};
let mut cells = Vec::new();
for row in 0..GRID_SIZE {
for col in 0..GRID_SIZE {
let value = pattern[row * GRID_SIZE + col];
if value != 0 {
let color = if value == 2 { shade } else { main };
cells.push(
div()
.row_start(row as i16 + 1)
.row_end(row as i16 + 2)
.col_start(col as i16 + 1)
.col_end(col as i16 + 2)
.bg(color),
);
}
}
}
div()
.grid()
.grid_cols(GRID_SIZE as u16)
.grid_rows(GRID_SIZE as u16)
.size(self.size)
.flex_shrink_0()
.overflow_hidden()
.bg(main.opacity(0.16))
.children(cells)
}
}
/// Generate the 8×8 cell pattern for `seed`. Cells are `0` (empty), `1`
/// (main color) or `2` (accent shade); the right half mirrors the left half.
fn pattern(seed: u64) -> [u8; GRID_SIZE * GRID_SIZE] {
let mut rng = PixelRng::new(seed);
let mut pattern = [0u8; GRID_SIZE * GRID_SIZE];
let mut filled = 0usize;
for row in 0..GRID_SIZE {
for col in 0..GRID_SIZE / 2 {
if rng.chance(FILL_PROBABILITY) {
let accent = rng.chance(ACCENT_PROBABILITY);
set_cell(&mut pattern, row, col, if accent { 2 } else { 1 });
filled += 1;
}
}
}
// Sparse rolls can come out nearly empty; top the pattern up to the
// minimum fill, scanning from a seeded starting cell.
if filled < MIN_FILLED {
let half = GRID_SIZE * GRID_SIZE / 2;
let start = (rng.next() % half as u64) as usize;
for offset in 0..half {
if filled >= MIN_FILLED {
break;
}
let ix = (start + offset) % half;
let row = ix / (GRID_SIZE / 2);
let col = ix % (GRID_SIZE / 2);
if pattern[row * GRID_SIZE + col] == 0 {
set_cell(&mut pattern, row, col, 1);
filled += 1;
}
}
}
pattern
}
/// Fill `cell (row, col)` and its horizontal mirror.
fn set_cell(pattern: &mut [u8; GRID_SIZE * GRID_SIZE], row: usize, col: usize, value: u8) {
pattern[row * GRID_SIZE + col] = value;
pattern[row * GRID_SIZE + (GRID_SIZE - 1 - col)] = value;
}
/// FNV-1a 64-bit hash; stable across platforms and runs.
fn fnv1a(bytes: &[u8]) -> u64 {
let mut hash = 0xcbf2_9ce4_8422_2325u64;
for &byte in bytes {
hash ^= byte as u64;
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
hash
}
/// Tiny xorshift64* PRNG for deriving the pattern from the seed.
struct PixelRng(u64);
impl PixelRng {
fn new(seed: u64) -> Self {
Self(seed.max(1))
}
fn next(&mut self) -> u64 {
let mut x = self.0;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.0 = x;
x.wrapping_mul(0x2545_f491_4f6c_dd1d)
}
fn chance(&mut self, probability: f32) -> bool {
self.next() as f32 / (u64::MAX as f32) < probability
}
}
#[cfg(test)]
mod tests {
use super::*;
fn count_filled(pattern: &[u8; GRID_SIZE * GRID_SIZE]) -> usize {
pattern.iter().filter(|&&cell| cell != 0).count()
}
#[test]
fn pattern_is_mirror_symmetric() {
for seed in 0..50 {
let pattern = pattern(seed);
for row in 0..GRID_SIZE {
for col in 0..GRID_SIZE {
assert_eq!(
pattern[row * GRID_SIZE + col],
pattern[row * GRID_SIZE + (GRID_SIZE - 1 - col)],
"asymmetric pattern for seed {seed} at ({row}, {col})"
);
}
}
}
}
#[test]
fn pattern_has_minimum_fill() {
for seed in 0..50 {
let pattern = pattern(seed);
assert!(
count_filled(&pattern) >= MIN_FILLED * 2,
"pattern too sparse for seed {seed}"
);
}
}
#[test]
fn pattern_is_deterministic() {
for seed in [0, 1, 42, u64::MAX] {
assert_eq!(pattern(seed), pattern(seed));
}
assert_ne!(pattern(42), pattern(43));
}
#[test]
fn fnv1a_is_stable_and_distinct() {
assert_eq!(fnv1a(b""), 0xcbf2_9ce4_8422_2325);
assert_eq!(fnv1a(b"repo"), fnv1a(b"repo"));
assert_ne!(fnv1a(b"repo:a"), fnv1a(b"repo:b"));
}
}
@@ -0,0 +1,497 @@
use dock::{DockArea, DockPlacement, panel_handle};
use gpui::prelude::*;
use gpui::{App, Entity, PathPromptOptions, SharedString, WeakEntity, Window, div, px};
use gpui_base::input::TextareaState;
use gpui_component::button::{Button, ButtonVariants, Toggle, ToggleVariants};
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
use gpui_component::form::{field, v_form};
use gpui_component::input::{Input, InputState, Textarea};
use gpui_component::{ActiveTheme, Disableable, IconName, Sizable, WindowExt, h_flex, v_flex};
use nostr::prelude::*;
use signed_core::{Announcement, filters};
use signed_state::Backend;
use super::super::RepoDetailView;
/// Grasp servers offered when the user hasn't published a grasp list (kind `10317`) yet.
const DEFAULT_GRASP_SERVERS: [&str; 3] = [
"wss://relay.ngit.dev",
"wss://gitnostr.com",
"wss://git.shakespeare.diy",
];
/// Shared state for the Create Repository dialog, so async results can be rendered.
#[derive(Default)]
pub struct CreateRepoState {
pub busy: bool,
/// The user's grasp list (kind `10317`) is being loaded.
pub loading_servers: bool,
pub error: Option<SharedString>,
pub grasp_servers: Vec<RelayUrl>,
/// Whether the grasp server section is shown; defaults to shown.
pub servers_enabled: bool,
}
impl CreateRepoState {
/// Defaults until the user's grasp list arrives; replaced by it when it lists any servers.
fn new_default() -> Self {
Self {
loading_servers: true,
servers_enabled: false,
grasp_servers: DEFAULT_GRASP_SERVERS
.iter()
.filter_map(|url| RelayUrl::parse(url).ok())
.collect(),
..Default::default()
}
}
}
/// Open the Create Repository dialog.
///
/// The dialog loads the user's default grasp servers (kind `10317` grasp
/// list) and falls back to [`DEFAULT_GRASP_SERVERS`] when none are set.
/// On success the dialog closes and the new repository opens in the dock.
pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App) {
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Repository name"));
let desc_input = cx.new(|cx| {
TextareaState::new(window, cx)
.auto_grow(3, 5)
.placeholder("Short description")
});
let folder_input = cx.new(|cx| {
InputState::new(window, cx)
.default_value(paths::desktop_dir().to_string_lossy().to_string())
});
let relay_input = cx.new(|cx| {
InputState::new(window, cx).placeholder("wss://relay.example.com or relay.example.com")
});
let state = cx.new(|_| CreateRepoState::new_default());
load_user_grasp_servers(state.clone(), window, cx);
window.open_dialog(cx, move |dialog, _window, _cx| {
const DESC: &str = "Publish a new repository to your grasp servers.";
const FOLDER_NOTE: &str = "Where the repository is stored, defaults to your Desktop";
const SERVER_NOTE: &str =
"Where the repository is hosted, the initial push goes to each server";
let name_input = name_input.clone();
let desc_input = desc_input.clone();
let folder_input = folder_input.clone();
let relay_input = relay_input.clone();
let state = state.clone();
let dock_area = dock_area.clone();
dialog
.width(px(520.))
.margin_top(px(50.))
.content(move |content, _window, cx| {
let busy = state.read(cx).busy;
let error = state.read(cx).error.clone();
let servers = state.read(cx).grasp_servers.clone();
let loading_servers = state.read(cx).loading_servers;
let servers_enabled = state.read(cx).servers_enabled;
content
.child(
DialogHeader::new()
.child(DialogTitle::new().child("Create repository"))
.child(DialogDescription::new().child(DESC)),
)
.child(
v_form()
.child(
field()
.label("Repository name")
.description("Max 100 characters")
.required(true)
.child(Input::new(&name_input)),
)
.child(
field()
.label("Description")
.child(Textarea::new(&desc_input)),
)
.child(
field().label("Folder").description(FOLDER_NOTE).child(
h_flex()
.gap_1()
.items_center()
.child(
div()
.flex_1()
.child(Input::new(&folder_input).disabled(true)),
)
.child(
Button::new("choose-folder")
.icon(IconName::FolderOpen)
.ghost()
.tooltip("Choose folder")
.on_click({
let folder_input = folder_input.clone();
move |_ev, window, cx| {
choose_folder(&folder_input, window, cx);
}
}),
),
),
)
.child(
field()
.label_fn({
let state = state.clone();
move |_window, cx| {
let enabled = state.read(cx).servers_enabled;
h_flex()
.w_full()
.justify_between()
.items_center()
.gap_1()
.child(
Toggle::new("grasp-servers-toggle")
.xsmall()
.ghost()
.icon({
if enabled {
IconName::ChevronDown
} else {
IconName::ChevronUp
}
})
.checked(enabled)
.on_click({
let state = state.clone();
move |checked, _window, cx| {
state.update(cx, |state, cx| {
state.servers_enabled =
*checked;
cx.notify();
});
}
}),
)
.child(div().child("Grasp servers"))
}
})
.when(servers_enabled, |this| this.description(SERVER_NOTE))
.child(v_flex().gap_1().when(servers_enabled, |this| {
this.children(servers.iter().enumerate().map(
|(ix, relay)| {
render_server_row(ix, relay, state.clone(), cx)
},
))
.child(
h_flex()
.gap_1()
.items_center()
.child(
div().flex_1().child(Input::new(&relay_input)),
)
.child(
Button::new("add-relay")
.icon(IconName::Plus)
.ghost()
.tooltip("Add grasp server")
.on_click({
let state = state.clone();
let relay_input = relay_input.clone();
move |_ev, window, cx| {
add_relay(
&state,
&relay_input,
window,
cx,
);
}
}),
),
)
.when(
loading_servers,
|this| {
this.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child("Loading your grasp servers…"),
)
},
)
})),
),
)
.children(error.map(|message| {
div().text_sm().text_color(cx.theme().danger).child(message)
}))
.child(
DialogFooter::new().justify_end().child(
Button::new("create")
.primary()
.label("Create repository")
.icon(IconName::ArrowRight)
.tooltip("Create repository")
.loading(busy)
.disabled(busy)
.on_click({
let name_input = name_input.clone();
let desc_input = desc_input.clone();
let state = state.clone();
let dock_area = dock_area.clone();
move |_ev, window, cx| {
create_repository(
name_input.clone(),
desc_input.clone(),
state.clone(),
dock_area.clone(),
window,
cx,
);
}
}),
),
)
})
});
}
/// A grasp server row: the host as a tag plus a remove button.
fn render_server_row(
ix: usize,
relay: &RelayUrl,
state: Entity<CreateRepoState>,
cx: &App,
) -> impl IntoElement {
h_flex()
.w_full()
.gap_1()
.items_center()
.child(
h_flex()
.h_8()
.w_full()
.px_2()
.bg(cx.theme().muted)
.text_color(cx.theme().muted_foreground)
.text_sm()
.rounded(cx.theme().radius)
.child(display_server(relay)),
)
.child(
Button::new(format!("remove-relay:{ix}"))
.icon(IconName::Close)
.ghost()
.flex_shrink_0()
.tooltip("Remove")
.on_click({
let state = state.clone();
move |_ev, _window, cx| {
state.update(cx, |state, _| {
state.grasp_servers.remove(ix);
});
}
}),
)
}
/// The bare host of a grasp server (defaults are entered without a scheme).
fn display_server(relay: &RelayUrl) -> SharedString {
relay
.domain()
.map(SharedString::from)
.unwrap_or_else(|| SharedString::from(relay.to_string()))
}
/// Prompt the user to pick the folder the repository will be stored in, using
/// the platform's native folder picker, and show the result in the disabled
/// folder input.
fn choose_folder(folder_input: &Entity<InputState>, window: &mut Window, cx: &mut App) {
let handle = window.window_handle();
let folder_input = folder_input.clone();
let prompt = cx.prompt_for_paths(PathPromptOptions {
files: false,
directories: true,
multiple: false,
prompt: Some("Choose folder".into()),
});
cx.spawn(async move |cx| {
if let Ok(Ok(Some(mut paths))) = prompt.await
&& let Some(path) = paths.pop()
{
let path = path.to_string_lossy().to_string();
cx.update_window(handle, |_, window, cx| {
folder_input.update(cx, |input, cx| input.set_value(path, window, cx));
})
.ok();
}
})
.detach();
}
/// Parse the relay input (accepting a bare host) and append it to the list.
fn add_relay(
state: &Entity<CreateRepoState>,
input: &Entity<InputState>,
window: &mut Window,
cx: &mut App,
) {
let value = input.read(cx).value().trim().to_owned();
if value.is_empty() {
return;
}
let normalized = if value.contains("://") {
value.clone()
} else {
format!("wss://{value}")
};
match RelayUrl::parse(&normalized) {
Ok(relay) => {
state.update(cx, |state, _| {
state.error = None;
if !state.grasp_servers.contains(&relay) {
state.grasp_servers.push(relay);
}
});
input.update(cx, |input, cx| input.set_value("", window, cx));
}
Err(_) => {
state.update(cx, |state, _| {
state.error = Some(format!("Invalid grasp server URL: {value}").into());
});
}
}
}
/// Run the create-repository flow; closes the dialog and opens the new repository on success.
fn create_repository(
name_input: Entity<InputState>,
desc_input: Entity<TextareaState>,
state: Entity<CreateRepoState>,
dock_area: WeakEntity<DockArea>,
window: &mut Window,
cx: &mut App,
) {
let name = name_input.read(cx).value().trim().to_owned();
let description = desc_input.read(cx).value().trim().to_owned();
let servers = state.read(cx).grasp_servers.clone();
if name.is_empty() {
state.update(cx, |state, _| {
state.error = Some("Repository name is required".into());
});
return;
}
if servers.is_empty() {
state.update(cx, |state, _| {
state.error = Some("Add at least one grasp server".into());
});
return;
}
state.update(cx, |state, _| {
state.busy = true;
state.error = None;
});
let backend = Backend::global(cx);
let task = backend.update(cx, |backend, cx| {
backend.create_repository(&name, &description, servers, cx)
});
let handle = window.window_handle();
let state = state.clone();
let dock_area = dock_area.clone();
cx.spawn(async move |cx| match task.await {
Ok(announcement) => {
cx.update_window(handle, |_, window, cx| {
window.close_dialog(cx);
open_repo(dock_area, announcement, window, cx);
})
.ok();
}
Err(e) => {
cx.update_window(handle, |_, _window, cx| {
state.update(cx, |state, _| {
state.busy = false;
state.error = Some(e.to_string().into());
});
})
.ok();
}
})
.detach();
}
/// Open the newly created repository in the dock's center.
fn open_repo(
dock_area: WeakEntity<DockArea>,
announcement: Announcement,
window: &mut Window,
cx: &mut App,
) {
let Some(dock_area) = dock_area.upgrade() else {
return;
};
let panel = cx.new(|cx| RepoDetailView::new(dock_area.downgrade(), announcement, window, cx));
dock_area.update(cx, |dock_area, cx| {
dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx);
});
}
/// Load the user's grasp list (kind `10317`) from the local database and
/// replace the defaults with it when it lists any servers.
fn load_user_grasp_servers(state: Entity<CreateRepoState>, window: &mut Window, cx: &mut App) {
let backend = Backend::global(cx);
let Some(user) = backend.read(cx).current_user() else {
state.update(cx, |state, _| state.loading_servers = false);
return;
};
let client = backend.read(cx).client();
let handle = window.window_handle();
cx.spawn(async move |cx| {
let result: anyhow::Result<Vec<RelayUrl>> = async {
let mut events: Vec<Event> = client
.database()
.query(filters::grasp_list(user))
.await?
.into_iter()
.collect();
events.sort_by_key(|event| event.created_at);
Ok(events
.into_iter()
.last()
.map(|event| {
event
.tags
.iter()
.filter(|tag| tag.kind() == "g")
.filter_map(|tag| tag.content())
.filter_map(|url| RelayUrl::parse(url).ok())
.collect()
})
.unwrap_or_default())
}
.await;
let _ = cx.update_window(handle, |_, _window, cx| {
state.update(cx, |state, _| {
state.loading_servers = false;
if let Ok(servers) = result
&& !servers.is_empty()
{
state.grasp_servers = servers;
}
});
});
})
.detach();
}
+214 -50
View File
@@ -1,3 +1,5 @@
use std::ops::Range;
use assets::CustomIconName;
use dock::{
BasePanel, DockArea, DockPlacement, Panel, PanelEvent, TAB_BAR_HEIGHT, panel_handle,
@@ -5,18 +7,21 @@ use dock::{
};
use gpui::prelude::*;
use gpui::{
App, ClickEvent, Context, ElementId, EventEmitter, FocusHandle, Focusable, Render,
SharedString, StyleRefinement, Subscription, WeakEntity, Window, div, px,
AnyElement, App, ClickEvent, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable,
Render, SharedString, StyleRefinement, Subscription, WeakEntity, Window, div, px, uniform_list,
};
use gpui_component::avatar::Avatar;
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::input::InputState;
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
use signed_state::{Backend, BackendEvent, Profile, ProfileStore};
use signed_core::Announcement;
use signed_state::{Backend, BackendEvent, Profile, ProfileStore, RepoListStore};
use super::RepoListView;
use super::{RepoDetailView, RepoListView};
use crate::image_cache::{MAX_IMAGES, image_cache};
use crate::pixel_avatar::PixelAvatar;
mod create_repo_dialog;
mod import_dialog;
mod onboarding_dialog;
pub(crate) mod passphrase_dialog;
@@ -29,6 +34,11 @@ pub struct SidebarPanel {
focus_handle: FocusHandle,
dock_area: WeakEntity<DockArea>,
explore: Option<WeakEntity<RepoListView>>,
/// Repositories announced by the current user, listed under
/// "All Repositories". Recreated when the signer changes.
my_repos: Option<Entity<RepoListStore>>,
/// Observes the current user's repo store so the list re-renders.
my_repos_subscription: Option<Subscription>,
logged_in: bool,
_subscription: Subscription,
}
@@ -42,21 +52,44 @@ impl SidebarPanel {
match event {
BackendEvent::SignerChanged => {
this.logged_in = backend.read(cx).current_user().is_some();
this.refresh_my_repos(cx);
}
BackendEvent::SignerRequired => {
this.logged_in = false;
this.my_repos = None;
this.my_repos_subscription = None;
}
_ => return,
}
cx.notify();
});
Self {
let mut panel = Self {
focus_handle: cx.focus_handle(),
dock_area,
explore: None,
my_repos: None,
my_repos_subscription: None,
logged_in,
_subscription: subscription,
};
if logged_in {
panel.refresh_my_repos(cx);
}
panel
}
/// (Re)create the store listing the current user's repositories.
fn refresh_my_repos(&mut self, cx: &mut Context<Self>) {
self.my_repos_subscription = None;
let author = Backend::global(cx).read(cx).current_user();
self.my_repos = author.map(|author| cx.new(|cx| RepoListStore::new(Some(author), cx)));
if let Some(store) = self.my_repos.as_ref() {
self.my_repos_subscription = Some(cx.observe(store, |_, _, cx| cx.notify()));
}
}
@@ -98,6 +131,126 @@ impl SidebarPanel {
onboarding_dialog::open(name_input, pass_input, repass_input, state, window, cx);
}
/// Show the Create Repository dialog.
fn open_create_repo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
create_repo_dialog::open(self.dock_area.clone(), window, cx);
}
/// Open a repository's detail view in the dock's center.
fn open_repo(
&mut self,
announcement: &Announcement,
window: &mut Window,
cx: &mut Context<Self>,
) {
let detail = cx.new(|cx| {
RepoDetailView::new(self.dock_area.clone(), announcement.clone(), window, cx)
});
let _ = self.dock_area.update(cx, |dock_area, cx| {
dock_area.add_panel_view(
panel_handle(detail),
DockPlacement::Center,
None,
window,
cx,
);
});
}
/// The "All Repositories" section: header with the create button and
/// the current user's repositories below it, lazily rendered through a
/// [`uniform_list`].
fn render_my_repos(&self, cx: &mut Context<Self>) -> impl IntoElement {
let store = self.my_repos.as_ref();
v_flex()
.px_2()
.flex_1()
.min_h_0()
.w_full()
.child(
h_flex()
.h_10()
.w_full()
.flex_shrink_0()
.justify_between()
.items_center()
.child(
h_flex()
.px_2()
.gap_2()
.text_color(cx.theme().muted_foreground)
.child(Icon::new(CustomIconName::Filter).small())
.child(div().text_xs().font_semibold().child("All Repositories")),
)
.child(
Button::new("add")
.icon(IconName::Plus)
.small()
.ghost()
.on_click(cx.listener(|this, _ev, window, cx| {
this.open_create_repo(window, cx);
})),
),
)
.when_some(store, |builder, store| {
let announcements = store.read(cx).announcements.clone();
if announcements.is_empty() {
builder.child(
div()
.flex_1()
.px_2()
.py_1()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child("No repositories yet"),
)
} else {
builder.child(
uniform_list(
"my-repos-list",
announcements.len(),
cx.processor(move |this, range: Range<usize>, _window, cx| {
range
.map(|ix| {
this.render_repo_row(&announcements[ix], cx)
.into_any_element()
})
.collect()
}),
)
.flex_1()
.min_h_0(),
)
}
})
}
/// One repository row in the sidebar, styled like the nav items: a
/// deterministic pixel avatar and the repo name.
fn render_repo_row(
&self,
announcement: &Announcement,
cx: &mut Context<Self>,
) -> impl IntoElement {
let name = announcement
.name
.clone()
.unwrap_or_else(|| SharedString::from(announcement.id.clone()));
let avatar = PixelAvatar::new(format!(
"{}:{}",
announcement.owner.to_hex(),
announcement.id
));
let announcement = announcement.clone();
NavItem::new(format!("my-repo:{}", announcement.id), name, avatar).on_click(
cx.listener(move |this, _ev, window, cx| this.open_repo(&announcement, window, cx)),
)
}
/// Show the Import Identity dialog.
fn open_import(&mut self, window: &mut Window, cx: &mut Context<Self>) {
import_dialog::open(window, cx);
@@ -214,8 +367,9 @@ impl Render for SidebarPanel {
.bg(cx.theme().sidebar)
.text_color(cx.theme().sidebar_foreground)
.child(
div()
v_flex()
.flex_1()
.min_h_0()
.when_some(profile.as_ref(), |this, profile| {
this.child(self.render_user(profile, window, cx))
})
@@ -225,41 +379,34 @@ impl Render for SidebarPanel {
.gap_1()
.items_start()
.justify_start()
.child(NavItem::new("inbox", "Inbox", IconName::Inbox).on_click(
cx.listener(|this, _ev, window, cx| this.open_explore(window, cx)),
))
.child(NavItem::new("explore", "Browse", IconName::Globe).on_click(
cx.listener(|this, _ev, window, cx| this.open_explore(window, cx)),
))
.child(NavItem::new("search", "Search", IconName::Search).on_click(
cx.listener(|this, _ev, window, cx| this.open_explore(window, cx)),
))
.child(
v_flex().w_full().child(
h_flex()
.h_10()
.w_full()
.justify_between()
.items_center()
.child(
h_flex()
.px_2()
.gap_2()
.text_color(cx.theme().muted_foreground)
.child(Icon::new(CustomIconName::Filter).small())
.child(
div()
.text_xs()
.font_semibold()
.child("All Repositories"),
),
)
.child(
Button::new("add").icon(IconName::Plus).small().ghost(),
),
),
NavItem::new("inbox", "Inbox", Icon::new(IconName::Inbox).small())
.on_click(cx.listener(|this, _ev, window, cx| {
this.open_explore(window, cx)
})),
)
.child(
NavItem::new(
"explore",
"Browse",
Icon::new(IconName::Globe).small(),
)
.on_click(cx.listener(
|this, _ev, window, cx| this.open_explore(window, cx),
)),
)
.child(
NavItem::new(
"search",
"Search",
Icon::new(IconName::Search).small(),
)
.on_click(cx.listener(
|this, _ev, window, cx| this.open_explore(window, cx),
)),
),
),
)
.child(self.render_my_repos(cx)),
)
.child(
v_flex()
@@ -268,11 +415,18 @@ impl Render for SidebarPanel {
.gap_1()
.items_start()
.justify_start()
.child(NavItem::new("guide", "Guide", IconName::Info).on_click(
cx.listener(|this, _ev, window, cx| this.open_explore(window, cx)),
))
.child(
NavItem::new("settings", "Settings", IconName::Settings).on_click(
NavItem::new("guide", "Guide", Icon::new(IconName::Info).small()).on_click(
cx.listener(|this, _ev, window, cx| this.open_explore(window, cx)),
),
)
.child(
NavItem::new(
"settings",
"Settings",
Icon::new(IconName::Settings).small(),
)
.on_click(
cx.listener(|this, _ev, window, cx| this.open_explore(window, cx)),
),
),
@@ -280,27 +434,29 @@ impl Render for SidebarPanel {
}
}
/// A single navigation entry in the sidebar: an icon and label with a hover
/// highlight and an optional click handler.
/// A single navigation entry in the sidebar: an arbitrary leading element
/// (an icon, avatar, ...) and a text label with a hover highlight and an
/// optional click handler.
#[allow(clippy::type_complexity)]
#[derive(IntoElement)]
struct NavItem {
id: ElementId,
style: StyleRefinement,
icon: IconName,
icon: AnyElement,
label: SharedString,
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
}
impl NavItem {
fn new<I, L>(id: I, label: L, icon: IconName) -> Self
fn new<I, L, N>(id: I, label: L, icon: N) -> Self
where
I: Into<ElementId>,
L: Into<SharedString>,
N: IntoElement,
{
Self {
id: id.into(),
icon,
icon: icon.into_any_element(),
label: label.into(),
style: StyleRefinement::default(),
on_click: None,
@@ -323,8 +479,16 @@ impl RenderOnce for NavItem {
.w_full()
.gap_2()
.rounded(cx.theme().radius)
.child(Icon::new(self.icon).small())
.child(div().text_sm().child(self.label))
.child(self.icon)
.child(
div()
.flex_1()
.min_w_0()
.text_sm()
.whitespace_nowrap()
.text_ellipsis()
.child(self.label),
)
.hover(|this| this.bg(cx.theme().list_hover))
.when_some(self.on_click, |this, listener| this.on_click(listener))
}