open repo panels from sidebar rows
Rust / build (macos-latest, stable) (push) Waiting to run
Rust / build (ubuntu-latest, stable) (push) Waiting to run
Rust / build (windows-latest, stable) (push) Waiting to run
Rust / build (macos-latest, stable) (pull_request) Waiting to run
Rust / build (ubuntu-latest, stable) (pull_request) Waiting to run
Rust / build (windows-latest, stable) (pull_request) Waiting to run

This commit is contained in:
2026-11-11 20:12:44 +07:00
parent 1f65a0111f
commit 89e718cb6a
21 changed files with 4537 additions and 25 deletions
Generated
+8 -8
View File
@@ -3059,7 +3059,7 @@ dependencies = [
[[package]]
name = "gpui-native-core"
version = "0.1.0"
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=4469a4c280194db033dd0abf9c4d6b8f4daa1858#4469a4c280194db033dd0abf9c4d6b8f4daa1858"
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=97106b7a3a54ec929c47feb70cdab4bed044d20e#97106b7a3a54ec929c47feb70cdab4bed044d20e"
dependencies = [
"cpal",
"flume 0.12.0",
@@ -3074,7 +3074,7 @@ dependencies = [
[[package]]
name = "gpui-native-data"
version = "0.1.0"
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=4469a4c280194db033dd0abf9c4d6b8f4daa1858#4469a4c280194db033dd0abf9c4d6b8f4daa1858"
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=97106b7a3a54ec929c47feb70cdab4bed044d20e#97106b7a3a54ec929c47feb70cdab4bed044d20e"
dependencies = [
"gpui",
"gpui-native-core",
@@ -3086,7 +3086,7 @@ dependencies = [
[[package]]
name = "gpui-native-editor"
version = "0.1.0"
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=4469a4c280194db033dd0abf9c4d6b8f4daa1858#4469a4c280194db033dd0abf9c4d6b8f4daa1858"
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=97106b7a3a54ec929c47feb70cdab4bed044d20e#97106b7a3a54ec929c47feb70cdab4bed044d20e"
dependencies = [
"gpui",
"gpui-native-core",
@@ -3105,7 +3105,7 @@ dependencies = [
[[package]]
name = "gpui-native-icons"
version = "0.1.0"
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=4469a4c280194db033dd0abf9c4d6b8f4daa1858#4469a4c280194db033dd0abf9c4d6b8f4daa1858"
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=97106b7a3a54ec929c47feb70cdab4bed044d20e#97106b7a3a54ec929c47feb70cdab4bed044d20e"
dependencies = [
"anyhow",
"gpui",
@@ -3114,7 +3114,7 @@ dependencies = [
[[package]]
name = "gpui-native-kit"
version = "0.1.0"
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=4469a4c280194db033dd0abf9c4d6b8f4daa1858#4469a4c280194db033dd0abf9c4d6b8f4daa1858"
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=97106b7a3a54ec929c47feb70cdab4bed044d20e#97106b7a3a54ec929c47feb70cdab4bed044d20e"
dependencies = [
"gpui",
"gpui-native-core",
@@ -3128,7 +3128,7 @@ dependencies = [
[[package]]
name = "gpui-native-layout"
version = "0.1.0"
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=4469a4c280194db033dd0abf9c4d6b8f4daa1858#4469a4c280194db033dd0abf9c4d6b8f4daa1858"
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=97106b7a3a54ec929c47feb70cdab4bed044d20e#97106b7a3a54ec929c47feb70cdab4bed044d20e"
dependencies = [
"gpui",
"gpui-native-core",
@@ -3141,7 +3141,7 @@ dependencies = [
[[package]]
name = "gpui-native-theme"
version = "0.1.0"
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=4469a4c280194db033dd0abf9c4d6b8f4daa1858#4469a4c280194db033dd0abf9c4d6b8f4daa1858"
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=97106b7a3a54ec929c47feb70cdab4bed044d20e#97106b7a3a54ec929c47feb70cdab4bed044d20e"
dependencies = [
"anyhow",
"async-channel",
@@ -3162,7 +3162,7 @@ dependencies = [
[[package]]
name = "gpui-native-utils"
version = "0.1.0"
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=4469a4c280194db033dd0abf9c4d6b8f4daa1858#4469a4c280194db033dd0abf9c4d6b8f4daa1858"
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=97106b7a3a54ec929c47feb70cdab4bed044d20e#97106b7a3a54ec929c47feb70cdab4bed044d20e"
dependencies = [
"gpui",
"gpui-native-theme",
+1 -1
View File
@@ -14,7 +14,7 @@ gpui = { git = "https://github.com/zed-industries/zed", rev = "f8cfc7ed9e57503de
gpui_platform = { git = "https://github.com/zed-industries/zed", rev = "f8cfc7ed9e57503def9479138894f3139f5400cc", features = ["font-kit"] }
reqwest_client = { git = "https://github.com/zed-industries/zed", rev = "f8cfc7ed9e57503def9479138894f3139f5400cc" }
gpui-native-kit = { git = "https://git.reya.info/reya/gpui-native-kit", rev = "4469a4c280194db033dd0abf9c4d6b8f4daa1858", features = ["layout", "editor"] }
gpui-native-kit = { git = "https://git.reya.info/reya/gpui-native-kit", rev = "97106b7a3a54ec929c47feb70cdab4bed044d20e", features = ["layout", "editor"] }
settings = { path = "crates/settings" }
signed_app = { path = "crates/signed_app" }
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/></svg>

After

Width:  |  Height:  |  Size: 258 B

+6
View File
@@ -54,6 +54,9 @@ pub enum CustomIconName {
Refresh,
Grid,
Init,
EllipsisVertical,
ArrowUp,
Trash,
}
impl IconNamed for CustomIconName {
@@ -84,6 +87,9 @@ impl IconNamed for CustomIconName {
CustomIconName::Recent => "icons/recent.svg",
CustomIconName::Grid => "icons/grid.svg",
CustomIconName::Init => "icons/init.svg",
CustomIconName::EllipsisVertical => "icons/ellipsis-vertical.svg",
CustomIconName::ArrowUp => "icons/arrow-up.svg",
CustomIconName::Trash => "icons/trash.svg",
}
.into()
}
+265
View File
@@ -0,0 +1,265 @@
use gpui::{
AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
SharedString, Styled as _, Window, div, px,
};
use gpui_native_kit::avatar::Avatar;
use gpui_native_kit::clipboard::ClipboardButton;
use gpui_native_kit::common::{Sizable as _, h_flex, v_flex};
use gpui_native_kit::dialog::Dialog;
use gpui_native_kit::theme::ActiveTheme as _;
use metadata::ProfileDirectory;
use nostr::prelude::PublicKey;
use signed_core::Announcement;
use utils::middle_truncate;
use super::{close_global, open_global};
pub struct AboutDialog {
announcement: Announcement,
directory: Entity<ProfileDirectory>,
}
impl AboutDialog {
pub fn new(
announcement: Announcement,
directory: Entity<ProfileDirectory>,
cx: &mut App,
) -> Entity<Self> {
cx.new(|_| Self {
announcement,
directory,
})
}
}
impl Render for AboutDialog {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
let announcement = self.announcement.clone();
let directory = self.directory.clone();
Dialog::new("about-dialog")
.open(true)
.min_width(px(500.))
.on_change(|open, _, cx| {
if !open {
close_global(cx);
}
})
.title("About")
.content(move |_, cx| announcement_rows(&announcement, &directory, cx))
.into_any_element()
}
}
fn announcement_rows(
announcement: &Announcement,
directory: &Entity<ProfileDirectory>,
cx: &App,
) -> AnyElement {
let mut rows: Vec<AnyElement> = Vec::new();
rows.push(row(
"Name",
text(
announcement
.name
.as_deref()
.map(SharedString::from)
.unwrap_or_else(|| SharedString::from("-")),
),
cx,
));
rows.push(row(
"Description",
text(
announcement
.description
.as_deref()
.map(SharedString::from)
.unwrap_or_else(|| SharedString::from("-")),
),
cx,
));
if !announcement.web.is_empty() {
rows.push(row(
"Web",
list(
"about-web",
announcement.web.iter().map(|url| url.to_string()),
cx,
),
cx,
));
}
if let Some(euc) = &announcement.euc {
rows.push(row(
"Earliest Commit",
copy_value("about-euc", euc.clone(), cx),
cx,
));
}
if let Some(upstream) = &announcement.upstream {
rows.push(row("Upstream", text(upstream.display()), cx));
}
if !announcement.hashtags.is_empty() {
rows.push(row(
"Hashtags",
text(SharedString::from(announcement.hashtags.join(", "))),
cx,
));
}
if !announcement.clone.is_empty() {
rows.push(row(
"Clone URLs",
list(
"about-clone",
announcement.clone.iter().map(|url| url.to_string()),
cx,
),
cx,
));
}
if !announcement.relays.is_empty() {
rows.push(row(
"Grasp Relays",
list(
"about-relays",
announcement.relays.iter().map(|url| url.to_string()),
cx,
),
cx,
));
}
if !announcement.maintainers.is_empty() {
rows.push(row(
"Maintainers",
maintainers(&announcement.maintainers, directory, cx),
cx,
));
}
v_flex().gap_3().w_full().children(rows).into_any_element()
}
fn row(label: &'static str, value: AnyElement, cx: &App) -> AnyElement {
v_flex()
.gap_1()
.min_w_0()
.child(
div()
.text_xs()
.font_weight(gpui::FontWeight::SEMIBOLD)
.text_color(cx.theme().colors.muted_foreground)
.child(label),
)
.child(value)
.into_any_element()
}
fn text<T>(value: T) -> AnyElement
where
T: Into<SharedString>,
{
div()
.text_sm()
.w_full()
.min_w_0()
.child(value.into())
.into_any_element()
}
fn copy_value(id: &'static str, value: String, cx: &App) -> AnyElement {
h_flex()
.gap_2()
.items_center()
.min_w_0()
.child(
div()
.font_family(cx.theme().mono_font)
.text_xs()
.min_w_0()
.child(SharedString::from(value.clone())),
)
.child(ClipboardButton::new(id, value))
.into_any_element()
}
fn maintainers(
maintainers: &[PublicKey],
directory: &Entity<ProfileDirectory>,
cx: &App,
) -> AnyElement {
v_flex()
.gap_2()
.min_w_0()
.children(maintainers.iter().map(|pubkey| {
let profile = directory.read(cx).profile(pubkey, cx);
let name = profile.name();
h_flex()
.gap_2()
.items_center()
.min_w_0()
.child(
Avatar::new(*pubkey)
.name(name.clone())
.picture(profile.picture())
.small(),
)
.child(
div()
.flex_1()
.min_w_0()
.overflow_hidden()
.whitespace_nowrap()
.text_ellipsis()
.text_sm()
.child(name),
)
.into_any_element()
}))
.into_any_element()
}
fn list(id: &'static str, items: impl IntoIterator<Item = String>, cx: &App) -> AnyElement {
v_flex()
.gap_2()
.min_w_0()
.children(items.into_iter().enumerate().map(|(ix, item)| {
h_flex()
.h_8()
.px_2()
.gap_2()
.min_w_0()
.bg(cx.theme().colors.secondary)
.rounded(cx.theme().radius.control)
.text_color(cx.theme().colors.secondary_foreground)
.child(
div()
.flex_1()
.min_w_0()
.whitespace_nowrap()
.text_ellipsis()
.text_sm()
.child(SharedString::from(middle_truncate(&item, 28, 16))),
)
.child(ClipboardButton::new(
SharedString::from(format!("{id}-{ix}")),
item,
))
.into_any_element()
}))
.into_any_element()
}
/// Opens the read-only repository metadata dialog.
pub fn open(announcement: Announcement, directory: Entity<ProfileDirectory>, cx: &mut App) {
open_global(AboutDialog::new(announcement, directory, cx), cx);
}
@@ -0,0 +1,269 @@
use std::path::PathBuf;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _,
PathPromptOptions, Render, Styled as _, WeakEntity, Window, div, px,
};
use gpui_native_kit::button::{Button, ButtonVariant};
use gpui_native_kit::common::{h_flex, v_flex};
use gpui_native_kit::dialog::Dialog;
use gpui_native_kit::field::Field;
use gpui_native_kit::icons::{Icon, IconRole};
use gpui_native_kit::input::{Input, InputState};
use gpui_native_kit::layout::dock::DockState;
use gpui_native_kit::theme::ActiveTheme as _;
use gpui_native_kit::tooltip::Tooltip;
use settings::SettingsStore;
use signed_state::{Backend, CheckoutsStore};
use super::grasp_servers::{GraspServersState, grasp_servers_field, load_user_grasp_servers};
use super::progress::{DialogProgress, error_row, push_steps_row};
use super::{close_global, open_global};
use crate::views::repo::actions::open_repo_panel;
/// Progress of the create-repository flow, so async results can be rendered.
pub struct CreateRepoDialog {
name_input: Entity<InputState>,
desc_input: Entity<InputState>,
folder_input: Entity<InputState>,
relay_input: Entity<InputState>,
state: Entity<DialogProgress>,
grasp_state: Entity<GraspServersState>,
dock: WeakEntity<DockState>,
}
impl CreateRepoDialog {
pub fn new(dock: WeakEntity<DockState>, window: &mut Window, cx: &mut App) -> Entity<Self> {
let settings = SettingsStore::global(cx);
let default_folder = settings
.read(cx)
.settings()
.create_repository
.default_folder
.clone()
.unwrap_or_else(paths::desktop_dir);
let grasp_settings = settings.read(cx).settings().grasp_servers.clone();
let name_input = InputState::new(cx);
let desc_input = InputState::new(cx);
desc_input.update(cx, |state, cx| state.set_multiline(true, cx));
let folder_input = InputState::new_with_text(default_folder.to_string_lossy(), cx);
folder_input.update(cx, |state, cx| state.set_read_only(true, cx));
let relay_input = InputState::new(cx);
let state = cx.new(|_| DialogProgress::default());
let grasp_state = cx.new(|_| GraspServersState::new_default(&grasp_settings));
load_user_grasp_servers(grasp_state.clone(), window, cx);
cx.new(|_| Self {
name_input,
desc_input,
folder_input,
relay_input,
state,
grasp_state,
dock,
})
}
fn create(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.state.read(cx).busy {
return;
}
let name = self.name_input.read(cx).value().trim().to_owned();
let description = self.desc_input.read(cx).value().trim().to_owned();
let folder = PathBuf::from(self.folder_input.read(cx).value().trim());
let servers = self.grasp_state.read(cx).grasp_servers.clone();
if name.is_empty() {
self.fail("Repository name is required", cx);
return;
}
if servers.is_empty() {
self.fail("Add at least one grasp server", cx);
return;
}
let backend = Backend::global(cx);
let Some(user) = backend.read(cx).current_user() else {
self.fail("Sign in to create a repository", cx);
return;
};
let addr =
signed_core::RepoAddr::new(user, signed_core::RepoAddr::identifier_from_name(&name));
self.state.update(cx, |state, cx| {
state.begin_push(addr, cx);
cx.notify();
});
let task = backend.update(cx, |backend, cx| {
backend.create_repository(&name, &description, folder, servers, cx)
});
let state = self.state.clone();
let dock = self.dock.clone();
let handle = window.window_handle();
cx.spawn(async move |_this, cx| match task.await {
Ok((announcement, local_path)) => {
cx.update_window(handle, |_, window, cx| {
let checkouts = CheckoutsStore::global(cx);
checkouts.update(cx, |store, cx| {
store.record(local_path.clone(), announcement.addr(), cx);
});
cx.open_with_system(&local_path);
open_repo_panel(&dock, &announcement.addr(), Some(&announcement), window, cx);
close_global(cx);
})
.ok();
}
Err(error) => {
state.update(cx, |state, cx| {
state.fail(error.to_string());
cx.notify();
});
}
})
.detach();
}
fn fail(&mut self, message: impl Into<gpui::SharedString>, cx: &mut Context<Self>) {
self.state.update(cx, |state, cx| {
state.fail(message);
cx.notify();
});
}
}
impl Render for CreateRepoDialog {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let name_input = self.name_input.clone();
let desc_input = self.desc_input.clone();
let folder_input = self.folder_input.clone();
let relay_input = self.relay_input.clone();
let state = self.state.clone();
let grasp_state = self.grasp_state.clone();
let action_state = state.clone();
let this = cx.entity().downgrade();
Dialog::new("create-repo-dialog")
.open(true)
.min_width(px(520.))
.on_change(|open, _, cx| {
if !open {
close_global(cx);
}
})
.title("Create repository")
.content(move |_window, cx| {
let progress = state.read(cx);
v_flex()
.gap_3()
.child(
div()
.text_sm()
.text_color(cx.theme().colors.muted_foreground)
.child("Publish a new repository to your grasp servers."),
)
.child(
Field::new("repo-name", "Repository name")
.description("Max 100 characters")
.child(Input::new("repo-name-input", name_input.clone())),
)
.child(
Field::new("repo-description", "Description").child(
Input::new("repo-description-input", desc_input.clone()).rows(3),
),
)
.child(
Field::new("repo-folder", "Folder")
.description("Where the repository's working copy is created.")
.child(
h_flex()
.gap_1()
.items_center()
.child(div().flex_1().child(Input::new(
"repo-folder-input",
folder_input.clone(),
)))
.child(
Button::new("choose-folder")
.icon(Icon::named(IconRole::Folder))
.variant(ButtonVariant::Ghost)
.tooltip(Tooltip::text("Choose folder"))
.on_click({
let folder_input = folder_input.clone();
move |_, window, cx| {
choose_folder(&folder_input, window, cx);
}
}),
),
),
)
.child(grasp_servers_field(&grasp_state, &relay_input, cx))
.when_some(push_steps_row(&progress.push_steps, cx), |el, steps| {
el.child(steps)
})
.when_some(error_row(&progress.error, cx), |el, error| el.child(error))
.into_any_element()
})
.actions(move |_, cx| {
let busy = action_state.read(cx).busy;
let this = this.clone();
let create: AnyElement = Button::new("create-repository")
.label("Create repository")
.icon(Icon::named(IconRole::Plus))
.loading(busy)
.disabled(busy)
.on_click(move |_, window, cx| {
let _ = this.update(cx, |this, cx| this.create(window, cx));
})
.into_any_element();
vec![create]
})
}
}
fn choose_folder(folder_input: &Entity<InputState>, window: &mut Window, cx: &mut App) {
let store = SettingsStore::global(cx);
let prompt = cx.prompt_for_paths(PathPromptOptions {
files: false,
directories: true,
multiple: false,
prompt: Some("Choose folder".into()),
});
let handle = window.window_handle();
let folder_input = folder_input.clone();
cx.spawn(async move |cx| {
if let Ok(Ok(Some(mut paths))) = prompt.await
&& let Some(path) = paths.pop()
{
if let Err(error) = cx.update_window(handle, |_, window, cx| {
let text = path.to_string_lossy().to_string();
store.update(cx, |store, cx| {
store.edit(
|settings| {
settings.create_repository.default_folder = Some(path.clone());
},
cx,
);
});
folder_input.update(cx, |input, cx| input.set_value(text, window, cx));
}) {
log::warn!("failed to apply selected folder: {error}");
}
}
})
.detach();
}
/// Opens the create-repository form.
pub fn open(dock: WeakEntity<DockState>, window: &mut Window, cx: &mut App) {
open_global(CreateRepoDialog::new(dock, window, cx), cx);
}
@@ -0,0 +1,271 @@
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, AppContext as _, Entity, IntoElement, ParentElement as _, SharedString,
Styled as _, Window, div,
};
use gpui_native_kit::button::{Button, ButtonVariant};
use gpui_native_kit::common::{h_flex, v_flex};
use gpui_native_kit::icons::{Icon, IconRole};
use gpui_native_kit::input::{Input, InputState};
use gpui_native_kit::switch::Switch;
use gpui_native_kit::theme::ActiveTheme as _;
use gpui_native_kit::tooltip::Tooltip;
use nostr::prelude::RelayUrl;
use settings::{DEFAULT_GRASP_SERVERS, GraspServersSettings};
use signed_state::Backend;
/// State of the grasp-server section of a publish dialog, so async results can be rendered.
#[derive(Default)]
pub struct GraspServersState {
/// Set while the user's kind `10317` grasp list loads.
pub loading_servers: bool,
pub grasp_servers: Vec<RelayUrl>,
pub servers_enabled: bool,
/// Error from the last grasp-server edit, such as an invalid relay URL.
pub error: Option<SharedString>,
}
impl GraspServersState {
/// Defaults used until the user's grasp list loads and replaces them.
pub fn new_default(settings: &GraspServersSettings) -> Self {
let urls: Vec<String> = if settings.default_servers.is_empty() {
DEFAULT_GRASP_SERVERS
.iter()
.map(|url| (*url).to_owned())
.collect()
} else {
settings.default_servers.clone()
};
Self {
loading_servers: true,
servers_enabled: false,
grasp_servers: urls
.iter()
.filter_map(|url| RelayUrl::parse(url).ok())
.collect(),
..Default::default()
}
}
}
/// The Grasp servers section shared by the publish dialogs.
pub fn grasp_servers_field(
state: &Entity<GraspServersState>,
relay_input: &Entity<InputState>,
cx: &App,
) -> AnyElement {
let state = state.clone();
let relay_input = relay_input.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;
let error = state.read(cx).error.clone();
let add_state = state.clone();
let add_input = relay_input.clone();
let toggle_state = state.clone();
v_flex()
.gap_2()
.child(
h_flex()
.justify_between()
.items_center()
.child(div().text_sm().child("Grasp servers"))
.child(
Switch::new("grasp-servers-toggle")
.checked(servers_enabled)
.on_change(move |checked, _, cx| {
toggle_state.update(cx, |state, cx| {
state.servers_enabled = checked;
cx.notify();
});
}),
),
)
.when(servers_enabled, |section| {
section
.child(
div()
.text_xs()
.text_color(cx.theme().colors.muted_foreground)
.child("Where the repository is hosted"),
)
.child(
v_flex().gap_1().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("grasp-relay-input", relay_input.clone())),
)
.child(
Button::new("add-relay")
.icon(Icon::named(IconRole::Plus))
.variant(ButtonVariant::Ghost)
.tooltip(Tooltip::text("Add grasp server"))
.on_click(move |_, window, cx| {
add_relay(&add_state, &add_input, window, cx);
}),
),
)
.when(loading_servers, |section| {
section.child(
div()
.text_xs()
.text_color(cx.theme().colors.muted_foreground)
.child("Loading your grasp servers..."),
)
})
.when_some(error, |section, error| {
section.child(
div()
.text_xs()
.text_color(cx.theme().ramps.destructive.base)
.child(error),
)
})
})
.into_any_element()
}
fn render_server_row(
ix: usize,
relay: &RelayUrl,
state: Entity<GraspServersState>,
cx: &App,
) -> AnyElement {
h_flex()
.w_full()
.gap_1()
.items_center()
.child(
div()
.flex_1()
.h_8()
.px_2()
.bg(cx.theme().colors.muted)
.text_color(cx.theme().colors.muted_foreground)
.text_sm()
.rounded(cx.theme().radius.control)
.child(server_host(relay)),
)
.child(
Button::new(SharedString::from(format!("remove-relay:{ix}")))
.icon(Icon::named(IconRole::Close))
.variant(ButtonVariant::Ghost)
.tooltip(Tooltip::text("Remove"))
.on_click(move |_, _, cx| {
state.update(cx, |state, cx| {
if ix < state.grasp_servers.len() {
state.grasp_servers.remove(ix);
cx.notify();
}
});
}),
)
.into_any_element()
}
/// Accepts a bare host as well as a full URL.
fn add_relay(
state: &Entity<GraspServersState>,
input: &Entity<InputState>,
window: &mut Window,
cx: &mut App,
) {
let value = input.read(cx).value().trim().to_owned();
if value.is_empty() {
return;
}
match normalize_server(&value) {
Some((_, relay)) => {
state.update(cx, |state, cx| {
state.error = None;
if !state.grasp_servers.contains(&relay) {
state.grasp_servers.push(relay);
}
cx.notify();
});
input.update(cx, |input, cx| input.set_value("", window, cx));
}
None => {
state.update(cx, |state, cx| {
state.error = Some(format!("Invalid grasp server URL: {value}").into());
cx.notify();
});
}
}
}
/// Loads the user's kind `10317` grasp list from the local database.
pub fn load_user_grasp_servers(
state: Entity<GraspServersState>,
window: &mut Window,
cx: &mut App,
) {
let backend = Backend::global(cx);
let (user, client) = {
let backend = backend.read(cx);
(backend.current_user(), backend.client())
};
let Some(user) = user else {
state.update(cx, |state, _| state.loading_servers = false);
return;
};
let task =
cx.background_spawn(
async move { signed_state::user_grasp_list_servers(&client, user).await },
);
let handle = window.window_handle();
cx.spawn(async move |cx| {
let result = task.await;
if let Err(error) = cx.update_window(handle, |_, _window, cx| {
state.update(cx, |state, cx| {
state.loading_servers = false;
if let Ok(servers) = result
&& !servers.is_empty()
{
state.grasp_servers = servers;
}
cx.notify();
});
}) {
log::warn!("failed to apply grasp servers: {error}");
}
})
.detach();
}
/// Normalize a user-typed grasp server, adding a `wss://` scheme when none is given.
pub fn normalize_server(input: &str) -> Option<(String, RelayUrl)> {
let text = if input.contains("://") {
input.to_owned()
} else {
format!("wss://{input}")
};
RelayUrl::parse(&text).ok().map(|relay| (text, relay))
}
/// The host of a relay URL, which is what the server lists show; the scheme is implied.
pub fn server_host(relay: &RelayUrl) -> SharedString {
relay
.domain()
.map(SharedString::from)
.unwrap_or_else(|| SharedString::from(relay.to_string()))
}
+2 -1
View File
@@ -1,7 +1,7 @@
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
Styled as _, Subscription, Task, Window, div,
Styled as _, Subscription, Task, Window, div, px,
};
use gpui_native_kit::button::Button;
use gpui_native_kit::common::v_flex;
@@ -112,6 +112,7 @@ impl Render for ImportDialog {
Dialog::new("import-dialog")
.open(true)
.min_width(px(520.))
.on_change(|open, _, cx| {
if !open {
close_global(cx);
+286
View File
@@ -0,0 +1,286 @@
use std::path::PathBuf;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
SharedString, Styled as _, WeakEntity, Window, div, px,
};
use gpui_native_kit::button::Button;
use gpui_native_kit::common::v_flex;
use gpui_native_kit::dialog::Dialog;
use gpui_native_kit::field::Field;
use gpui_native_kit::icons::{Icon, IconRole};
use gpui_native_kit::input::{Input, InputState};
use gpui_native_kit::select::{Select, SelectOption};
use gpui_native_kit::theme::ActiveTheme as _;
use settings::SettingsStore;
use signed_core::RepoAddr;
use signed_git::Repo;
use signed_state::Backend;
use super::grasp_servers::{GraspServersState, grasp_servers_field, load_user_grasp_servers};
use super::progress::{DialogProgress, error_row, push_steps_row};
use super::{close_global, open_global};
use crate::views::repo::RepoDetailView;
pub struct InitRepoDialog {
local_path: PathBuf,
name_input: Entity<InputState>,
desc_input: Entity<InputState>,
relay_input: Entity<InputState>,
state: Entity<DialogProgress>,
grasp_state: Entity<GraspServersState>,
branches: Vec<SharedString>,
selected_branch: Option<SharedString>,
view: WeakEntity<RepoDetailView>,
}
impl InitRepoDialog {
pub fn new(
local_path: PathBuf,
view: WeakEntity<RepoDetailView>,
window: &mut Window,
cx: &mut App,
) -> Entity<Self> {
let default_name = local_path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default();
let grasp_settings = SettingsStore::global(cx)
.read(cx)
.settings()
.grasp_servers
.clone();
let state = cx.new(|_| DialogProgress::default());
let grasp_state = cx.new(|_| GraspServersState::new_default(&grasp_settings));
let name_input = InputState::new_with_text(default_name, cx);
name_input.update(cx, |state, cx| state.set_read_only(true, cx));
let desc_input = InputState::new(cx);
desc_input.update(cx, |state, cx| state.set_multiline(true, cx));
let relay_input = InputState::new(cx);
load_user_grasp_servers(grasp_state.clone(), window, cx);
let dialog = cx.new(|_| Self {
local_path,
name_input,
desc_input,
relay_input,
state,
grasp_state,
branches: Vec::new(),
selected_branch: None,
view,
});
load_branches(dialog.clone(), cx);
dialog
}
fn init_repository(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.state.read(cx).busy {
return;
}
let name = self.name_input.read(cx).value().trim().to_owned();
let description = self.desc_input.read(cx).value().trim().to_owned();
let servers = self.grasp_state.read(cx).grasp_servers.clone();
let default_branch = self
.selected_branch
.as_ref()
.map(|branch| branch.trim().to_owned())
.filter(|branch| !branch.is_empty());
if name.is_empty() {
self.fail("Repository name is required", cx);
return;
}
if servers.is_empty() {
self.fail("Add at least one grasp server", cx);
return;
}
let backend = Backend::global(cx);
let Some(user) = backend.read(cx).current_user() else {
self.fail("Sign in to publish a repository", cx);
return;
};
let addr = RepoAddr::new(user, RepoAddr::identifier_from_name(&name));
self.state.update(cx, |state, cx| {
state.begin_push(addr, cx);
cx.notify();
});
let local_path = self.local_path.clone();
let task = backend.update(cx, |backend, cx| {
backend.publish_local_repo(local_path, &name, &description, servers, default_branch, cx)
});
let state = self.state.clone();
let view = self.view.clone();
let handle = window.window_handle();
cx.spawn(async move |_this, cx| match task.await {
Ok(announcement) => {
cx.update_window(handle, |_, _window, cx| {
close_global(cx);
if let Some(view) = view.upgrade() {
view.update(cx, |this, cx| this.apply_announcement(announcement, cx));
}
})
.ok();
}
Err(error) => {
state.update(cx, |state, cx| {
state.fail(error.to_string());
cx.notify();
});
}
})
.detach();
}
fn fail(&mut self, message: impl Into<SharedString>, cx: &mut Context<Self>) {
self.state.update(cx, |state, cx| {
state.fail(message);
cx.notify();
});
}
}
impl Render for InitRepoDialog {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let name_input = self.name_input.clone();
let desc_input = self.desc_input.clone();
let relay_input = self.relay_input.clone();
let state = self.state.clone();
let grasp_state = self.grasp_state.clone();
let action_state = state.clone();
let this = cx.entity().downgrade();
let folder = self.local_path.display().to_string();
let branches = self.branches.clone();
let selected_branch = self.selected_branch.clone();
Dialog::new("init-repo-dialog")
.open(true)
.min_width(px(520.))
.on_change(|open, _, cx| {
if !open {
close_global(cx);
}
})
.title("Initialize repository")
.content(move |_, cx| {
let progress = state.read(cx);
v_flex()
.gap_3()
.child(
div()
.text_sm()
.text_color(cx.theme().colors.muted_foreground)
.child("Publish this local repository to Nostr."),
)
.child(
Field::new("init-name", "Repository name")
.description("Max 100 characters")
.child(Input::new("init-name-input", name_input.clone())),
)
.child(
Field::new("init-description", "Description").child(
Input::new("init-description-input", desc_input.clone()).rows(3),
),
)
.child(
Field::new("init-folder", "Folder")
.description("The local repository being published")
.child(
div()
.h_8()
.w_full()
.px_2()
.bg(cx.theme().colors.muted)
.text_sm()
.text_color(cx.theme().colors.muted_foreground)
.rounded(cx.theme().radius.control)
.child(folder.clone()),
),
)
.child(
Field::new("init-branch", "Default branch")
.description("The local branch to announce as the default")
.child(
Select::new("init-branch-select")
.options(
branches
.iter()
.cloned()
.map(|branch| SelectOption::new(branch.clone(), branch))
.collect::<Vec<_>>(),
)
.selected(selected_branch.clone())
.placeholder("Select branch"),
),
)
.child(grasp_servers_field(&grasp_state, &relay_input, cx))
.when_some(push_steps_row(&progress.push_steps, cx), |el, steps| {
el.child(steps)
})
.when_some(error_row(&progress.error, cx), |el, error| el.child(error))
.into_any_element()
})
.actions(move |_, cx| {
let busy = action_state.read(cx).busy;
let this = this.clone();
let init: AnyElement = Button::new("initialize-repository")
.label("Initialize")
.icon(Icon::named(IconRole::Plus))
.loading(busy)
.disabled(busy)
.on_click(move |_, window, cx| {
let _ = this.update(cx, |this, cx| this.init_repository(window, cx));
})
.into_any_element();
vec![init]
})
}
}
fn load_branches(dialog: Entity<InitRepoDialog>, cx: &mut App) {
let local_path = dialog.read(cx).local_path.clone();
cx.spawn(async move |cx| {
let loaded = cx
.background_spawn(async move {
let repo = Repo::open(&local_path)?;
let current: Option<SharedString> = repo.current_branch().map(Into::into);
Ok::<_, anyhow::Error>((repo.branches()?, current))
})
.await;
let Ok((branches, current)) = loaded else {
log::warn!("failed to list branches for the default branch selector");
return;
};
dialog.update(cx, |dialog, cx| {
dialog.branches = branches.into_iter().map(Into::into).collect();
dialog.selected_branch = current;
cx.notify();
});
})
.detach();
}
/// Opens the initialize form for `local_path`.
pub fn open(
local_path: PathBuf,
view: WeakEntity<RepoDetailView>,
window: &mut Window,
cx: &mut App,
) {
open_global(InitRepoDialog::new(local_path, view, window, cx), cx);
}
+5
View File
@@ -1,8 +1,13 @@
pub mod about;
pub mod create_repo;
pub mod exit;
pub mod grasp_servers;
pub mod import;
pub mod init_repo;
pub mod onboarding;
pub mod passphrase;
pub mod progress;
pub mod reset_remote;
use gpui::prelude::FluentBuilder as _;
use gpui::{
+2 -1
View File
@@ -1,7 +1,7 @@
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
Styled as _, Window, div,
Styled as _, Window, div, px,
};
use gpui_native_kit::button::Button;
use gpui_native_kit::common::v_flex;
@@ -88,6 +88,7 @@ impl Render for OnboardingDialog {
Dialog::new("onboarding-dialog")
.open(true)
.min_width(px(520.))
.on_change(|open, _, cx| {
if !open {
close_global(cx);
@@ -0,0 +1,99 @@
use gpui::{
AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
Styled as _, Window, div, px,
};
use gpui_native_kit::button::{Button, ButtonVariant};
use gpui_native_kit::common::v_flex;
use gpui_native_kit::dialog::Dialog;
use gpui_native_kit::theme::ActiveTheme as _;
use signed_state::RepoStore;
use super::{close_global, open_global};
pub struct ResetRemoteDialog {
store: Entity<RepoStore>,
deleted: Vec<String>,
}
impl ResetRemoteDialog {
pub fn new(store: Entity<RepoStore>, deleted: Vec<String>, cx: &mut App) -> Entity<Self> {
cx.new(|_| Self { store, deleted })
}
}
impl Render for ResetRemoteDialog {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
let deleted = self.deleted.clone();
let store = self.store.clone();
Dialog::new("reset-remote-dialog")
.open(true)
.min_width(px(480.))
.on_change(|open, _, cx| {
if !open {
close_global(cx);
}
})
.title("Reset remote to local")
.content(move |_, cx| {
v_flex()
.gap_2()
.child(
div()
.text_sm()
.text_color(cx.theme().colors.muted_foreground)
.child(
"Grasp servers are overwritten to match the local mirror. Refs that do not exist locally are deleted from the servers. This cannot be undone.",
),
)
.child(if deleted.is_empty() {
div()
.text_sm()
.text_color(cx.theme().colors.muted_foreground)
.child("No server refs will be deleted.")
.into_any_element()
} else {
v_flex()
.gap_1()
.children(deleted.iter().map(|name| {
div()
.text_sm()
.font_family(cx.theme().mono_font)
.child(name.clone())
}))
.into_any_element()
})
.into_any_element()
})
.actions(move |_, _| {
let store = store.clone();
let cancel: AnyElement = Button::new("cancel-reset-remote")
.label("Cancel")
.variant(ButtonVariant::Secondary)
.on_click(|_, _, cx| close_global(cx))
.into_any_element();
let confirm: AnyElement = Button::new("confirm-reset-remote")
.label("Reset remote")
.variant(ButtonVariant::Destructive)
.on_click(move |_, _, cx| {
let task = store.update(cx, |store, cx| store.reset_repository(cx));
cx.spawn(async move |_cx| {
if let Err(error) = task.await {
log::error!("failed to reset remote: {error:#}");
}
})
.detach();
close_global(cx);
})
.into_any_element();
vec![cancel, confirm]
})
}
}
/// Opens the reset confirmation listing the refs the reset deletes.
pub fn open(store: Entity<RepoStore>, deleted: Vec<String>, cx: &mut App) {
open_global(ResetRemoteDialog::new(store, deleted, cx), cx);
}
+22
View File
@@ -1 +1,23 @@
use gpui::{App, Entity, SharedString, WeakEntity, Window};
use gpui_native_kit::layout::dock::{DockState, Panel};
/// Registers a panel builder under `id` and opens it in the dock.
pub fn open<P, F>(
dock: &WeakEntity<DockState>,
id: impl Into<SharedString>,
build: F,
window: &mut Window,
cx: &mut App,
) where
P: Panel + 'static,
F: Fn(&mut Window, &mut App) -> Entity<P> + 'static,
{
let id = id.into();
if let Err(error) = dock.update(cx, |dock, cx| {
dock.register_panel(id.clone(), build);
dock.open_panel(&id, window, cx);
}) {
log::warn!("failed to open panel {id}: {error}");
}
}
+2
View File
@@ -1,2 +1,4 @@
pub mod placeholder;
pub mod repo;
pub mod sidebar;
pub mod tree;
@@ -0,0 +1,32 @@
use gpui::{App, AppContext as _, SharedString, WeakEntity, Window};
use gpui_native_kit::layout::dock::DockState;
use signed_core::{Announcement, RepoAddr};
use crate::panels;
use crate::views::repo::RepoDetailView;
/// Opens a repository as a dock panel, keyed by its address.
pub fn open_repo_panel(
dock: &WeakEntity<DockState>,
addr: &RepoAddr,
hint: Option<&Announcement>,
window: &mut Window,
cx: &mut App,
) {
let id = SharedString::from(format!("repo:{addr}"));
let addr = addr.clone();
let hint = hint.cloned();
let dock_for_build = dock.clone();
panels::open(
dock,
id,
move |window, cx| {
let addr = addr.clone();
let hint = hint.clone();
cx.new(|cx| RepoDetailView::new(dock_for_build.clone(), addr, hint, window, cx))
},
window,
cx,
);
}
@@ -0,0 +1,54 @@
use std::collections::HashSet;
use std::path::PathBuf;
use signed_state::{CheckoutStatus, CheckoutSyncStatus};
#[derive(Default)]
pub(super) struct Banners {
dismissed: HashSet<(PathBuf, String)>,
sync_dismissed: HashSet<PathBuf>,
ready_requested: bool,
ready_head: Option<String>,
ready_statuses: Vec<CheckoutStatus>,
push_statuses: Vec<CheckoutStatus>,
}
impl Banners {
pub(super) fn dismissal(&self, status: &CheckoutStatus) -> bool {
self.dismissed
.contains(&(status.path.clone(), status.branch.clone()))
}
pub(super) fn dismiss(&mut self, status: &CheckoutStatus) {
self.dismissed
.insert((status.path.clone(), status.branch.clone()));
}
pub(super) fn sync_dismissal(&self, checkout: &CheckoutSyncStatus) -> bool {
self.sync_dismissed.contains(&checkout.path)
}
pub(super) fn sync_dismiss(&mut self, checkout: &CheckoutSyncStatus) {
self.sync_dismissed.insert(checkout.path.clone());
}
pub(super) fn ready_requested_at(&self) -> (bool, &Option<String>) {
(self.ready_requested, &self.ready_head)
}
pub(super) fn mark_ready_requested(&mut self, head: Option<String>) {
self.ready_requested = true;
self.ready_head = head;
}
pub(super) fn set_statuses(
&mut self,
ready: Vec<CheckoutStatus>,
push: Vec<CheckoutStatus>,
) -> bool {
let changed = ready != self.ready_statuses || push != self.push_statuses;
self.ready_statuses = ready;
self.push_statuses = push;
changed
}
}
+809
View File
@@ -0,0 +1,809 @@
use std::collections::{HashMap, HashSet, VecDeque};
use std::path::{Component, Path, PathBuf};
use anyhow::Error;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
SharedString, Styled as _, Task, WeakEntity, Window, div, px,
};
use gpui_native_kit::common::{h_flex, v_flex};
use gpui_native_kit::data::tree::{Tree, TreeItem};
use gpui_native_kit::editor::code::CodeViewState;
use gpui_native_kit::editor::highlight::Language;
use gpui_native_kit::editor::markdown::MarkdownViewState;
use gpui_native_kit::empty_state::EmptyState;
use gpui_native_kit::icons::{Icon, IconRole};
use gpui_native_kit::spinner::Spinner;
use gpui_native_kit::theme::ActiveTheme as _;
use signed_git::{FileCommit, Repo, WorktreeSnapshot};
use crate::views::tree::{TreeItemSeed, TreeRow, flatten};
const TREE_WIDTH: f32 = 240.;
const MAX_PREVIEW_BYTES: usize = 1024 * 1024;
const MAX_PREVIEWED_FILES: usize = 32;
const MAX_PREVIEW_CACHE_BYTES: usize = 8 * 1024 * 1024;
enum FileContent {
Text(String),
Binary,
TooLarge,
Failed(String),
}
struct MarkdownView {
/// `None` means the repository README.
path: Option<SharedString>,
state: Entity<MarkdownViewState>,
/// Hash of the source, so the same document is not re-parsed on a refresh.
source_hash: u64,
}
struct CodeView {
/// Source path, relative to the worktree root.
path: SharedString,
state: Entity<CodeViewState>,
/// Hash of the source, so the same document is not re-parsed on a refresh.
source_hash: u64,
}
pub struct RepoFilesView {
seeds: Vec<TreeItemSeed>,
expanded: HashSet<String>,
rows: Vec<TreeRow>,
selected_index: Option<usize>,
worktree: Option<PathBuf>,
worktree_paths: Vec<String>,
md: Option<MarkdownView>,
code: Option<CodeView>,
readme_name: Option<SharedString>,
selected_file: Option<SharedString>,
files: HashMap<String, FileContent>,
file_order: VecDeque<String>,
preview_bytes: usize,
loading_files: HashSet<String>,
commits: HashMap<String, FileCommit>,
pending_commits: Vec<String>,
loading_commits: bool,
tasks: Vec<Task<Result<(), Error>>>,
}
impl RepoFilesView {
pub fn new(cx: &mut Context<Self>) -> Self {
let _ = cx;
Self {
seeds: Vec::new(),
expanded: HashSet::new(),
rows: Vec::new(),
selected_index: None,
worktree: None,
worktree_paths: Vec::new(),
md: None,
code: None,
readme_name: None,
selected_file: None,
files: HashMap::new(),
file_order: VecDeque::new(),
preview_bytes: 0,
loading_files: HashSet::new(),
commits: HashMap::new(),
pending_commits: Vec::new(),
loading_commits: false,
tasks: Vec::new(),
}
}
pub fn set_worktree(&mut self, path: PathBuf) {
self.worktree = Some(path);
}
pub fn apply_entries(
&mut self,
tree: Vec<TreeItemSeed>,
paths: Vec<String>,
cx: &mut Context<Self>,
) {
self.worktree_paths = paths;
self.seeds = tree;
let folder_ids = folder_ids(&self.seeds);
self.expanded.retain(|id| folder_ids.contains(id));
self.rebuild_rows();
cx.notify();
}
fn rebuild_rows(&mut self) {
self.rows = flatten(&self.seeds, &self.expanded);
self.selected_index = self
.selected_file
.as_ref()
.and_then(|path| self.rows.iter().position(|row| row.id == path.as_ref()));
}
fn toggle_folder(&mut self, id: &str) {
if !self.expanded.remove(id) {
self.expanded.insert(id.to_owned());
}
self.rebuild_rows();
}
/// Point the README pane at `path`/`bytes`, or clear it when absent.
pub fn set_readme(
&mut self,
path: Option<PathBuf>,
bytes: Option<Vec<u8>>,
cx: &mut Context<Self>,
) -> bool {
let Some((path, bytes)) = path.zip(bytes) else {
let changed = self.readme_name.is_some() || self.md.is_some();
self.readme_name = None;
self.md = None;
return changed;
};
let name: SharedString = path.to_string_lossy().into();
let mut changed = self.readme_name.as_ref() != Some(&name);
self.readme_name = Some(name);
self.load_commit(&path.to_string_lossy(), cx);
if let Ok(text) = String::from_utf8(bytes) {
changed |= self.set_markdown(None, &text, cx);
}
changed
}
/// Drop every cached preview and the README, e.g. on a branch switch.
pub fn clear_previews(&mut self) {
self.selected_file = None;
self.selected_index = None;
self.files.clear();
self.file_order.clear();
self.preview_bytes = 0;
self.loading_files.clear();
self.commits.clear();
self.pending_commits.clear();
self.loading_commits = false;
self.md = None;
self.code = None;
self.readme_name = None;
}
/// Refresh after the mirror caught up with the remote.
pub fn catch_up(
&mut self,
snapshot: &WorktreeSnapshot,
tree: Vec<TreeItemSeed>,
paths: Vec<String>,
cx: &mut Context<Self>,
) -> bool {
let mut changed = false;
if paths != self.worktree_paths {
self.apply_entries(tree, paths, cx);
changed = true;
}
let present: HashSet<String> = snapshot
.entries
.iter()
.map(|path| path.to_string_lossy().into_owned())
.collect();
let mut previewed: Vec<String> = Vec::new();
previewed.extend(self.files.keys().cloned());
previewed.extend(self.selected_file.clone().map(|path| path.to_string()));
if let Some(path) = self.md.as_ref().and_then(|md| md.path.clone()) {
previewed.push(path.to_string());
}
if let Some(path) = self.code.as_ref().map(|code| code.path.clone()) {
previewed.push(path.to_string());
}
previewed.sort();
previewed.dedup();
for path in previewed {
if !present.contains(&path) {
self.drop_preview_of(&path);
changed = true;
}
}
if self.selected_file.is_none() {
changed |= self.set_readme(snapshot.readme_path.clone(), snapshot.readme.clone(), cx);
}
changed
}
fn pane_title(&self) -> SharedString {
self.selected_file
.clone()
.or_else(|| self.readme_name.clone())
.unwrap_or_else(|| "Overview".into())
}
fn activate_row(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
let Some(row) = self.rows.get(index) else {
return;
};
let id = row.id.clone();
let expandable = row.expandable;
self.selected_index = Some(index);
if expandable {
self.toggle_folder(&id);
} else {
self.open_file(&id, window, cx);
}
cx.notify();
}
fn render_tree_column(
&self,
rows: Vec<TreeRow>,
selected: Option<usize>,
view: WeakEntity<Self>,
cx: &mut Context<Self>,
) -> impl IntoElement {
let change_view = view.clone();
let disclosure_view = view;
v_flex()
.h_full()
.w(px(TREE_WIDTH))
.p_2()
.flex_none()
.border_r_1()
.border_color(cx.theme().colors.border)
.child(
div().flex_1().min_h_0().child(
Tree::new("repo-tree")
.selected(selected)
.items(move |_, _| {
rows.iter()
.map(|row| {
let item = TreeItem::new(row.id.clone(), row.label.clone())
.depth(row.depth)
.expandable(row.expandable)
.expanded(row.expanded);
if row.expandable {
item.icon(Icon::named(IconRole::Folder))
} else {
item
}
})
.collect()
})
.on_change({
let view = change_view;
move |index, window, cx| {
if let Some(index) = index
&& let Err(error) = view.update(cx, |this, cx| {
this.activate_row(index, window, cx);
})
{
log::warn!("failed to activate tree row: {error}");
}
}
})
.on_disclosure({
let view = disclosure_view;
move |index, _, cx| {
if let Err(error) = view.update(cx, |this, cx| {
let id = this.rows.get(index).map(|row| row.id.clone());
if let Some(id) = id {
this.toggle_folder(&id);
cx.notify();
}
}) {
log::warn!("failed to toggle folder: {error}");
}
}
}),
),
)
}
fn render_content_column(
&self,
pane_title: SharedString,
cx: &mut Context<Self>,
) -> impl IntoElement {
let body: AnyElement = if let Some(path) = self.selected_file.clone() {
match self.files.get(path.as_ref()) {
Some(FileContent::Text(_)) => {
if is_markdown_path(path.as_ref()) {
self.markdown_element(Some(path.as_ref()))
} else {
self.code_element(path.as_ref())
}
}
Some(FileContent::Binary) => placeholder("Binary file - preview not supported"),
Some(FileContent::TooLarge) => placeholder("File is too large to preview"),
Some(FileContent::Failed(message)) => placeholder(message),
None => preview_spinner(),
}
} else if self.readme_name.is_some() {
self.markdown_element(None)
} else {
placeholder("No README found")
};
let commit = match &self.selected_file {
Some(path) => self.commits.get(path.as_ref()),
None => self
.readme_name
.as_ref()
.and_then(|name| self.commits.get(name.as_ref())),
};
v_flex()
.flex_1()
.min_w_0()
.h_full()
.child(
h_flex()
.px_3()
.h_9()
.gap_2()
.bg(cx.theme().colors.muted)
.border_b_1()
.border_color(cx.theme().colors.border)
.text_color(cx.theme().colors.muted_foreground)
.child(
div()
.text_xs()
.font_weight(gpui::FontWeight::SEMIBOLD)
.text_ellipsis()
.whitespace_nowrap()
.child(pane_title),
)
.when_some(commit, |this, commit| {
this.child(
h_flex()
.flex_1()
.gap_1()
.child(
div()
.text_xs()
.font_family(cx.theme().mono_font)
.child(commit.id.clone()),
)
.child(
div()
.max_w(px(250.))
.text_xs()
.text_ellipsis()
.whitespace_nowrap()
.child(commit.summary.clone()),
),
)
}),
)
.child(div().flex_1().min_h_0().child(body))
}
fn set_markdown(
&mut self,
path: Option<SharedString>,
text: &str,
cx: &mut Context<Self>,
) -> bool {
let hash = source_hash(text);
if let Some(md) = &self.md
&& md.path == path
&& md.source_hash == hash
{
return false;
}
if let Some(md) = self.md.take()
&& md.path == path
{
md.state.update(cx, |state, cx| {
state.set_source(text, cx);
});
self.md = Some(MarkdownView {
path,
state: md.state,
source_hash: hash,
});
return true;
}
let state = cx.new(|_| MarkdownViewState::new("repo-readme", text));
self.md = Some(MarkdownView {
path,
state,
source_hash: hash,
});
true
}
fn markdown_element(&self, path: Option<&str>) -> AnyElement {
let Some(md) = &self.md else {
return preview_spinner();
};
let ready = match path {
Some(path) => md.path.as_deref() == Some(path),
None => md.path.is_none(),
};
if !ready {
return preview_spinner();
}
div()
.p_4()
.size_full()
.overflow_hidden()
.child(md.state.clone())
.into_any_element()
}
fn set_code(
&mut self,
path: SharedString,
text: &str,
window: &mut Window,
cx: &mut Context<Self>,
) {
let hash = source_hash(text);
let language = code_language(path.as_ref());
if let Some(code) = &self.code
&& code.path == path
&& code.source_hash == hash
{
return;
}
if let Some(code) = self.code.take()
&& code.path == path
{
code.state.update(cx, |state, cx| {
state.set_source(text, language, cx);
});
self.code = Some(CodeView {
path,
state: code.state,
source_hash: hash,
});
return;
}
let state = cx.new(|cx| CodeViewState::new("repo-code", text, language, cx));
let _ = window;
self.code = Some(CodeView {
path,
state,
source_hash: hash,
});
}
fn code_element(&self, path: &str) -> AnyElement {
let Some(code) = &self.code else {
return preview_spinner();
};
if code.path.as_ref() != path {
return preview_spinner();
}
div()
.size_full()
.overflow_hidden()
.child(code.state.clone())
.into_any_element()
}
fn open_file(&mut self, path: &str, window: &mut Window, cx: &mut Context<Self>) {
self.selected_file = Some(path.into());
if self.files.contains_key(path) {
if let Some(FileContent::Text(text)) = self.files.get(path) {
let text = text.clone();
if is_markdown_path(path) {
if self.md.as_ref().map(|md| md.path.as_deref()) != Some(Some(path)) {
self.set_markdown(Some(path.into()), &text, cx);
}
} else if self.code.as_ref().map(|code| code.path.as_str()) != Some(path) {
self.set_code(path.into(), &text, window, cx);
}
}
cx.notify();
return;
}
if self.loading_files.contains(path) {
cx.notify();
return;
}
let rel = Path::new(path);
let unsafe_path = rel.is_absolute()
|| rel.components().any(|c| {
matches!(
c,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
});
let Some(worktree) = self.worktree.clone() else {
return;
};
if unsafe_path {
return;
}
self.loading_files.insert(path.to_string());
let path = path.to_string();
self.load_commit(&path, cx);
let task: Task<Result<(), Error>> = cx.spawn_in(window, async move |this, cx| {
let path_for_read = path.clone();
let content = cx
.background_spawn(async move {
let full = worktree.join(&path_for_read);
let metadata = match std::fs::metadata(&full) {
Ok(metadata) => metadata,
Err(error) => return Err(anyhow::anyhow!("{}", error)),
};
if metadata.len() > MAX_PREVIEW_BYTES as u64 {
return Ok(FileContent::TooLarge);
}
let bytes = match std::fs::read(&full) {
Ok(bytes) => bytes,
Err(error) => return Err(anyhow::anyhow!("{}", error)),
};
match String::from_utf8(bytes) {
Ok(text) => Ok(FileContent::Text(text)),
Err(_) => Ok(FileContent::Binary),
}
})
.await;
this.update_in(cx, |this, window, cx| {
this.loading_files.remove(&path);
match content {
Ok(kind) => {
if let FileContent::Text(text) = &kind {
if is_markdown_path(&path) {
let same = this.md.as_ref().map(|md| md.path.as_deref())
== Some(Some(path.as_str()));
if !same {
this.set_markdown(Some(path.clone().into()), text, cx);
}
} else {
let same = this.code.as_ref().map(|code| code.path.as_str())
== Some(path.as_str());
if !same {
this.set_code(path.clone().into(), text, window, cx);
}
}
this.preview_bytes += text.len();
}
this.files.insert(path.clone(), kind);
this.file_order.push_back(path);
this.evict_previews();
}
Err(error) => {
this.files
.insert(path, FileContent::Failed(error.to_string()));
}
}
cx.notify();
})?;
Ok(())
});
self.tasks.push(task);
}
fn drop_preview_of(&mut self, path: &str) {
if let Some(FileContent::Text(text)) = self.files.remove(path) {
self.preview_bytes -= text.len();
}
self.commits.remove(path);
if self.selected_file.as_deref() == Some(path) {
self.selected_file = None;
}
if self.md.as_ref().and_then(|md| md.path.as_deref()) == Some(path) {
self.md = None;
}
if self.code.as_ref().map(|code| code.path.as_ref()) == Some(path) {
self.code = None;
}
}
fn evict_previews(&mut self) {
while (self.files.len() > MAX_PREVIEWED_FILES
|| self.preview_bytes > MAX_PREVIEW_CACHE_BYTES)
&& self.file_order.len() > 1
{
let Some(path) = self.file_order.pop_front() else {
break;
};
if Some(path.as_str()) == self.selected_file.as_deref() {
self.file_order.push_back(path);
continue;
}
if let Some(FileContent::Text(text)) = self.files.remove(&path) {
self.preview_bytes -= text.len();
}
if self.md.as_ref().and_then(|md| md.path.as_deref()) == Some(path.as_str()) {
self.md = None;
}
if self
.code
.as_ref()
.is_some_and(|code| code.path.as_ref() == path.as_str())
{
self.code = None;
}
self.commits.remove(&path);
}
}
fn load_commit(&mut self, path: &str, cx: &mut Context<Self>) {
if self.commits.contains_key(path) || self.pending_commits.iter().any(|p| p == path) {
return;
}
self.pending_commits.push(path.to_string());
if !self.loading_commits {
self.load_commits(cx);
}
}
fn load_commits(&mut self, cx: &mut Context<Self>) {
if self.pending_commits.is_empty() || self.loading_commits {
return;
}
let Some(worktree) = self.worktree.clone() else {
self.pending_commits.clear();
return;
};
self.loading_commits = true;
let paths = std::mem::take(&mut self.pending_commits);
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
let rels: Vec<PathBuf> = paths.iter().map(PathBuf::from).collect();
let result = cx
.background_spawn(async move {
Repo::open_cached(&worktree).and_then(|repo| repo.last_commits(&rels))
})
.await;
this.update(cx, |this, cx| {
this.loading_commits = false;
if let Ok(found) = result {
for (path, commit) in found {
this.commits
.insert(path.to_string_lossy().into_owned(), commit);
}
}
if !this.pending_commits.is_empty() {
this.load_commits(cx);
}
cx.notify();
})?;
Ok(())
});
self.tasks.push(task);
}
}
impl Render for RepoFilesView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let rows = self.rows.clone();
let selected = self.selected_index;
let view = cx.entity().downgrade();
let pane_title = self.pane_title();
h_flex()
.flex_1()
.w_full()
.overflow_hidden()
.child(self.render_tree_column(rows, selected, view, cx))
.child(self.render_content_column(pane_title, cx))
}
}
fn folder_ids(seeds: &[TreeItemSeed]) -> HashSet<String> {
fn push(seed: &TreeItemSeed, ids: &mut HashSet<String>) {
if !seed.children.is_empty() {
ids.insert(seed.id.clone());
for child in &seed.children {
push(child, ids);
}
}
}
let mut ids = HashSet::new();
for seed in seeds {
push(seed, &mut ids);
}
ids
}
fn source_hash(text: &str) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
text.hash(&mut hasher);
hasher.finish()
}
fn preview_spinner() -> AnyElement {
v_flex()
.size_full()
.items_center()
.justify_center()
.child(Spinner::new("repo-preview-spinner"))
.into_any_element()
}
fn placeholder(message: impl Into<SharedString>) -> AnyElement {
v_flex()
.size_full()
.items_center()
.justify_center()
.child(EmptyState::new(message))
.into_any_element()
}
/// The kit grammar for a path; only four languages are supported.
fn code_language(path: &str) -> Language {
match Path::new(path)
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.to_ascii_lowercase())
.as_deref()
{
Some("rs") => Language::Rust,
Some("ts" | "mts" | "cts" | "tsx") => Language::TypeScript,
Some("js" | "mjs" | "cjs" | "jsx") => Language::JavaScript,
Some("py") => Language::Python,
_ => Language::Plain,
}
}
/// Whether a file path has a markdown extension.
fn is_markdown_path(path: &str) -> bool {
Path::new(path)
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| {
matches!(
ext.to_ascii_lowercase().as_str(),
"md" | "markdown" | "mdown" | "mkdn"
)
})
}
File diff suppressed because it is too large Load Diff
+139
View File
@@ -0,0 +1,139 @@
use gpui::{App, AppContext as _, Entity, SharedString, WeakEntity, Window};
use gpui_native_kit::combobox::{Combobox, ComboboxState};
use super::RepoDetailView;
pub(super) struct RefSwitcher {
pub(super) branch_select: Entity<ComboboxState<SharedString>>,
pub(super) tag_select: Entity<ComboboxState<SharedString>>,
ref_branches: Vec<SharedString>,
ref_tags: Vec<SharedString>,
pub(super) switching_ref: bool,
}
impl RefSwitcher {
pub(super) fn new(view: WeakEntity<RepoDetailView>, window: &mut Window, cx: &mut App) -> Self {
let branch_view = view.clone();
let branch_select = cx.new(|cx| {
ComboboxState::new(
Combobox::new("branch-select", "Branch", Vec::new())
.placeholder("Branch")
.on_change(move |value, window, cx| {
if let Some(name) = value {
if let Err(error) = branch_view.update(cx, |this, cx| {
this.switch_ref(super::RefKind::Branch, name, window, cx);
}) {
log::warn!("failed to switch branch: {error}");
}
}
}),
window,
cx,
)
});
let tag_view = view;
let tag_select = cx.new(|cx| {
ComboboxState::new(
Combobox::new("tag-select", "Tag", Vec::new())
.placeholder("Tag")
.on_change(move |value, window, cx| {
if let Some(name) = value {
if let Err(error) = tag_view.update(cx, |this, cx| {
this.switch_ref(super::RefKind::Tag, name, window, cx);
}) {
log::warn!("failed to switch tag: {error}");
}
}
}),
window,
cx,
)
});
Self {
branch_select,
tag_select,
ref_branches: Vec::new(),
ref_tags: Vec::new(),
switching_ref: false,
}
}
pub(super) fn set_branches(
&mut self,
branches: Vec<SharedString>,
selected: Option<SharedString>,
window: &mut Window,
cx: &mut App,
) -> bool {
sync_selector(
&self.branch_select,
&mut self.ref_branches,
branches,
selected,
window,
cx,
)
}
pub(super) fn set_tags(
&mut self,
tags: Vec<SharedString>,
window: &mut Window,
cx: &mut App,
) -> bool {
sync_selector(&self.tag_select, &mut self.ref_tags, tags, None, window, cx)
}
pub(super) fn restore_selection(
&self,
select: &Entity<ComboboxState<SharedString>>,
previous: &Option<SharedString>,
window: &mut Window,
cx: &mut App,
) {
select.update(cx, |state, cx| {
state.set_selected(previous.clone(), window, cx)
});
}
}
fn sync_selector(
select: &Entity<ComboboxState<SharedString>>,
cached: &mut Vec<SharedString>,
items: Vec<SharedString>,
selected: Option<SharedString>,
window: &mut Window,
cx: &mut App,
) -> bool {
let items_changed = *cached != items;
let selection_changed = selected
.as_ref()
.is_some_and(|value| select.read(cx).selected() != Some(value));
if !items_changed && !selection_changed {
return false;
}
select.update(cx, |state, cx| {
if items_changed {
state.set_options(
items
.iter()
.map(|item| (item.clone(), item.clone()))
.collect(),
cx,
);
}
if let Some(value) = selected
&& (items_changed || selection_changed)
{
state.set_selected(Some(value), window, cx);
}
});
*cached = items;
true
}
+261 -14
View File
@@ -3,23 +3,26 @@ use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use gpui::{
App, Context, FontWeight, IntoElement, ObjectFit, ParentElement as _, SharedString,
Styled as _, StyledImage as _, Subscription, WeakEntity, Window, div, img, px,
AnyElement, App, AppContext as _, Context, FontWeight, IntoElement, ObjectFit,
ParentElement as _, SharedString, Styled as _, StyledImage as _, Subscription, WeakEntity,
Window, div, img, px,
};
use gpui_native_kit::avatar::Avatar;
use gpui_native_kit::badge::{Badge, BadgeVariant};
use gpui_native_kit::button::{Button, ButtonVariant};
use gpui_native_kit::common::{h_flex, v_flex};
use gpui_native_kit::data::sidebar::Sidebar;
use gpui_native_kit::common::{Sizable as _, h_flex, v_flex};
use gpui_native_kit::data::sidebar::{Sidebar, SidebarEntry};
use gpui_native_kit::icons::{Icon, IconRole};
use gpui_native_kit::layout::dock::DockState;
use gpui_native_kit::menu::{Menu, MenuItem};
use gpui_native_kit::theme::ActiveTheme as _;
use gpui_native_kit::tooltip::Tooltip;
use metadata::{Profile, ProfileDirectory};
use nostr::prelude::PublicKey;
use signed_core::{Announcement, RepoAddr};
use signed_state::{
Backend, BackendEvent, CheckoutsStore, LocalReposStore, RepoListStore, ResolvedLocalRepo,
SyncStatusDirectory,
Backend, BackendEvent, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind, RepoListStore,
ResolvedLocalRepo, SyncStatusDirectory,
};
use crate::dialogs;
@@ -208,18 +211,198 @@ impl SidebarView {
};
let weak = self.weak.clone();
let announcements = self.announcements.clone();
let local_repos = self.local_repos.clone();
let unpushed = self.unpushed.clone();
let directory = self.directory.clone();
let sync_directory = self.sync_directory.clone();
Sidebar::new("signed-sidebar")
.transparent(true)
.header(self.render_user(&profile, cx))
.entries(|_, _| Vec::new())
.header(self.render_user(&profile, self.scanning, cx))
.entries(move |_, cx| {
let mut entries = vec![SidebarEntry::header("All Repositories")];
for announcement in announcements.iter() {
let owner = directory.read(cx).profile(&announcement.owner, cx);
let name = announcement
.name
.as_deref()
.map(SharedString::from)
.unwrap_or_else(|| SharedString::from(announcement.id.clone()));
let addr = announcement.addr();
let mut row = SidebarEntry::row(
SharedString::from(format!("repo:{}", announcement.id)),
name,
)
.prefix(
Avatar::new(announcement.owner)
.name(owner.name())
.picture(owner.picture())
.small(),
);
let count = unpushed.get(&addr).copied().unwrap_or(0);
let unsynced = sync_directory.read(cx).unsynced(&addr, cx);
if count > 0 || unsynced > 0 {
let mut badges = h_flex().gap_1();
if count > 0 {
badges = badges.child(
Badge::new(
SharedString::from(format!("unpushed:{addr}")),
count.to_string(),
)
.variant(BadgeVariant::Danger),
);
}
if unsynced > 0 {
badges = badges.child(
Badge::new(
SharedString::from(format!("unsynced:{addr}")),
unsynced.to_string(),
)
.variant(BadgeVariant::Accent),
);
}
row = row.suffix(badges);
}
entries.push(row);
}
for entry in local_repos.iter() {
let suffix: AnyElement = match entry.nip34.as_ref() {
Some(binding) => {
let (label, note) = match binding.kind {
Nip34Kind::Initialized => {
let platform = local_platform(binding).unwrap_or("Grasp");
(platform, "Initialized for NIP-34")
}
Nip34Kind::Cloned => ("Cloned", "Cloned but not initialized"),
Nip34Kind::ToolingOnly => ("Tooling", "Grasp tooling only"),
};
Badge::new(
SharedString::from(format!("local-badge:{}", entry.path.display())),
label,
)
.tooltip(Tooltip::text(note))
.into_any_element()
}
None => Badge::new(
SharedString::from(format!(
"local-unpublished:{}",
entry.path.display()
)),
"Unpublished",
)
.variant(BadgeVariant::Warning)
.into_any_element(),
};
entries.push(
SidebarEntry::row(
SharedString::from(format!("local-repo:{}", entry.path.display())),
entry.name(),
)
.prefix(local_avatar(entry, &directory, cx))
.suffix(suffix),
);
}
entries
})
.on_change(move |index, window, cx| {
let _ = weak.update(cx, |this, cx| this.activate(index, window, cx));
if let Err(error) = weak.update(cx, |this, cx| this.activate(index, window, cx)) {
log::warn!("failed to activate sidebar row: {error}");
}
})
}
fn activate(&mut self, _index: Option<usize>, _window: &mut Window, _cx: &mut Context<Self>) {
// Repository and navigation rows arrive with their screens.
fn activate(&mut self, index: Option<usize>, window: &mut Window, cx: &mut Context<Self>) {
let Some(index) = index else {
return;
};
let Some(row) = index.checked_sub(1) else {
return;
};
if row < self.announcements.len() {
let announcement = self.announcements[row].clone();
let addr = announcement.addr();
crate::views::repo::actions::open_repo_panel(
&self.dock,
&addr,
Some(&announcement),
window,
cx,
);
return;
}
let local_ix = row - self.announcements.len();
if let Some(entry) = self.local_repos.get(local_ix).cloned() {
self.open_local_entry(entry, window, cx);
}
}
/// A local repository opens as the announced repository when its binding matches one.
fn open_local_entry(
&mut self,
entry: ResolvedLocalRepo,
window: &mut Window,
cx: &mut Context<Self>,
) {
let ResolvedLocalRepo {
path,
nip34,
announcement,
} = entry;
if let Some(announcement) = announcement {
let id = SharedString::from(format!("repo:{}", announcement.addr()));
let dock = self.dock.clone();
let dock_for_build = dock.clone();
crate::panels::open(
&dock,
id,
move |window, cx| {
cx.new(|cx| {
crate::views::repo::RepoDetailView::new_local_announced(
dock_for_build.clone(),
announcement.clone(),
path.clone(),
window,
cx,
)
})
},
window,
cx,
);
return;
}
let id = SharedString::from(format!("repo-local:{}", path.display()));
let dock = self.dock.clone();
let dock_for_build = dock.clone();
crate::panels::open(
&dock,
id,
move |window, cx| {
cx.new(|cx| {
crate::views::repo::RepoDetailView::new_local(
dock_for_build.clone(),
path.clone(),
nip34.clone(),
window,
cx,
)
})
},
window,
cx,
);
}
fn open_panel(&mut self, id: &str, window: &mut Window, cx: &mut Context<Self>) {
@@ -231,15 +414,16 @@ impl SidebarView {
}
}
fn render_user(&self, profile: &Profile, cx: &App) -> impl IntoElement {
fn render_user(&self, profile: &Profile, scanning: bool, cx: &App) -> impl IntoElement {
let name = profile.name();
let picture = profile.picture();
let public_key = profile.public_key();
let weak = self.weak.clone();
let weak_for_open = self.weak.clone();
let muted = cx.theme().colors.muted_foreground;
let dock = self.dock.clone();
Menu::new("user-menu")
let menu = Menu::new("user-menu")
.open(self.user_menu_open)
.on_change({
let weak = weak_for_open.clone();
@@ -302,7 +486,36 @@ impl SidebarView {
.destructive(true)
.handler(|_, cx| dialogs::exit::open(cx)),
]
})
});
h_flex().w_full().justify_between().child(menu).child(
h_flex()
.gap_1()
.child(
Button::new("rescan-repos")
.icon(Icon::named(IconRole::Loader))
.variant(ButtonVariant::Ghost)
.loading(scanning)
.disabled(scanning)
.tooltip(Tooltip::text("Rescan for local repositories"))
.on_click(move |_, _, cx| {
let local_repos = LocalReposStore::global(cx);
local_repos.update(cx, |store, cx| store.rescan(cx));
}),
)
.child(
Button::new("add-repo")
.icon(Icon::named(IconRole::Plus))
.variant(ButtonVariant::Ghost)
.tooltip(Tooltip::text("Create repository"))
.on_click({
let dock = dock.clone();
move |_, window, cx| {
dialogs::create_repo::open(dock.clone(), window, cx);
}
}),
),
)
}
/// Shown while no identity is signed in.
@@ -367,3 +580,37 @@ fn pick_banner() -> SharedString {
+ 1;
format!("backgrounds/banner{num}.jpg").into()
}
/// The platform that bound a repository, `"nak"` or `"ngit"`.
fn local_platform(binding: &Nip34Binding) -> Option<&'static str> {
let signals = &binding.signals;
if signals.nip34_json || signals.nip34_grasp_remote || signals.nip34_state_refs {
Some("nak")
} else if signals.nostr_repo_config {
Some("ngit")
} else {
None
}
}
/// The bound owner's avatar, falling back to the path's pixel avatar when unowned.
fn local_avatar(
entry: &ResolvedLocalRepo,
directory: &gpui::Entity<ProfileDirectory>,
cx: &App,
) -> AnyElement {
let Some(owner) = entry.nip34.as_ref().and_then(|binding| binding.owner) else {
return Avatar::new(entry.path.to_string_lossy())
.small()
.into_any_element();
};
let profile = directory.read(cx).profile(&owner, cx);
Avatar::new(owner)
.name(profile.name())
.picture(profile.picture())
.small()
.into_any_element()
}
+170
View File
@@ -0,0 +1,170 @@
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
pub struct TreeItemSeed {
/// Path of the node, relative to the worktree root.
pub id: String,
pub label: String,
pub children: Vec<TreeItemSeed>,
}
/// One visible row of the flattened tree.
#[derive(Clone)]
pub struct TreeRow {
pub id: String,
pub label: String,
pub depth: usize,
pub expandable: bool,
pub expanded: bool,
}
/// Flattens the seeds into the rows a flat tree renders, honoring `expanded`.
pub fn flatten(seeds: &[TreeItemSeed], expanded: &HashSet<String>) -> Vec<TreeRow> {
fn push(
seed: &TreeItemSeed,
depth: usize,
expanded: &HashSet<String>,
rows: &mut Vec<TreeRow>,
) {
let is_expanded = expanded.contains(&seed.id);
rows.push(TreeRow {
id: seed.id.clone(),
label: seed.label.clone(),
depth,
expandable: !seed.children.is_empty(),
expanded: is_expanded,
});
if is_expanded {
for child in &seed.children {
push(child, depth + 1, expanded, rows);
}
}
}
let mut rows = Vec::new();
for seed in seeds {
push(seed, 0, expanded, &mut rows);
}
rows
}
/// Build nested tree items from a flat entry list sorted dirs-first.
pub fn build_tree_items(entries: &[PathBuf]) -> Vec<TreeItemSeed> {
// Node indices by full path, so parents resolve in constant time while inserting.
let mut index: HashMap<String, usize> = HashMap::new();
let mut nodes: Vec<(String, String, Vec<usize>)> = Vec::new();
let mut roots: Vec<usize> = Vec::new();
for entry in entries {
let mut parent: Option<usize> = None;
let mut path = String::new();
for part in entry.components() {
let label = part.as_os_str().to_string_lossy().into_owned();
path = if path.is_empty() {
label.clone()
} else {
format!("{path}/{label}")
};
let ix = *index.entry(path.clone()).or_insert_with(|| {
let ix = nodes.len();
nodes.push((path.clone(), label.clone(), Vec::new()));
match parent {
Some(parent) => nodes[parent].2.push(ix),
None => roots.push(ix),
}
ix
});
parent = Some(ix);
}
}
fn assemble(ix: usize, nodes: &[(String, String, Vec<usize>)]) -> TreeItemSeed {
let (id, label, children) = &nodes[ix];
TreeItemSeed {
id: id.clone(),
label: label.clone(),
children: children
.iter()
.map(|child| assemble(*child, nodes))
.collect(),
}
}
roots.iter().map(|root| assemble(*root, &nodes)).collect()
}
/// Sorted relative paths of a worktree snapshot.
pub fn sorted_worktree_paths(entries: &[PathBuf]) -> Vec<String> {
let mut paths: Vec<String> = entries
.iter()
.map(|path| path.to_string_lossy().into_owned())
.collect();
paths.sort();
paths
}
#[cfg(test)]
mod tests {
use super::*;
fn ids(rows: &[TreeRow]) -> Vec<(&str, usize)> {
rows.iter()
.map(|row| (row.id.as_str(), row.depth))
.collect()
}
#[test]
fn collapsed_folders_hide_children() {
let seeds = build_tree_items(&[
PathBuf::from("src"),
PathBuf::from("src/lib.rs"),
PathBuf::from("README.md"),
]);
let rows = flatten(&seeds, &HashSet::new());
assert_eq!(ids(&rows), vec![("src", 0), ("README.md", 0)]);
assert!(rows[0].expandable);
assert!(!rows[0].expanded);
}
#[test]
fn expanding_a_folder_shows_children_in_order() {
let seeds = build_tree_items(&[
PathBuf::from("src"),
PathBuf::from("src/lib.rs"),
PathBuf::from("src/views/mod.rs"),
PathBuf::from("README.md"),
]);
let expanded = HashSet::from(["src".to_owned(), "src/views".to_owned()]);
let rows = flatten(&seeds, &expanded);
assert_eq!(
ids(&rows),
vec![
("src", 0),
("src/lib.rs", 1),
("src/views", 1),
("src/views/mod.rs", 2),
("README.md", 0),
]
);
}
#[test]
fn indices_map_to_row_ids() {
let seeds = build_tree_items(&[
PathBuf::from("docs"),
PathBuf::from("docs/guide.md"),
PathBuf::from("README.md"),
]);
let expanded = HashSet::from(["docs".to_owned()]);
let rows = flatten(&seeds, &expanded);
assert_eq!(rows[0].id, "docs");
assert_eq!(rows[1].id, "docs/guide.md");
assert_eq!(rows[2].id, "README.md");
}
}