feat: out-of-box experience (#2)

Reviewed-on: https://git.reya.su/reya/signed/pulls/2
This commit was merged in pull request #2.
This commit is contained in:
2026-08-25 13:23:07 +00:00
parent 7249a323f8
commit dacfd49cdf
180 changed files with 16763 additions and 1042 deletions
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "workspace"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
assets = { path = "../assets" }
dock = { workspace = true }
signed_core = { path = "../signed_core" }
signed_git = { path = "../signed_git" }
signed_state = { path = "../signed_state" }
utils = { path = "../utils" }
gpui.workspace = true
gpui-component.workspace = true
gpui-base.workspace = true
gix.workspace = true
nostr.workspace = true
anyhow.workspace = true
futures.workspace = true
log.workspace = true
+139
View File
@@ -0,0 +1,139 @@
use std::collections::{HashMap, VecDeque};
use std::mem::take;
use futures::FutureExt;
use gpui::{
App, AppContext, Asset, AssetLogger, ElementId, Entity, ImageAssetLoader, ImageCache,
ImageCacheItem, ImageCacheProvider, ImageSource, Resource, hash,
};
/// Default number of images each view's cache retains. Loading a new image
/// evicts the least recently used entry once this is reached.
pub const MAX_IMAGES: usize = 128;
pub fn image_cache(id: impl Into<ElementId>, max_items: usize) -> AppImageCacheProvider {
AppImageCacheProvider {
id: id.into(),
max_items,
}
}
pub struct AppImageCacheProvider {
id: ElementId,
max_items: usize,
}
impl ImageCacheProvider for AppImageCacheProvider {
fn provide(&mut self, window: &mut gpui::Window, cx: &mut App) -> gpui::AnyImageCache {
window
.with_global_id(self.id.clone(), |id, window| {
window.with_element_state(id, |cache, _| {
let cache = cache.unwrap_or_else(|| AppImageCache::new(self.max_items, cx));
(cache.clone(), cache)
})
})
.into()
}
}
pub struct AppImageCache {
max_items: usize,
usage_list: VecDeque<u64>,
cache: HashMap<u64, (ImageCacheItem, Resource)>,
}
impl AppImageCache {
pub fn new(max_items: usize, cx: &mut App) -> Entity<Self> {
cx.new(|cx| {
log::info!("Creating AppImageCacheProvider");
cx.on_release(|this: &mut Self, cx| {
for (ix, (mut image, resource)) in take(&mut this.cache) {
if let Some(Ok(image)) = image.get() {
log::info!("Dropping image {ix}");
cx.drop_image(image, None);
}
ImageSource::Resource(resource).remove_asset(cx);
}
})
.detach();
AppImageCache {
max_items,
usage_list: VecDeque::with_capacity(max_items),
cache: HashMap::with_capacity(max_items),
}
})
}
}
impl ImageCache for AppImageCache {
fn load(
&mut self,
resource: &Resource,
window: &mut gpui::Window,
cx: &mut gpui::App,
) -> Option<Result<std::sync::Arc<gpui::RenderImage>, gpui::ImageCacheError>> {
let hash = hash(resource);
if let Some(item) = self.cache.get_mut(&hash) {
let current_idx = self
.usage_list
.iter()
.position(|item| *item == hash)
.expect("cache has an item usage_list doesn't");
self.usage_list.remove(current_idx);
self.usage_list.push_front(hash);
return item.0.get();
}
let load_future = AssetLogger::<ImageAssetLoader>::load(resource.clone(), cx);
let task = cx.background_executor().spawn(load_future).shared();
if self.usage_list.len() >= self.max_items {
log::info!("Image cache is full, evicting oldest item");
if let Some(oldest) = self.usage_list.pop_back() {
let mut image = self
.cache
.remove(&oldest)
.expect("usage_list has an item cache doesn't");
if let Some(Ok(image)) = image.0.get() {
log::info!("requesting image to be dropped");
cx.drop_image(image, Some(window));
}
ImageSource::Resource(image.1).remove_asset(cx);
}
}
self.cache.insert(
hash,
(
gpui::ImageCacheItem::Loading(task.clone()),
resource.clone(),
),
);
self.usage_list.push_front(hash);
let entity = window.current_view();
window
.spawn(cx, async move |cx| {
let result = task.await;
if let Err(err) = result {
log::error!("error loading image into cache: {:?}", err);
}
cx.on_next_frame(move |_, cx| {
cx.notify(entity);
});
})
.detach();
None
}
}
+16
View File
@@ -0,0 +1,16 @@
mod views;
mod workspace;
pub mod image_cache;
use gpui::{App, AppContext, Entity, Window};
use gpui_component::Root;
pub use views::{RepoListView, SidebarPanel};
pub use workspace::Workspace;
/// Build the root view tree. Requires `signed_state::init` and
/// `gpui_component::init` to have been called first.
pub fn root(window: &mut Window, cx: &mut App) -> Entity<Root> {
let view = cx.new(|cx| Workspace::new(window, cx));
cx.new(|cx| Root::new(view, window, cx))
}
+7
View File
@@ -0,0 +1,7 @@
mod repo_detail;
mod repo_list;
pub(crate) mod sidebar;
pub use repo_detail::RepoDetailView;
pub use repo_list::RepoListView;
pub use sidebar::SidebarPanel;
@@ -0,0 +1,312 @@
//! File explorer of the repository detail view: the file tree column and the
//! content column (README / file preview), backed by persistent
//! [`TextViewState`]s for markdown documents and persistent [`InputState`]s
//! for code files.
use gpui::prelude::*;
use gpui::{AnyElement, Context, Entity, SharedString, WeakEntity, Window, div, px};
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::input::{Editor, EditorState};
use gpui_component::list::ListItem;
use gpui_component::spinner::Spinner;
use gpui_component::text::{TextView, TextViewState};
use gpui_component::tree::{TreeEntry, TreeState, tree};
use gpui_component::{ActiveTheme, Sizable, StyledExt, h_flex, v_flex};
use super::RepoDetailView;
use super::helpers::{code_language, is_markdown_path, placeholder, tree_row};
/// Width of the file explorer column.
const TREE_WIDTH: f32 = 240.;
/// Files larger than this are not previewed.
pub(super) const MAX_PREVIEW_BYTES: usize = 1024 * 1024;
/// Preview cache caps: at most this many files (or this many text bytes)
/// are kept in memory at once; the oldest previews are evicted beyond that.
pub(super) const MAX_PREVIEWED_FILES: usize = 32;
pub(super) const MAX_PREVIEW_CACHE_BYTES: usize = 8 * 1024 * 1024;
/// Preview state of a browsed file.
pub(super) enum FileContent {
/// Decodable text content.
Text(String),
/// Not valid UTF-8.
Binary,
/// Bigger than [`MAX_PREVIEW_BYTES`].
TooLarge,
/// Reading failed.
Failed(String),
}
/// A markdown document loaded into a persistent [`TextViewState`].
///
/// The state is owned by the view rather than created per render (as the
/// stateless `text::markdown` helper does), so it survives branch switches
/// in the content pane. GPUI's keyed element state is dropped as soon as the
/// element is absent for a single frame, which would otherwise re-parse the
/// whole document on the main thread every time the pane switches between
/// the README, a file preview, and the loading spinner.
pub(super) struct MarkdownView {
/// Source path; `None` means the repository README.
pub(super) path: Option<SharedString>,
pub(super) state: Entity<TextViewState>,
}
/// A code file loaded into a persistent [`InputState`], rendered as a
/// disabled (read-only) code editor with syntax highlighting, line numbers
/// and search.
///
/// Same persistence rationale as [`MarkdownView`]: the state lives as long
/// as this view, so re-viewing the same file does not re-parse it, and
/// parsing happens on a background task inside the editor.
pub(super) struct CodeView {
/// Source path, relative to the worktree root.
pub(super) path: SharedString,
pub(super) state: Entity<EditorState>,
}
/// Spinner shown while a document is being loaded/parsed.
fn preview_spinner() -> AnyElement {
v_flex()
.size_full()
.items_center()
.justify_center()
.child(Spinner::new().small())
.into_any_element()
}
impl RepoDetailView {
/// One row of the file tree: icon + name, indented by depth.
fn render_tree_item(
ix: usize,
entry: &TreeEntry,
selected: bool,
view: &WeakEntity<Self>,
) -> ListItem {
let view = view.clone();
let id = entry.item().id.clone();
tree_row(ix, entry, selected, move |window, cx| {
if let Some(view) = view.upgrade() {
view.update(cx, |this, cx| this.open_file(&id, window, cx));
}
})
}
/// Left column: the file tree.
pub(super) fn render_tree_column(
tree_state: Entity<TreeState>,
view: WeakEntity<Self>,
cx: &mut Context<Self>,
) -> impl IntoElement {
v_flex()
.h_full()
.w(px(TREE_WIDTH))
.p_2()
.flex_none()
.border_r_1()
.border_color(cx.theme().border)
.child(div().flex_1().min_h_0().child(tree(
&tree_state,
move |ix, entry, selected, _window, _cx| {
Self::render_tree_item(ix, entry, selected, &view)
},
)))
}
/// Right column: README, selected file preview, or status text.
pub(super) fn render_content_column(
&self,
pane_title: SharedString,
cx: &mut Context<Self>,
) -> impl IntoElement {
let loading = self.loading;
let error = self.error.clone();
let selected_file = self.selected_file.clone();
let body: AnyElement = if loading {
v_flex()
.size_full()
.items_center()
.justify_center()
.gap_2()
.child(Spinner::new().small())
.child(
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child("Cloning repository…"),
)
.into_any_element()
} else if let Some(error) = error {
v_flex()
.size_full()
.items_center()
.justify_center()
.p_4()
.child(
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child(error),
)
.into_any_element()
} else if let Some(path) = selected_file {
match self.files.get(path.as_ref()) {
Some(FileContent::Text(_)) => {
if is_markdown_path(path.as_ref()) {
self.markdown_element(Some(path.as_ref()), cx)
} else {
self.code_element(path.as_ref(), cx)
}
}
Some(FileContent::Binary) => placeholder("Binary file — preview not supported", cx),
Some(FileContent::TooLarge) => placeholder("File is too large to preview", cx),
Some(FileContent::Failed(message)) => placeholder(message, cx),
None => preview_spinner(),
}
} else if self.readme_name.is_some() {
self.markdown_element(None, cx)
} else {
placeholder("No README found", cx)
};
// Latest commit for the current pane: the selected file, or the README
// while nothing is selected. Computed after the body above, which
// needs `&mut self`.
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().muted)
.border_b(px(1.))
.border_color(cx.theme().border)
.text_color(cx.theme().muted_foreground)
.child(
div()
.text_xs()
.font_semibold()
.text_ellipsis()
.whitespace_nowrap()
.child(pane_title),
)
.when_some(commit, |this, commit| {
this.child(
h_flex()
.flex_1()
.gap_1()
.child(
Button::new("commit")
.xsmall()
.text()
.label(commit.id.clone()),
)
.child(
div()
.max_w(px(250.))
.text_xs()
.text_ellipsis()
.whitespace_nowrap()
.child(commit.summary.clone()),
),
)
}),
)
.child(div().id("repo-content").flex_1().min_h_0().child(body))
}
/// Load `text` into the persistent markdown TextView state.
///
/// The state is created empty and fed via `push_str`, which parses on a
/// background task: switching files never blocks the main thread, and
/// the state lives as long as this view, so re-viewing the same document
/// does not re-parse it.
pub(super) fn set_markdown(
&mut self,
path: Option<SharedString>,
text: &str,
cx: &mut Context<Self>,
) {
let state = cx.new(|cx| TextViewState::markdown("", cx));
state.update(cx, |state, cx| state.push_str(text, cx));
self.md = Some(MarkdownView { path, state });
}
/// The persistent markdown TextView for `path` (`None` = README), or a
/// spinner while the document is being loaded/parsed.
fn markdown_element(&self, path: Option<&str>, _cx: &mut Context<Self>) -> 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();
}
TextView::new(&md.state)
.selectable(true)
.scrollable(true)
.p_4()
.text_sm()
.into_any_element()
}
/// Load `text` into the persistent code editor state for `path`.
///
/// The state is created in code editor mode so the Input renders it as
/// a syntax-highlighted, read-only editor. Like [`set_markdown`], the
/// state lives as long as this view, so re-viewing the same file does
/// not re-parse it; the tree-sitter parse runs on a background task
/// inside the editor instead of blocking the main thread.
pub(super) fn set_code(
&mut self,
path: SharedString,
text: &str,
window: &mut Window,
cx: &mut Context<Self>,
) {
let language = code_language(path.as_ref()).unwrap_or("text");
let state = cx.new(|cx| {
EditorState::new(window, cx)
.language(language)
.default_value(text)
.line_number(true)
.folding(true)
});
self.code = Some(CodeView { path, state });
}
/// The persistent code editor for `path`, or a spinner while the file is
/// being loaded/parsed.
fn code_element(&self, path: &str, _cx: &mut Context<Self>) -> AnyElement {
let Some(code) = &self.code else {
return preview_spinner();
};
if code.path.as_ref() != path {
return preview_spinner();
}
Editor::new(&code.state)
.readonly(true)
.bordered(false)
.rounded_none()
.h_full()
.text_sm()
.into_any_element()
}
}
@@ -0,0 +1,164 @@
//! Commits tab of the repository detail view: a virtual list of all
//! commits reachable from HEAD, newest first, with the total count shown
//! as a badge on the tab.
use gpui::prelude::*;
use gpui::{AnyElement, App, Context, WeakEntity, div, px};
use gpui_component::scroll::Scrollbar;
use gpui_component::spinner::Spinner;
use gpui_component::{ActiveTheme, Sizable, h_flex, v_flex, v_virtual_list};
use signed_git::FileCommit;
use utils::relative_time_secs;
use super::RepoDetailView;
use super::helpers::placeholder;
/// Height of one commit row in the virtual list.
pub(super) const COMMIT_ROW_HEIGHT: f32 = 56.;
/// One row of the commit list: id, summary, author and relative time.
/// Clicking a row opens the diff of that commit in a new panel.
fn commit_row(
ix: usize,
commit: &FileCommit,
view: &WeakEntity<RepoDetailView>,
cx: &App,
) -> AnyElement {
let view = view.clone();
// Only the id is needed by the click handler: the diff panel fetches
// the full commit itself.
let id = commit.id.clone();
h_flex()
.id(ix)
.px_4()
.h(px(COMMIT_ROW_HEIGHT))
.w_full()
.gap_3()
.items_center()
.border_b(px(1.))
.border_color(cx.theme().border)
.hover(|this| this.bg(cx.theme().list_hover))
.child(
v_flex()
.flex_1()
.min_w_0()
.gap_0p5()
.justify_center()
.child(
h_flex()
.gap_2()
.items_center()
.overflow_hidden()
.child(
div()
.font_family(cx.theme().mono_font_family.clone())
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(commit.id.clone()),
)
.child(
div()
.flex_1()
.min_w_0()
.text_sm()
.text_ellipsis()
.whitespace_nowrap()
.child(commit.summary.clone()),
),
)
.child(
h_flex()
.gap_2()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(commit.author.clone())
.child(relative_time_secs(commit.time)),
),
)
.on_click(move |_event, window, cx| {
if let Some(view) = view.upgrade() {
view.update(cx, |this, cx| this.open_commit_diff(&id, window, cx));
}
})
.into_any_element()
}
impl RepoDetailView {
/// Full-height body of the Commits tab: all commits in a virtual
/// list, or a status message while loading / when there are none.
pub(super) fn render_commits_tab(&self, cx: &mut Context<Self>) -> AnyElement {
let Some(list) = self.all_commits.as_ref() else {
return if self.loading_all_commits {
v_flex()
.size_full()
.items_center()
.justify_center()
.child(Spinner::new().small())
.into_any_element()
} else {
placeholder("Failed to load commits", cx)
};
};
if list.commits.is_empty() {
return placeholder("No commits found", cx);
}
// Copy only the values the element tree needs; the list itself is
// borrowed inside the renderer below instead of being cloned per
// frame (a full history can be tens of thousands of commits).
let view = cx.entity().clone();
let sizes = self.item_sizes.clone();
let scroll_handle = self.scroll_handle.clone();
let shown = list.commits.len();
let total = list.total;
v_flex()
.relative()
.flex_1()
.w_full()
.min_h_0()
.child(
v_virtual_list(
view,
"repo-commits",
sizes,
move |this, range, _window, cx| {
let commits = this
.all_commits
.as_ref()
.map(|list| list.commits.as_slice())
.unwrap_or(&[]);
let view = cx.entity().downgrade();
range
.map(|ix| commit_row(ix, &commits[ix], &view, cx))
.collect()
},
)
.track_scroll(&scroll_handle)
.size_full(),
)
.when(shown < total, |this| {
// The history is capped; tell the user the list is truncated.
this.child(
div()
.py_2()
.w_full()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(format!("Showing {shown} of {total} commits")),
)
})
.child(
div()
.absolute()
.top_0()
.left_0()
.right_0()
.bottom_0()
.child(Scrollbar::vertical(&self.scroll_handle)),
)
.into_any_element()
}
}
@@ -0,0 +1,504 @@
use std::path::PathBuf;
use std::rc::Rc;
use dock::{BasePanel, Panel, PanelEvent};
use gpui::prelude::*;
use gpui::{
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
ScrollStrategy, SharedString, Size, WeakEntity, Window, div, px, size,
};
use gpui_component::clipboard::Clipboard;
use gpui_component::list::ListItem;
use gpui_component::resizable::{resizable_panel, v_resizable};
use gpui_component::scroll::{ScrollableElement, Scrollbar};
use gpui_component::spinner::Spinner;
use gpui_component::tag::Tag;
use gpui_component::tree::{TreeEntry, TreeState, tree};
use gpui_component::{
ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list,
};
use signed_git::{CommitDiff, DiffStatus, FileCommit, FileDiff};
use utils::relative_time_secs;
use super::helpers::{
DIFF_ROW_HEIGHT, DiffRow, build_tree_items, diff_rows, find_item, placeholder, render_diff_row,
tree_items, tree_row,
};
/// Width of the changed-files column.
const TREE_WIDTH: f32 = 260.;
/// Detail panel showing the diff of one commit.
pub struct CommitDiffView {
focus_handle: FocusHandle,
/// Local clone the commit lives in.
worktree: PathBuf,
/// Display name of the repository the commit belongs to.
repo_name: SharedString,
/// The commit being shown (header and tab title). Starts as an id-only
/// stub; [`Self::load`] replaces it with the full metadata, which the
/// history list intentionally omits.
commit: FileCommit,
/// Loaded diff; `None` while loading or after a failure.
diff: Option<CommitDiff>,
/// The diff is being computed on a background task.
loading: bool,
error: Option<SharedString>,
/// Changed-files explorer state.
tree_state: Entity<TreeState>,
/// Path of the file whose diff is shown in the detail column.
selected_file: Option<SharedString>,
/// Rows of the selected file's diff (hunk headers + lines), backing the
/// virtual list in the detail column.
rows: Vec<DiffRow>,
/// Per-row heights of [`Self::rows`].
item_sizes: Rc<Vec<Size<Pixels>>>,
/// Virtual list state of the diff rows.
scroll_handle: VirtualListScrollHandle,
/// In-flight tasks; pruned on every push (see [`helpers::track`]).
tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
}
impl CommitDiffView {
pub fn new(
worktree: PathBuf,
repo_name: SharedString,
commit_id: String,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let tree_state = cx.new(|cx| TreeState::new(cx));
// Defer until the window is ready, like the repository detail view.
cx.defer_in(window, |this, window, cx| {
this.load(window, cx);
});
Self {
focus_handle: cx.focus_handle(),
worktree,
repo_name,
commit: FileCommit {
id: commit_id,
summary: String::new(),
description: None,
author: String::new(),
time: 0,
},
diff: None,
loading: true,
error: None,
tree_state,
selected_file: None,
rows: Vec::new(),
item_sizes: Rc::new(Vec::new()),
scroll_handle: VirtualListScrollHandle::new(),
tasks: Vec::new(),
}
}
/// Load the commit diff (and the full commit metadata) on a background
/// task and populate the tree.
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.loading = true;
self.error = None;
cx.notify();
let worktree = self.worktree.clone();
let id = self.commit.id.clone();
let task = cx.spawn_in(window, async move |this, cx| {
let commit = cx
.background_spawn({
let worktree = worktree.clone();
let id = id.clone();
async move { signed_git::worktree_commit(&worktree, &id) }
})
.await;
let diff = cx
.background_spawn({
let worktree = worktree.clone();
let id = id.clone();
async move { signed_git::worktree_commit_diff(&worktree, &id) }
})
.await;
this.update_in(cx, |this, _window, cx| {
this.loading = false;
if let Ok(Some(commit)) = commit {
this.commit = commit;
}
match diff {
Ok(diff) => {
let mut paths: Vec<PathBuf> = diff
.files
.iter()
.map(|file| PathBuf::from(&file.path))
.collect();
paths.sort();
let items = tree_items(build_tree_items(&paths), true);
let first = diff
.files
.first()
.map(|file| SharedString::from(file.path.as_str()));
this.tree_state.update(cx, |state, cx| {
state.set_items(items.clone(), cx);
let item = find_item(&items, first.as_deref());
state.set_selected_item(item, cx);
});
this.selected_file = first.clone();
this.diff = Some(diff);
if let Some(path) = first {
this.set_diff_rows(path.as_ref());
}
}
Err(error) => {
this.error = Some(error.to_string().into());
}
}
cx.notify();
})?;
Ok(())
});
self.tasks.push(task);
}
/// Show the diff of the file at `path` (selected in the tree).
fn select_file(&mut self, path: &str, cx: &mut Context<Self>) {
self.selected_file = Some(path.into());
self.set_diff_rows(path);
cx.notify();
}
/// Rebuild the virtual list state for the file at `path` and scroll back
/// to the top.
fn set_diff_rows(&mut self, path: &str) {
let Some(diff) = self.diff.as_ref() else {
return;
};
let Some(file) = diff.files.iter().find(|file| file.path == path) else {
return;
};
self.rows = diff_rows(file);
self.item_sizes = Rc::new(vec![size(px(0.), px(DIFF_ROW_HEIGHT)); self.rows.len()]);
self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top);
}
/// One row of the changed-files tree: icon + name, indented by depth.
fn render_tree_item(
ix: usize,
entry: &TreeEntry,
selected: bool,
view: &WeakEntity<Self>,
) -> ListItem {
let view = view.clone();
let id = entry.item().id.clone();
tree_row(ix, entry, selected, move |_window, cx| {
if let Some(view) = view.upgrade() {
view.update(cx, |this, cx| this.select_file(&id, cx));
}
})
}
/// Left column: the changed-files tree.
fn render_tree_column(&self, cx: &mut Context<Self>) -> AnyElement {
let tree_state = self.tree_state.clone();
let view = cx.entity().downgrade();
v_flex()
.h_full()
.w(px(TREE_WIDTH))
.flex_none()
.border_r_1()
.border_color(cx.theme().border)
.child(
div()
.flex_1()
.min_h_0()
.when(self.diff.is_some(), |this| {
this.child(
tree(&tree_state, move |ix, entry, selected, _window, _cx| {
Self::render_tree_item(ix, entry, selected, &view)
})
.p_2(),
)
})
.when(self.diff.is_none() && !self.loading, |this| {
this.child(placeholder("Failed to load diff", cx))
}),
)
.into_any_element()
}
/// Right column: header of the selected file plus its diff.
fn render_detail_column(&self, cx: &mut Context<Self>) -> AnyElement {
if self.loading {
return v_flex()
.size_full()
.items_center()
.justify_center()
.child(Spinner::new().small())
.into_any_element();
}
if let Some(error) = self.error.clone() {
return placeholder(&error, cx);
}
let Some(diff) = self.diff.as_ref() else {
return placeholder("Failed to load diff", cx);
};
let Some(path) = self.selected_file.clone() else {
return if diff.files.is_empty() {
placeholder("No files changed in this commit", cx)
} else {
placeholder("Select a file", cx)
};
};
let Some(file) = diff.files.iter().find(|file| file.path == path.as_ref()) else {
return placeholder("File not found", cx);
};
self.render_file_diff(file, cx.entity(), cx)
}
/// The diff of one file: a header with status and stats, then the hunks
/// in a virtual list (a large diff is never materialized per frame).
fn render_file_diff(&self, file: &FileDiff, view: Entity<Self>, cx: &App) -> AnyElement {
let status_label = match file.status {
DiffStatus::Added => "A",
DiffStatus::Modified => "M",
DiffStatus::Deleted => "D",
DiffStatus::Renamed => "R",
DiffStatus::Copied => "C",
};
let status_color = match file.status {
DiffStatus::Added => cx.theme().success,
DiffStatus::Modified => cx.theme().info,
DiffStatus::Deleted => cx.theme().danger,
DiffStatus::Renamed | DiffStatus::Copied => cx.theme().muted_foreground,
};
let title = match &file.old_path {
Some(old) => format!("{old}{}", file.path),
None => file.path.clone(),
};
let body: AnyElement = if file.binary {
placeholder("Diff not available", cx)
} else if file.hunks.is_empty() {
placeholder("No content changes", cx)
} else {
let sizes = self.item_sizes.clone();
let scroll_handle = self.scroll_handle.clone();
v_flex()
.size_full()
.relative()
.child(
v_virtual_list(
view,
"commit-diff-rows",
sizes,
move |this, range, _window, cx| {
let Some(diff) = this.diff.as_ref() else {
return Vec::new();
};
let Some(path) = this.selected_file.as_deref() else {
return Vec::new();
};
let Some(file) = diff.files.iter().find(|file| file.path == path)
else {
return Vec::new();
};
range
.map(|ix| render_diff_row(&file.hunks, this.rows[ix], cx))
.collect()
},
)
.track_scroll(&scroll_handle)
.size_full(),
)
.child(
div()
.absolute()
.top_0()
.left_0()
.right_0()
.bottom_0()
.child(Scrollbar::vertical(&scroll_handle)),
)
.into_any_element()
};
v_flex()
.flex_1()
.min_w_0()
.h_full()
.child(
h_flex()
.px_3()
.h_9()
.gap_2()
.items_center()
.child(
div()
.text_xs()
.font_semibold()
.text_color(status_color)
.child(status_label),
)
.child(
div()
.flex_1()
.min_w_0()
.text_xs()
.font_semibold()
.text_ellipsis()
.whitespace_nowrap()
.child(title),
)
.when(!file.binary, |this| {
this.child(
h_flex()
.gap_2()
.text_xs()
.child(
div()
.text_color(cx.theme().success)
.child(format!("+{}", file.insertions)),
)
.child(
div()
.text_color(cx.theme().danger)
.child(format!("-{}", file.deletions)),
),
)
}),
)
.child(div().id("commit-diff-body").flex_1().min_h_0().child(body))
.into_any_element()
}
/// Header: commit id, summary, author/time and overall change stats.
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
let commit = &self.commit;
let (files, insertions, deletions) = self.diff.as_ref().map_or((0, 0, 0), |diff| {
(
diff.files.len(),
diff.files.iter().map(|file| file.insertions).sum(),
diff.files.iter().map(|file| file.deletions).sum(),
)
});
v_flex()
.px_4()
.pb_4()
.w_full()
.gap_4()
.child(
v_flex()
.child(
div()
.font_semibold()
.text_ellipsis()
.whitespace_nowrap()
.child(commit.summary.clone()),
)
.child(
h_flex()
.gap_2()
.text_sm()
.child(h_flex().child(format!("{} committed", commit.author)))
.child(
h_flex()
.gap_0p5()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(&commit.id))
.child(Clipboard::new("commit").value(&commit.id)),
)
.child(
div()
.text_color(cx.theme().muted_foreground)
.child(relative_time_secs(commit.time)),
),
),
)
.when_some(commit.description.as_ref(), |this, description| {
this.child(div().text_sm().child(SharedString::from(description)))
})
.child(
h_flex()
.gap_2()
.text_xs()
.child(
Tag::primary()
.small()
.child(format!("{files} files changed")),
)
.when(insertions > 0, |this| {
this.child(
Tag::success()
.outline()
.small()
.child(format!("+ {insertions}")),
)
})
.when(deletions > 0, |this| {
this.child(
Tag::danger()
.outline()
.small()
.child(format!("- {deletions}")),
)
}),
)
.overflow_y_scrollbar()
.into_any_element()
}
}
impl BasePanel for CommitDiffView {
fn panel_name(&self) -> &'static str {
"commit_diff"
}
}
impl Panel for CommitDiffView {
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().text_sm().child(SharedString::from(format!(
"{}/{}",
self.repo_name, self.commit.id
)))
}
}
impl EventEmitter<PanelEvent> for CommitDiffView {}
impl Focusable for CommitDiffView {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for CommitDiffView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_resizable("commit-diff")
.child(
resizable_panel()
.size(px(180.))
.size_range(px(120.)..px(420.))
.flex_none()
.bg(cx.theme().background)
.child(self.render_header(cx)),
)
.child(
resizable_panel().child(
h_flex()
.size_full()
.min_h_0()
.bg(cx.theme().background)
.child(self.render_tree_column(cx))
.child(self.render_detail_column(cx)),
),
)
}
}
@@ -0,0 +1,485 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use assets::CustomIconName;
use gpui::prelude::*;
use gpui::{AnyElement, App, SharedString, Window, div, px};
use gpui_component::list::ListItem;
use gpui_component::tooltip::Tooltip;
use gpui_component::tree::{TreeEntry, TreeItem};
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex, v_flex};
use signed_core::RepoStatus;
use signed_git::{DiffHunk, DiffLine, DiffLineKind, FileDiff};
/// A `Send` file-tree node: the tree is built on a background thread and
/// converted into [`TreeItem`]s (which hold `Rc` state, so they cannot
/// cross threads) on the main thread.
pub(super) struct TreeItemSeed {
/// Path of the node, relative to the worktree root.
id: String,
/// File or directory name.
label: String,
children: Vec<TreeItemSeed>,
}
/// Convert tree seeds into [`TreeItem`]s, expanding every folder when
/// `expand_folders` is set.
///
/// The commit diff explorer shows only changed files, which is typically a
/// handful of paths, so its folders start expanded; the worktree explorer
/// starts collapsed instead.
pub(super) fn tree_items(seeds: Vec<TreeItemSeed>, expand_folders: bool) -> Vec<TreeItem> {
fn convert(seed: TreeItemSeed, expand_folders: bool) -> TreeItem {
let mut item = TreeItem::new(seed.id, seed.label);
if expand_folders && !seed.children.is_empty() {
item = item.expanded(true);
}
item.children = seed
.children
.into_iter()
.map(|seed| convert(seed, expand_folders))
.collect();
item
}
seeds
.into_iter()
.map(|seed| convert(seed, expand_folders))
.collect()
}
/// One row of a file tree: icon + name, indented by depth. Clicking a file
/// runs `on_click`; folders expand/collapse via the tree itself.
pub(super) fn tree_row<F>(ix: usize, entry: &TreeEntry, selected: bool, on_click: F) -> ListItem
where
F: Fn(&mut Window, &mut App) + 'static,
{
let item = entry.item();
let is_folder = entry.is_folder();
let icon = if is_folder {
if entry.is_expanded() {
IconName::FolderOpen
} else {
IconName::FolderClosed
}
} else {
IconName::File
};
ListItem::new(ix)
.pl(px(8.) + px(14.) * entry.depth() as f32)
.selected(selected)
.child(
h_flex()
.gap_2()
.overflow_hidden()
.child(Icon::new(icon).small())
.child(div().text_sm().text_ellipsis().child(item.label.clone())),
)
.on_click(move |_event, window, cx| {
// Folders expand/collapse via the tree itself.
if is_folder {
return;
}
on_click(window, cx);
})
}
/// Build nested tree items from a flat, sorted (dirs-first) entry list.
///
/// Returns [`TreeItemSeed`]s so the build can run off the main thread; a
/// worktree walk can yield tens of thousands of entries. Nodes live in an
/// arena and parents are found via a path -> index map, which keeps the
/// build linear in the number of path components.
pub(super) fn build_tree_items(entries: &[PathBuf]) -> Vec<TreeItemSeed> {
// Node indices by full path, for O(1) parent lookup 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()
}
/// The markdown fence language for a file path, or `None` for plain text.
///
/// Names are chosen so `gpui_component`'s highlighter can resolve them
/// (`highlighter::Language::from_name` accepts short aliases such as `rs`
/// and `js`).
pub(super) fn code_language(path: &str) -> Option<&'static str> {
let name = Path::new(path)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default();
// Some common files are recognized by name rather than extension.
match name {
"Makefile" | "makefile" => return Some("make"),
"CMakeLists.txt" => return Some("cmake"),
_ => {}
}
let ext = Path::new(path).extension()?.to_str()?.to_ascii_lowercase();
Some(match ext.as_str() {
"rs" => "rust",
"toml" => "toml",
"json" | "jsonc" => "json",
"py" => "python",
"js" | "mjs" | "cjs" => "javascript",
"ts" | "mts" | "cts" => "typescript",
"tsx" | "jsx" => "tsx",
"go" => "go",
"c" | "h" => "c",
"cc" | "cpp" | "cxx" | "hh" | "hpp" | "hxx" => "cpp",
"cs" => "csharp",
"java" => "java",
"kt" | "kts" | "ktm" => "kotlin",
"swift" => "swift",
"php" | "phtml" => "php",
"rb" => "ruby",
"sh" | "bash" | "zsh" => "bash",
"yml" | "yaml" => "yaml",
"css" | "scss" | "sass" => "css",
"html" | "htm" => "html",
"lua" => "lua",
"sql" => "sql",
"proto" | "protobuf" => "proto",
"cmake" => "cmake",
"zig" => "zig",
"ex" | "exs" => "elixir",
"graphql" | "gql" => "graphql",
"diff" | "patch" => "diff",
"svelte" => "svelte",
"astro" => "astro",
"scala" => "scala",
_ => return None,
})
}
/// Whether a file path has a markdown extension.
pub(super) 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"
)
})
}
/// A centered muted placeholder message.
pub(super) fn placeholder(message: &str, cx: &App) -> AnyElement {
v_flex()
.size_full()
.items_center()
.justify_center()
.p_4()
.child(
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child(message.to_string()),
)
.into_any_element()
}
/// The status badge shown next to an issue or pull request: icon + colored
/// square, with a tooltip describing the status.
pub(super) fn status_badge(status: RepoStatus, cx: &App) -> AnyElement {
let (icon, label, tooltip, bg, fg) = match status {
RepoStatus::Open => (
CustomIconName::GitIssueDone,
"open",
"Issue is open",
cx.theme().primary,
cx.theme().primary_foreground,
),
RepoStatus::Closed => (
CustomIconName::GitIssueClosed,
"closed",
"Issue is closed",
cx.theme().danger,
cx.theme().danger_foreground,
),
RepoStatus::Draft => (
CustomIconName::GitIssueOngoing,
"draft",
"Issue is draft",
cx.theme().accent,
cx.theme().accent_foreground,
),
RepoStatus::Applied => (
CustomIconName::GitIssueOpen,
"applied",
"Issue is completed",
cx.theme().secondary,
cx.theme().secondary_foreground,
),
};
v_flex()
.id(label)
.flex_shrink_0()
.size_7()
.items_center()
.justify_center()
.rounded(cx.theme().radius)
.bg(bg)
.child(Icon::new(icon).small().text_color(fg))
.tooltip(move |window, cx| Tooltip::new(tooltip).build(window, cx))
.into_any_element()
}
/// Width of one line-number gutter in a diff row.
pub(super) const GUTTER_WIDTH: f32 = 44.;
/// Height of one row in a virtual diff list.
pub(super) const DIFF_ROW_HEIGHT: f32 = 20.;
/// One row of a virtual diff list: a hunk header, or a line of a hunk.
/// Shared by the commit diff and pull request diff viewers.
#[derive(Clone, Copy)]
pub(super) enum DiffRow {
Hunk {
old_start: u32,
old_lines: u32,
new_start: u32,
new_lines: u32,
},
/// Line `line` of hunk `hunk` of the selected file's diff.
Line { hunk: usize, line: usize },
}
/// The rows of `file`'s diff: one header row per hunk, then its lines.
pub(super) fn diff_rows(file: &FileDiff) -> Vec<DiffRow> {
let mut rows = Vec::new();
for (hunk_ix, hunk) in file.hunks.iter().enumerate() {
rows.push(DiffRow::Hunk {
old_start: hunk.old_start,
old_lines: hunk.old_lines,
new_start: hunk.new_start,
new_lines: hunk.new_lines,
});
rows.extend((0..hunk.lines.len()).map(|line| DiffRow::Line {
hunk: hunk_ix,
line,
}));
}
rows
}
/// One row of the virtual diff list: a hunk header or a single line.
pub(super) fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> AnyElement {
match row {
DiffRow::Hunk {
old_start,
old_lines,
new_start,
new_lines,
} => div()
.px_2()
.w_full()
.h(px(DIFF_ROW_HEIGHT))
.font_family(cx.theme().mono_font_family.clone())
.text_xs()
.bg(cx.theme().muted)
.border_y(px(1.))
.border_color(cx.theme().border)
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(format!(
"@@ -{},{} +{},{} @@",
old_start, old_lines, new_start, new_lines
)))
.into_any_element(),
DiffRow::Line { hunk, line } => render_diff_line(&hunks[hunk].lines[line], cx),
}
}
/// One diff line: old and new line numbers in gutters, then the content,
/// tinted by kind (addition / deletion / context).
pub(super) fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement {
let bg = match line.kind {
DiffLineKind::Addition => Some(cx.theme().success.opacity(0.2)),
DiffLineKind::Deletion => Some(cx.theme().danger.opacity(0.2)),
DiffLineKind::Context => None,
};
let gutter = cx.theme().muted_foreground;
// Fixed height and nowrap: the virtual list assumes every row has
// the same height, so long lines are clipped instead of wrapped.
h_flex()
.w_full()
.h(px(DIFF_ROW_HEIGHT))
.items_center()
.font_family(cx.theme().mono_font_family.clone())
.text_xs()
.when_some(bg, |this, bg| this.bg(bg))
.child(
div()
.w(px(GUTTER_WIDTH))
.flex_none()
.pr_2()
.text_right()
.text_color(gutter)
.child(line.old.map(|n| n.to_string()).unwrap_or_default()),
)
.child(
div()
.w(px(GUTTER_WIDTH))
.flex_none()
.pr_2()
.text_right()
.text_color(gutter)
.child(line.new.map(|n| n.to_string()).unwrap_or_default()),
)
.child(
div()
.flex_1()
.min_w_0()
.overflow_hidden()
.whitespace_nowrap()
.text_color(cx.theme().foreground)
.child(line.text.clone()),
)
.into_any_element()
}
/// Find a tree item by id, searching into nested children.
pub(super) fn find_item<'a>(items: &'a [TreeItem], id: Option<&str>) -> Option<&'a TreeItem> {
let id = id?;
items.iter().find_map(|item| {
if item.id.as_ref() == id {
Some(item)
} else {
find_item(&item.children, Some(id))
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builds_nested_tree_from_flat_entries() {
let entries = vec![
PathBuf::from("src"),
PathBuf::from("src/lib.rs"),
PathBuf::from("README.md"),
PathBuf::from("docs/guide.md"),
];
let items = build_tree_items(&entries);
// Input order is preserved (dirs-first, as produced by worktree_entries).
assert_eq!(items.len(), 3);
assert_eq!(items[0].label, "src");
assert_eq!(items[0].id, "src");
assert_eq!(items[0].children.len(), 1);
assert_eq!(items[0].children[0].label, "lib.rs");
assert_eq!(items[0].children[0].id, "src/lib.rs");
assert_eq!(items[1].label, "README.md");
assert_eq!(items[1].id, "README.md");
assert_eq!(items[2].label, "docs");
assert_eq!(items[2].children[0].label, "guide.md");
assert_eq!(items[2].children[0].id, "docs/guide.md");
}
#[test]
fn tree_builder_handles_deep_nesting() {
let entries = vec![
PathBuf::from("a"),
PathBuf::from("a/b"),
PathBuf::from("a/b/c.txt"),
];
let items = build_tree_items(&entries);
assert_eq!(items.len(), 1);
assert_eq!(items[0].children[0].id, "a/b");
assert_eq!(items[0].children[0].children[0].id, "a/b/c.txt");
}
#[test]
fn tree_builder_merges_shared_prefixes() {
// File children of a directory arrive after other directories'
// entries (the worktree list is dirs-first globally); the shared
// prefix must still resolve to one node.
let entries = vec![
PathBuf::from("a/x.txt"),
PathBuf::from("b/y.txt"),
PathBuf::from("a/z.txt"),
];
let items = build_tree_items(&entries);
assert_eq!(items.len(), 2);
assert_eq!(items[0].label, "a");
assert_eq!(items[0].children.len(), 2);
assert_eq!(items[1].label, "b");
}
#[test]
fn tree_seeds_convert_to_tree_items() {
let entries = vec![
PathBuf::from("src"),
PathBuf::from("src/main.rs"),
PathBuf::from("README.md"),
];
let items: Vec<TreeItem> = tree_items(build_tree_items(&entries), false);
assert_eq!(items.len(), 2);
assert_eq!(items[0].label, "src");
assert_eq!(items[0].children.len(), 1);
assert_eq!(items[0].children[0].label, "main.rs");
}
#[test]
fn code_language_maps_extensions_and_names() {
assert_eq!(code_language("src/main.rs"), Some("rust"));
assert_eq!(code_language("Cargo.toml"), Some("toml"));
assert_eq!(code_language("app.js"), Some("javascript"));
assert_eq!(code_language("index.tsx"), Some("tsx"));
assert_eq!(code_language("Makefile"), Some("make"));
assert_eq!(code_language("CMakeLists.txt"), Some("cmake"));
assert_eq!(code_language("data.csv"), None);
assert_eq!(code_language("LICENSE"), None);
assert_eq!(code_language("README.md"), None);
}
}
@@ -0,0 +1,379 @@
use assets::CustomIconName;
use dock::{BasePanel, Panel, PanelEvent};
use gpui::prelude::*;
use gpui::{
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString,
Window, div, px, relative,
};
use gpui_component::avatar::Avatar;
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::input::{Textarea, TextareaState};
use gpui_component::scroll::ScrollableElement;
use gpui_component::tag::Tag;
use gpui_component::{ActiveTheme, Icon, Sizable, StyledExt, h_flex, v_flex};
use nostr::prelude::{Event, EventId, PublicKey};
use signed_core::activity_subject;
use signed_state::{ProfileStore, RepoStore};
use utils::relative_time;
use super::helpers::{placeholder, status_badge};
use crate::image_cache::{MAX_IMAGES, image_cache};
/// Detail panel of a single issue.
pub struct IssueDetailView {
focus_handle: FocusHandle,
/// Repo store holding the issues and their statuses.
store: Entity<RepoStore>,
issue_id: EventId,
/// Input state of the "leave a comment" textarea.
comment_input: Entity<TextareaState>,
}
impl IssueDetailView {
pub fn new(
store: Entity<RepoStore>,
issue_id: EventId,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let comment_input =
cx.new(|cx| TextareaState::new(window, cx).placeholder("Leave a comment…"));
Self {
focus_handle: cx.focus_handle(),
store,
issue_id,
comment_input,
}
}
fn render_sidebar(&self, cx: &mut Context<Self>) -> impl IntoElement {
let profile_store = ProfileStore::global(cx);
let store = self.store.read(cx);
let Some(issue) = store.issues.iter().find(|issue| issue.id == self.issue_id) else {
// `render` already bails out when the issue is missing.
return div().into_any_element();
};
// Participants: the issue author plus everyone who commented.
let mut participants: Vec<PublicKey> = vec![issue.pubkey];
participants.extend(store.comments_of(&issue.id).map(|comment| comment.pubkey));
participants.sort_by_key(PublicKey::to_hex);
participants.dedup();
// Issue labels are NIP-34 `t` hashtag tags on the event.
let labels: Vec<String> = issue.tags.hashtags().map(|tag| tag.to_string()).collect();
v_flex()
.w(px(240.))
.h_full()
.flex_none()
.px_4()
.gap_4()
.border_l(px(1.))
.border_color(cx.theme().sidebar_border)
.child(
v_flex()
.gap_2()
.child(sidebar_title("Participants", cx))
.children(participants.iter().map(|pubkey| {
let profile = profile_store.read(cx).get(pubkey);
let name = profile.name();
let picture = profile.picture();
h_flex()
.gap_1()
.items_center()
.child(
Avatar::new()
.name(name.clone())
.when_some(picture, |this, url| this.src(url))
.rounded(cx.theme().radius)
.small(),
)
.child(div().text_sm().truncate().text_ellipsis().child(name))
.into_any_element()
})),
)
.child(
v_flex()
.gap_2()
.child(sidebar_title("Labels", cx))
.map(|this| {
if labels.is_empty() {
this.child(
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child("None yet."),
)
} else {
this.child(h_flex().gap_1().children({
let mut items = vec![];
for label in labels.iter() {
items.push(
Tag::secondary()
.outline()
.xsmall()
.child(SharedString::from(label)),
);
}
items
}))
}
}),
)
.into_any_element()
}
fn render_comments(&mut self, id: &EventId, cx: &mut Context<Self>) -> impl IntoElement {
let store = self.store.read(cx);
let comments: Vec<&Event> = store.comments_of(id).collect();
let title = SharedString::from(format!("Discussions {}", comments.len()));
v_flex()
.gap_4()
.child(div().text_xs().font_semibold().child(title))
.children(comments.iter().map(|comment| {
let profile = ProfileStore::global(cx).read(cx).get(&comment.pubkey);
let author = profile.name();
let picture = profile.picture();
let age = relative_time(comment.created_at);
v_flex()
.gap_1()
.p_3()
.border_1()
.border_color(cx.theme().border)
.rounded(cx.theme().radius)
.child(
h_flex()
.gap_2()
.text_sm()
.child(
h_flex()
.gap_1()
.child(
Avatar::new()
.name(author.clone())
.when_some(picture, |this, url| this.src(url))
.rounded(cx.theme().radius)
.small(),
)
.child(author),
)
.child(
div()
.text_color(cx.theme().muted_foreground)
.child("commented"),
)
.child(
div()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(age)),
),
)
.child(
div()
.text_sm()
.child(SharedString::from(comment.content.clone())),
)
}))
.into_any_element()
}
fn render_form(&mut self, id: &EventId, cx: &mut Context<Self>) -> impl IntoElement {
let comment_input = self.comment_input.clone();
let store = self.store.clone();
let id = id.to_owned();
v_flex()
.gap_2()
.child(
Textarea::new(&self.comment_input)
.h_24()
.text_color(cx.theme().muted_foreground)
.bg(cx.theme().muted),
)
.child(
h_flex()
.justify_between()
.child(
h_flex()
.gap_1()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(Icon::new(CustomIconName::Markdown).small())
.child("Markdown is supported"),
)
.child(
Button::new("comment")
.primary()
.label("Comment")
.tooltip("Post comment")
.on_click(move |_event, window, cx| {
let content = comment_input.read(cx).value().trim().to_string();
if content.is_empty() {
return;
}
let Some(root) = store
.read(cx)
.issues
.iter()
.find(|issue| issue.id == id)
.cloned()
else {
return;
};
store.update(cx, |store, cx| {
store.comment(&root, content, cx);
});
comment_input.update(cx, |input, cx| {
input.set_value("", window, cx);
});
}),
),
)
.into_any_element()
}
}
impl BasePanel for IssueDetailView {
fn panel_name(&self) -> &'static str {
"issue_detail"
}
}
impl Panel for IssueDetailView {
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let short_id = self
.store
.read(cx)
.issues
.iter()
.find(|issue| issue.id == self.issue_id)
.map(|issue| {
let hex = issue.id.to_hex();
SharedString::from(&hex[..8])
})
.unwrap_or_else(|| SharedString::from("Issue"));
div().text_sm().child(short_id)
}
}
impl EventEmitter<PanelEvent> for IssueDetailView {}
impl Focusable for IssueDetailView {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for IssueDetailView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let store = self.store.read(cx);
let Some(issue) = store.issues.iter().find(|issue| issue.id == self.issue_id) else {
return placeholder("Issue not found", cx);
};
let (title, author, picture, status, age, issue_id, content) = {
let profile_store = ProfileStore::global(cx);
let profile = profile_store.read(cx).get(&issue.pubkey);
(
activity_subject(issue),
profile.name(),
profile.picture(),
store.status_of(issue),
relative_time(issue.created_at),
issue.id,
issue.content.clone(),
)
};
h_flex()
.image_cache(image_cache("issue-detail", MAX_IMAGES))
.id("issue-detail")
.size_full()
.child(
v_flex()
.px_4()
.pb_4()
.gap_6()
.size_full()
.min_w_0()
.overflow_y_scrollbar()
.child(
h_flex()
.min_h_16()
.gap_2()
.child(status_badge(status, cx))
.child(
div()
.flex_1()
.min_w_0()
.font_semibold()
.line_height(relative(1.2))
.child(title),
),
)
.child(
v_flex()
.px_4()
.gap_8()
.child(
v_flex()
.gap_4()
.child(
h_flex()
.gap_2()
.text_sm()
.child(
h_flex()
.gap_1()
.child(
Avatar::new()
.when_some(picture, |this, url| {
this.src(url)
})
.name(author.clone())
.rounded(cx.theme().radius)
.small(),
)
.child(author),
)
.child(
div()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from("opened")),
)
.child(
div()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(age)),
),
)
.child(div().text_sm().child(SharedString::from(&content))),
)
.child(self.render_comments(&issue_id, cx))
.child(self.render_form(&issue_id, cx)),
),
)
.child(self.render_sidebar(cx))
.into_any_element()
}
}
fn sidebar_title(text: &str, cx: &App) -> AnyElement {
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
.child(text.to_string())
.into_any_element()
}
@@ -0,0 +1,515 @@
//! Issues panel: a bottom panel listing every issue of the repository with
//! its title, event id, author, age and status, filterable by status via
//! the header's All/Open/Closed filter.
use std::rc::Rc;
use assets::CustomIconName;
use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle};
use gpui::prelude::*;
use gpui::{
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
SharedString, Size, WeakEntity, Window, div, px, relative, size,
};
use gpui_base::Button as BaseButton;
use gpui_component::avatar::Avatar;
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
use gpui_component::form::{field, v_form};
use gpui_component::input::{Input, InputState, Textarea, TextareaState};
use gpui_component::scroll::Scrollbar;
use gpui_component::{
ActiveTheme, Icon, Sizable, VirtualListScrollHandle, WindowExt, h_flex, v_flex, v_virtual_list,
};
use nostr::prelude::{Event, EventId};
use signed_core::{RepoStatus, activity_subject};
use signed_state::{ProfileStore, RepoStore};
use utils::relative_time;
use super::helpers::{placeholder, status_badge};
use super::issue_detail::IssueDetailView;
use crate::image_cache::{MAX_IMAGES, image_cache};
/// Height of one issue row in the virtual list: 8px vertical padding
/// (`py_2`) on top and bottom, a 32px title line (`h_8`) and a 24px meta
/// line (`h_6`), plus the 1px bottom border; the row totals 73px. The
/// status chip (`size_7`, 28px) is shorter than the content.
const ISSUE_ROW_HEIGHT: f32 = 73.;
/// Status filter of the issues list, chosen via the header's filter buttons.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum IssueFilter {
/// Every issue, regardless of status.
All,
/// Issues whose resolved status is [`RepoStatus::Open`].
Open,
/// Issues whose resolved status is [`RepoStatus::Closed`] or
/// [`RepoStatus::Applied`] (both are "done" states).
Closed,
}
impl IssueFilter {
/// Whether `issue` (of `store`) is included by this filter.
fn matches(self, store: &RepoStore, issue: &Event) -> bool {
match self {
Self::All => true,
Self::Open => store.status_of(issue) == RepoStatus::Open,
Self::Closed => matches!(
store.status_of(issue),
RepoStatus::Closed | RepoStatus::Applied
),
}
}
}
pub struct IssuesView {
focus_handle: FocusHandle,
/// Dock area the issue detail panel is opened in.
dock_area: WeakEntity<DockArea>,
/// Repo store holding the issues and their statuses.
store: Entity<RepoStore>,
/// Display name of the repository, for the panel title.
repo_name: SharedString,
/// Filter selected in the header filter buttons.
filter: IssueFilter,
/// Per-row heights of the virtual list.
item_sizes: Rc<Vec<Size<Pixels>>>,
/// Number of rows [`Self::item_sizes`] was built for (the filtered
/// issue count); rebuilt on change.
issue_len: usize,
/// Indices into the store's `issues` matching [`Self::filter`], rebuilt
/// every render; the virtual list renders this slice.
visible_issues: Vec<usize>,
/// Virtual list state of the issues list.
scroll_handle: VirtualListScrollHandle,
}
impl IssuesView {
pub fn new(
dock_area: WeakEntity<DockArea>,
store: Entity<RepoStore>,
repo_name: SharedString,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Self {
focus_handle: cx.focus_handle(),
dock_area,
store,
repo_name,
filter: IssueFilter::Open,
item_sizes: Rc::new(Vec::new()),
issue_len: 0,
visible_issues: Vec::new(),
scroll_handle: VirtualListScrollHandle::new(),
}
}
/// Open the detail panel of `issue_id` at the bottom of the dock area.
fn open_issue_detail(
&mut self,
issue_id: EventId,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(dock_area) = self.dock_area.upgrade() else {
return;
};
let panel = cx.new(|cx| IssueDetailView::new(self.store.clone(), issue_id, window, cx));
dock_area.update(cx, |dock_area, cx| {
dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx);
});
}
/// Render one row of the issue list; `ix` is the row index and
/// `issue_ix` the index of the issue in the store's `issues`.
fn render_row(&self, ix: usize, issue_ix: usize, cx: &mut Context<Self>) -> AnyElement {
let issue = &self.store.read(cx).issues[issue_ix];
let title = activity_subject(issue);
let id_hex = issue.id.to_hex();
let profile = ProfileStore::global(cx).read(cx).get(&issue.pubkey);
let author = profile.name();
let picture = profile.picture();
let age = relative_time(issue.created_at);
let status = self.store.read(cx).status_of(issue);
let issue_id = issue.id;
h_flex()
.id(ix)
.w_full()
.gap_4()
.px_4()
.py_2()
.border_b_1()
.border_color(cx.theme().border)
.items_start()
.on_click(cx.listener(move |this, _event, window, cx| {
this.open_issue_detail(issue_id, window, cx);
}))
.child(status_badge(status, cx))
.child(
v_flex()
.flex_1()
.child(
div()
.h_8()
.min_w_0()
.text_ellipsis()
.whitespace_nowrap()
.line_clamp(1)
.text_sm()
.child(title),
)
.child(
h_flex()
.h_6()
.gap_2()
.text_xs()
.child(
h_flex()
.gap_1()
.child(
Avatar::new()
.name(author.clone())
.when_some(picture, |this, url| this.src(url))
.rounded(cx.theme().radius)
.small(),
)
.child(div().child(author)),
)
.child(SharedString::from("opened"))
.child(
div()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(&id_hex[..8])),
)
.child(SharedString::from(age)),
),
)
.hover(|this| this.bg(cx.theme().list_hover))
.into_any_element()
}
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
let store = self.store.read(cx);
let (total, open, closed) =
store
.issues
.iter()
.fold(
(0usize, 0usize, 0usize),
|(total, open, closed), issue| match store.status_of(issue) {
RepoStatus::Open => (total + 1, open + 1, closed),
RepoStatus::Closed => (total + 1, open, closed + 1),
RepoStatus::Draft | RepoStatus::Applied => (total + 1, open, closed),
},
);
h_flex()
.px_4()
.w_full()
.gap_3()
.border_b_1()
.border_color(cx.theme().border)
.bg(cx.theme().muted.opacity(0.5))
.child(
h_flex()
.h_12()
.gap_2()
.child(
BaseButton::new("all")
.flex()
.items_center()
.h_7()
.px_2()
.gap_1()
.child(Icon::new(CustomIconName::GitIssueDone))
.child(div().text_sm().child("All"))
.child(
h_flex()
.justify_center()
.ml_2()
.px_1()
.py_0p5()
.min_w_4()
.text_size(px(8.))
.bg(cx.theme().muted)
.text_color(cx.theme().muted_foreground)
.rounded(cx.theme().radius)
.line_height(relative(1.))
.child(SharedString::from(total.to_string())),
)
.text_color(cx.theme().button_foreground)
.rounded(cx.theme().radius)
.hover(|this| this.bg(cx.theme().button_hover))
.active(|this| this.bg(cx.theme().button_active))
.selected(self.filter == IssueFilter::All)
.when(self.filter == IssueFilter::All, |this| {
this.bg(cx.theme().button_active)
})
.on_click(cx.listener(|this, _event, _window, cx| {
this.filter = IssueFilter::All;
cx.notify();
})),
)
.child(
BaseButton::new("open")
.flex()
.items_center()
.h_7()
.px_2()
.gap_1()
.child(Icon::new(CustomIconName::GitIssueOpen))
.child(div().text_sm().child("Open"))
.child(
h_flex()
.justify_center()
.ml_2()
.px_1()
.py_0p5()
.min_w_4()
.text_size(px(8.))
.bg(cx.theme().muted)
.text_color(cx.theme().muted_foreground)
.rounded(cx.theme().radius)
.line_height(relative(1.))
.child(SharedString::from(open.to_string())),
)
.text_color(cx.theme().button_foreground)
.rounded(cx.theme().radius)
.hover(|this| this.bg(cx.theme().button_hover))
.selected(self.filter == IssueFilter::Open)
.when(self.filter == IssueFilter::Open, |this| {
this.bg(cx.theme().button_active)
})
.on_click(cx.listener(|this, _event, _window, cx| {
this.filter = IssueFilter::Open;
cx.notify();
})),
)
.child(
BaseButton::new("closed")
.flex()
.items_center()
.h_7()
.px_2()
.gap_1()
.child(Icon::new(CustomIconName::GitIssueClosed))
.child(div().text_sm().child("Closed"))
.child(
h_flex()
.justify_center()
.ml_2()
.px_1()
.py_0p5()
.min_w_4()
.text_size(px(8.))
.bg(cx.theme().muted)
.text_color(cx.theme().muted_foreground)
.rounded(cx.theme().radius)
.line_height(relative(1.))
.child(SharedString::from(closed.to_string())),
)
.text_color(cx.theme().button_foreground)
.rounded(cx.theme().radius)
.hover(|this| this.bg(cx.theme().button_hover))
.selected(self.filter == IssueFilter::Closed)
.when(self.filter == IssueFilter::Closed, |this| {
this.bg(cx.theme().button_active)
})
.on_click(cx.listener(|this, _event, _window, cx| {
this.filter = IssueFilter::Closed;
cx.notify();
})),
),
)
.child(div().flex_1())
.child(
BaseButton::new("new")
.flex()
.items_center()
.h_7()
.px_2()
.gap_1()
.child(Icon::new(CustomIconName::CirclePlus))
.child(div().text_sm().child("New issue"))
.text_color(cx.theme().button_primary_foreground)
.rounded(cx.theme().radius)
.bg(cx.theme().button_primary)
.hover(|this| this.bg(cx.theme().button_primary_hover))
.active(|this| this.bg(cx.theme().button_primary_active))
.on_click(cx.listener(|this, _event, window, cx| {
open_new_issue_dialog(this.store.clone(), window, cx);
})),
)
.into_any_element()
}
}
/// Open the "new issue" dialog: a title and a content input that submit
/// through [`RepoStore::open_issue`] when confirmed.
fn open_new_issue_dialog(store: Entity<RepoStore>, window: &mut Window, cx: &mut App) {
let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Issue title"));
let content = cx.new(|cx| TextareaState::new(window, cx).placeholder("Describe the issue…"));
window.open_dialog(cx, move |dialog, _window, _cx| {
let subject = subject.clone();
let content = content.clone();
let store = store.clone();
dialog
.keyboard(true)
.close_button(true)
.content(move |body, _window, _cx| {
body.child(
DialogHeader::new()
.child(DialogTitle::new().child("New issue"))
.child(
DialogDescription::new()
.child("Report a bug, ask a question, or propose a change."),
),
)
.child(
v_form()
.child(
field()
.label("Title")
.required(true)
.child(Input::new(&subject)),
)
.child(
field()
.label("Content")
.child(Textarea::new(&content).h(px(160.))),
),
)
.child(
DialogFooter::new().justify_end().child(
Button::new("submit")
.primary()
.label("Create issue")
.tooltip("Create issue")
.on_click({
let subject = subject.clone();
let content = content.clone();
let store = store.clone();
move |_event, window, cx| {
let subject = subject.read(cx).value().to_string();
let content = content.read(cx).value().to_string();
let subject = (!subject.is_empty()).then_some(subject);
store.update(cx, |store, cx| {
store.open_issue(subject, content, cx);
});
window.close_dialog(cx);
}
}),
),
)
})
});
}
impl BasePanel for IssuesView {
fn panel_name(&self) -> &'static str {
"issues"
}
}
impl Panel for IssuesView {
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().child(SharedString::from(format!("{}/issues", self.repo_name)))
}
}
impl EventEmitter<PanelEvent> for IssuesView {}
impl Focusable for IssuesView {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for IssuesView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let filter = self.filter;
// Indices of the issues matching the active filter; the virtual
// list renders this filtered slice.
self.visible_issues = {
let store = self.store.read(cx);
store
.issues
.iter()
.enumerate()
.filter(|(_, issue)| filter.matches(store, issue))
.map(|(ix, _)| ix)
.collect()
};
let count = self.visible_issues.len();
// The virtual list's item count comes from `item_sizes`; rebuild it
// whenever the filtered issue count changes.
if count != self.issue_len {
self.issue_len = count;
self.item_sizes = Rc::new(vec![size(px(0.), px(ISSUE_ROW_HEIGHT)); count]);
}
let sizes = self.item_sizes.clone();
let scroll_handle = self.scroll_handle.clone();
v_flex()
.size_full()
.image_cache(image_cache("issues", MAX_IMAGES))
.child(self.render_header(cx))
.child(
v_flex()
.relative()
.flex_1()
.min_h_0()
.w_full()
.when(count > 0, |this| {
this.child(
v_virtual_list(
cx.entity().clone(),
"issues",
sizes,
move |this, range, _window, cx| {
range
.map(|ix| {
let issue = this.visible_issues[ix];
this.render_row(ix, issue, cx)
})
.collect()
},
)
.track_scroll(&scroll_handle)
.size_full(),
)
.child(
div()
.absolute()
.top_0()
.left_0()
.right_0()
.bottom_0()
.child(Scrollbar::vertical(&scroll_handle)),
)
})
.when(count == 0, |this| {
let message = match filter {
IssueFilter::All => "No issues",
IssueFilter::Open => "No open issues",
IssueFilter::Closed => "No closed issues",
};
this.child(placeholder(message, cx))
}),
)
.into_any_element()
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,613 @@
use std::rc::Rc;
use assets::CustomIconName;
use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle};
use gpui::prelude::*;
use gpui::{
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
SharedString, Size, WeakEntity, Window, div, px, relative, size,
};
use gpui_base::Button as BaseButton;
use gpui_component::avatar::Avatar;
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
use gpui_component::form::{field, v_form};
use gpui_component::input::{Input, InputState, Textarea, TextareaState};
use gpui_component::scroll::Scrollbar;
use gpui_component::{
ActiveTheme, Icon, Sizable, VirtualListScrollHandle, WindowExt, h_flex, v_flex, v_virtual_list,
};
use nostr::prelude::{Event, EventId, Kind};
use signed_core::{RepoStatus, activity_subject};
use signed_state::{ProfileStore, RepoStore};
use utils::relative_time;
use super::helpers::{placeholder, status_badge};
use super::pull_request_detail::PullRequestDetailView;
use crate::image_cache::{MAX_IMAGES, image_cache};
/// Height of one pull request row in the virtual list: same layout as an
/// issue row (8px vertical padding (`py_2`) on top and bottom, a 32px title
/// line (`h_8`) and a 24px meta line (`h_6`), plus the 1px bottom border),
/// so the row totals 73px. The status badge (`size_7`, 28px) is shorter
/// than the content.
const PR_ROW_HEIGHT: f32 = 73.;
/// Status filter of the pull request list, chosen via the header's filter
/// buttons.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PullRequestFilter {
/// Every pull request, regardless of status.
All,
/// Pull requests whose resolved status is [`RepoStatus::Open`].
Open,
/// Pull requests whose resolved status is [`RepoStatus::Closed`].
Closed,
/// Pull requests whose resolved status is [`RepoStatus::Draft`].
Draft,
/// Pull requests whose resolved status is [`RepoStatus::Applied`]
/// (i.e. merged).
Merged,
}
impl PullRequestFilter {
/// Whether `pr` (of `store`) is included by this filter.
fn matches(self, store: &RepoStore, pr: &Event) -> bool {
match self {
Self::All => true,
Self::Open => store.status_of(pr) == RepoStatus::Open,
Self::Closed => store.status_of(pr) == RepoStatus::Closed,
Self::Draft => store.status_of(pr) == RepoStatus::Draft,
Self::Merged => store.status_of(pr) == RepoStatus::Applied,
}
}
}
pub struct PullRequestsView {
focus_handle: FocusHandle,
/// Dock area the detail panels are added to.
dock_area: WeakEntity<DockArea>,
/// Repo store holding the pull requests and their statuses.
store: Entity<RepoStore>,
/// Display name of the repository, for the panel title.
repo_name: SharedString,
/// Filter selected in the header filter buttons.
filter: PullRequestFilter,
/// Per-row heights of the virtual list.
item_sizes: Rc<Vec<Size<Pixels>>>,
/// Number of rows [`Self::item_sizes`] was built for (the filtered
/// pull request count); rebuilt on change.
pr_len: usize,
/// Indices into the store's `pull_requests` matching [`Self::filter`]
/// (root PR events only; updates are revisions of the root and are not
/// listed separately), rebuilt every render; the virtual list renders
/// this slice.
visible_prs: Vec<usize>,
/// Virtual list state of the pull requests list.
scroll_handle: VirtualListScrollHandle,
}
impl PullRequestsView {
pub fn new(
dock_area: WeakEntity<DockArea>,
store: Entity<RepoStore>,
repo_name: SharedString,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Self {
focus_handle: cx.focus_handle(),
dock_area,
store,
repo_name,
filter: PullRequestFilter::Open,
item_sizes: Rc::new(Vec::new()),
pr_len: 0,
visible_prs: Vec::new(),
scroll_handle: VirtualListScrollHandle::new(),
}
}
/// Open the detail panel of `pr_id` at the bottom of the dock area.
fn open_pull_request_detail(
&mut self,
pr_id: EventId,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(dock_area) = self.dock_area.upgrade() else {
return;
};
let panel = cx.new(|cx| {
PullRequestDetailView::new(
self.dock_area.clone(),
self.store.clone(),
pr_id,
window,
cx,
)
});
dock_area.update(cx, |dock_area, cx| {
dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx);
});
}
/// Render one row of the pull request list; `ix` is the row index and
/// `pr_ix` the index of the pull request in the store's `pull_requests`.
fn render_row(&self, ix: usize, pr_ix: usize, cx: &mut Context<Self>) -> AnyElement {
let pr = &self.store.read(cx).pull_requests[pr_ix];
let pr_id = pr.id;
let title = activity_subject(pr);
let id_hex = pr.id.to_hex();
let age = relative_time(pr.created_at);
let status = self.store.read(cx).status_of(pr);
let profile = ProfileStore::global(cx).read(cx).get(&pr.pubkey);
let author = profile.name();
let picture = profile.picture();
h_flex()
.id(ix)
.w_full()
.gap_4()
.px_4()
.py_2()
.border_b_1()
.border_color(cx.theme().border)
.items_start()
.on_click(cx.listener(move |this, _event, window, cx| {
this.open_pull_request_detail(pr_id, window, cx);
}))
.child(status_badge(status, cx))
.child(
v_flex()
.flex_1()
.child(
div()
.h_8()
.min_w_0()
.text_ellipsis()
.whitespace_nowrap()
.line_clamp(1)
.text_sm()
.child(title),
)
.child(
h_flex()
.h_6()
.gap_2()
.text_xs()
.child(
h_flex()
.gap_1()
.child(
Avatar::new()
.name(author.clone())
.when_some(picture, |this, url| this.src(url))
.rounded(cx.theme().radius)
.small(),
)
.child(div().child(author)),
)
.child(SharedString::from("opened"))
.child(
div()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(&id_hex[..8])),
)
.child(SharedString::from(age)),
),
)
.hover(|this| this.bg(cx.theme().list_hover))
.into_any_element()
}
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
let store = self.store.read(cx);
let (total, open, closed, draft, merged) = store.pull_requests.iter().fold(
(0usize, 0usize, 0usize, 0usize, 0usize),
|(total, open, closed, draft, merged), pr| match store.status_of(pr) {
RepoStatus::Open => (total + 1, open + 1, closed, draft, merged),
RepoStatus::Closed => (total + 1, open, closed + 1, draft, merged),
RepoStatus::Draft => (total + 1, open, closed, draft + 1, merged),
RepoStatus::Applied => (total + 1, open, closed, draft, merged + 1),
},
);
h_flex()
.px_4()
.w_full()
.gap_3()
.border_b_1()
.border_color(cx.theme().border)
.bg(cx.theme().muted.opacity(0.5))
.child(
h_flex()
.h_12()
.gap_2()
.child(
BaseButton::new("all")
.flex()
.items_center()
.h_7()
.px_2()
.gap_1()
.child(Icon::new(CustomIconName::GitPullRequest))
.child(div().text_sm().child("All"))
.child(
h_flex()
.justify_center()
.ml_2()
.px_1()
.py_0p5()
.min_w_4()
.text_size(px(8.))
.bg(cx.theme().muted)
.text_color(cx.theme().muted_foreground)
.rounded(cx.theme().radius)
.line_height(relative(1.))
.child(SharedString::from(total.to_string())),
)
.text_color(cx.theme().button_foreground)
.rounded(cx.theme().radius)
.hover(|this| this.bg(cx.theme().button_hover))
.active(|this| this.bg(cx.theme().button_active))
.selected(self.filter == PullRequestFilter::All)
.when(self.filter == PullRequestFilter::All, |this| {
this.bg(cx.theme().button_active)
})
.on_click(cx.listener(|this, _event, _window, cx| {
this.filter = PullRequestFilter::All;
cx.notify();
})),
)
.child(
BaseButton::new("open")
.flex()
.items_center()
.h_7()
.px_2()
.gap_1()
.child(Icon::new(CustomIconName::GitPullRequest))
.child(div().text_sm().child("Open"))
.child(
h_flex()
.justify_center()
.ml_2()
.px_1()
.py_0p5()
.min_w_4()
.text_size(px(8.))
.bg(cx.theme().muted)
.text_color(cx.theme().muted_foreground)
.rounded(cx.theme().radius)
.line_height(relative(1.))
.child(SharedString::from(open.to_string())),
)
.text_color(cx.theme().button_foreground)
.rounded(cx.theme().radius)
.hover(|this| this.bg(cx.theme().button_hover))
.active(|this| this.bg(cx.theme().button_active))
.selected(self.filter == PullRequestFilter::Open)
.when(self.filter == PullRequestFilter::Open, |this| {
this.bg(cx.theme().button_active)
})
.on_click(cx.listener(|this, _event, _window, cx| {
this.filter = PullRequestFilter::Open;
cx.notify();
})),
)
.child(
BaseButton::new("closed")
.flex()
.items_center()
.h_7()
.px_2()
.gap_1()
.child(Icon::new(CustomIconName::GitPullRequestClosed))
.child(div().text_sm().child("Closed"))
.child(
h_flex()
.justify_center()
.ml_2()
.px_1()
.py_0p5()
.min_w_4()
.text_size(px(8.))
.bg(cx.theme().muted)
.text_color(cx.theme().muted_foreground)
.rounded(cx.theme().radius)
.line_height(relative(1.))
.child(SharedString::from(closed.to_string())),
)
.text_color(cx.theme().button_foreground)
.rounded(cx.theme().radius)
.hover(|this| this.bg(cx.theme().button_hover))
.active(|this| this.bg(cx.theme().button_active))
.selected(self.filter == PullRequestFilter::Closed)
.when(self.filter == PullRequestFilter::Closed, |this| {
this.bg(cx.theme().button_active)
})
.on_click(cx.listener(|this, _event, _window, cx| {
this.filter = PullRequestFilter::Closed;
cx.notify();
})),
)
.child(
BaseButton::new("draft")
.flex()
.items_center()
.h_7()
.px_2()
.gap_1()
.child(Icon::new(CustomIconName::GitPullRequestDraft))
.child(div().text_sm().child("Draft"))
.child(
h_flex()
.justify_center()
.ml_2()
.px_1()
.py_0p5()
.min_w_4()
.text_size(px(8.))
.bg(cx.theme().muted)
.text_color(cx.theme().muted_foreground)
.rounded(cx.theme().radius)
.line_height(relative(1.))
.child(SharedString::from(draft.to_string())),
)
.text_color(cx.theme().button_foreground)
.rounded(cx.theme().radius)
.hover(|this| this.bg(cx.theme().button_hover))
.active(|this| this.bg(cx.theme().button_active))
.selected(self.filter == PullRequestFilter::Draft)
.when(self.filter == PullRequestFilter::Draft, |this| {
this.bg(cx.theme().button_active)
})
.on_click(cx.listener(|this, _event, _window, cx| {
this.filter = PullRequestFilter::Draft;
cx.notify();
})),
)
.child(
BaseButton::new("merged")
.flex()
.items_center()
.h_7()
.px_2()
.gap_1()
.child(Icon::new(CustomIconName::GitPullRequestMerged))
.child(div().text_sm().child("Merged"))
.child(
h_flex()
.justify_center()
.ml_2()
.px_1()
.py_0p5()
.min_w_4()
.text_size(px(8.))
.bg(cx.theme().muted)
.text_color(cx.theme().muted_foreground)
.rounded(cx.theme().radius)
.line_height(relative(1.))
.child(SharedString::from(merged.to_string())),
)
.text_color(cx.theme().button_foreground)
.rounded(cx.theme().radius)
.hover(|this| this.bg(cx.theme().button_hover))
.active(|this| this.bg(cx.theme().button_active))
.selected(self.filter == PullRequestFilter::Merged)
.when(self.filter == PullRequestFilter::Merged, |this| {
this.bg(cx.theme().button_active)
})
.on_click(cx.listener(|this, _event, _window, cx| {
this.filter = PullRequestFilter::Merged;
cx.notify();
})),
),
)
.child(div().flex_1())
.child(
BaseButton::new("new-pr")
.flex()
.items_center()
.h_7()
.px_2()
.gap_1()
.child(Icon::new(CustomIconName::CirclePlus))
.child(div().text_sm().child("New pull request"))
.text_color(cx.theme().button_primary_foreground)
.rounded(cx.theme().radius)
.bg(cx.theme().button_primary)
.hover(|this| this.bg(cx.theme().button_primary_hover))
.active(|this| this.bg(cx.theme().button_primary_active))
.on_click(cx.listener(|this, _event, window, cx| {
open_new_pull_request_dialog(this.store.clone(), window, cx);
})),
)
.into_any_element()
}
}
/// Open the "new pull request" dialog: a title, an optional description and
/// a patch input that submit through [`RepoStore::open_pull_request`] when
/// confirmed.
fn open_new_pull_request_dialog(store: Entity<RepoStore>, window: &mut Window, cx: &mut App) {
let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Pull request title"));
let description =
cx.new(|cx| TextareaState::new(window, cx).placeholder("Describe the change…"));
let patch =
cx.new(|cx| TextareaState::new(window, cx).placeholder("Paste `git format-patch` output…"));
window.open_dialog(cx, move |dialog, _window, _cx| {
let subject = subject.clone();
let description = description.clone();
let patch = patch.clone();
let store = store.clone();
dialog
.width(px(520.))
.margin_top(px(50.))
.content(move |body, _window, _cx| {
body.child(
DialogHeader::new()
.child(DialogTitle::new().child("New pull request"))
.child(
DialogDescription::new()
.child("Propose a change with the output of `git format-patch`."),
),
)
.child(
v_form()
.child(
field()
.label("Title")
.required(true)
.child(Input::new(&subject)),
)
.child(
field()
.label("Description")
.child(Textarea::new(&description).h(px(96.))),
)
.child(
field()
.label("Patch")
.child(Textarea::new(&patch).h(px(160.))),
),
)
.child(
DialogFooter::new().justify_end().child(
Button::new("submit")
.primary()
.label("Create pull request")
.tooltip("Create pull request")
.on_click({
let subject = subject.clone();
let description = description.clone();
let patch = patch.clone();
let store = store.clone();
move |_event, window, cx| {
let subject = subject.read(cx).value().to_string();
let description = description.read(cx).value().to_string();
let patch = patch.read(cx).value().to_string();
let subject = (!subject.is_empty()).then_some(subject);
store.update(cx, |store, cx| {
store.open_pull_request(subject, description, patch, cx);
});
window.close_dialog(cx);
}
}),
),
)
})
});
}
impl BasePanel for PullRequestsView {
fn panel_name(&self) -> &'static str {
"pull-requests"
}
}
impl Panel for PullRequestsView {
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().child(SharedString::from(format!(
"{}/pull-requests",
self.repo_name
)))
}
}
impl EventEmitter<PanelEvent> for PullRequestsView {}
impl Focusable for PullRequestsView {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for PullRequestsView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let filter = self.filter;
// Indices of the root pull requests matching the active filter
// (updates are revisions of the root and are not listed
// separately); the virtual list renders this filtered slice.
self.visible_prs = {
let store = self.store.read(cx);
store
.pull_requests
.iter()
.enumerate()
.filter(|(_, pr)| pr.kind == Kind::GitPullRequest && filter.matches(store, pr))
.map(|(ix, _)| ix)
.collect()
};
let count = self.visible_prs.len();
// The virtual list's item count comes from `item_sizes`; rebuild it
// whenever the filtered pull request count changes.
if count != self.pr_len {
self.pr_len = count;
self.item_sizes = Rc::new(vec![size(px(0.), px(PR_ROW_HEIGHT)); count]);
}
let sizes = self.item_sizes.clone();
let scroll_handle = self.scroll_handle.clone();
let view = cx.entity().clone();
v_flex()
.size_full()
.image_cache(image_cache("pull-requests", MAX_IMAGES))
.child(self.render_header(cx))
.child(
v_flex()
.relative()
.flex_1()
.min_h_0()
.w_full()
.when(count > 0, |this| {
this.child(
v_virtual_list(view, "prl", sizes, move |this, range, _window, cx| {
range
.map(|ix| {
let pr_ix = this.visible_prs[ix];
this.render_row(ix, pr_ix, cx)
})
.collect()
})
.track_scroll(&scroll_handle)
.size_full(),
)
.child(
div()
.absolute()
.top_0()
.left_0()
.right_0()
.bottom_0()
.child(Scrollbar::vertical(&scroll_handle)),
)
})
.when(count == 0, |this| {
let message = match filter {
PullRequestFilter::All => "No pull requests",
PullRequestFilter::Open => "No open pull requests",
PullRequestFilter::Closed => "No closed pull requests",
PullRequestFilter::Draft => "No draft pull requests",
PullRequestFilter::Merged => "No merged pull requests",
};
this.child(placeholder(message, cx))
}),
)
.into_any_element()
}
}
+260
View File
@@ -0,0 +1,260 @@
use std::rc::Rc;
use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle};
use gpui::prelude::*;
use gpui::{
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
SharedString, Size, Subscription, WeakEntity, Window, div, px, size,
};
use gpui_component::avatar::Avatar;
use gpui_component::scroll::Scrollbar;
use gpui_component::{
ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list,
};
use signed_core::Announcement;
use signed_state::{ProfileStore, RepoListStore, Timestamp};
use utils::relative_time;
use super::RepoDetailView;
use crate::image_cache::{MAX_IMAGES, image_cache};
const CARD_HEIGHT: f32 = 160.;
/// Browse all announced repositories (works anonymously).
pub struct RepoListView {
store: Entity<RepoListStore>,
dock_area: WeakEntity<DockArea>,
focus_handle: FocusHandle,
scroll_handle: VirtualListScrollHandle,
item_sizes: Rc<Vec<Size<Pixels>>>,
_subscription: Subscription,
}
impl RepoListView {
pub fn new(
dock_area: WeakEntity<DockArea>,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let store = cx.new(|cx| RepoListStore::new(None, cx));
let subscription = cx.observe(&store, |this, store, cx| {
let count = store.read(cx).announcements.len();
if this.item_sizes.len() != count {
this.item_sizes = Rc::new(vec![size(px(0.), px(CARD_HEIGHT)); count]);
}
});
Self {
store,
dock_area,
focus_handle: cx.focus_handle(),
scroll_handle: VirtualListScrollHandle::new(),
item_sizes: Rc::new(vec![]),
_subscription: subscription,
}
}
fn open_repo(
&mut self,
announcement: &Announcement,
window: &mut Window,
cx: &mut Context<Self>,
) {
let dock_area = self.dock_area.clone();
let detail =
cx.new(|cx| RepoDetailView::new(dock_area.clone(), announcement.clone(), window, cx));
if let Some(dock_area) = dock_area.upgrade() {
dock_area.update(cx, |dock_area, cx| {
dock_area.add_panel_view(
panel_handle(detail),
DockPlacement::Center,
None,
window,
cx,
);
});
}
}
fn render_card(
&self,
ix: usize,
announcement: &Announcement,
last_activity: Option<Timestamp>,
cx: &mut Context<Self>,
) -> AnyElement {
let profile_store = ProfileStore::global(cx);
let owner = profile_store.read(cx).get(&announcement.owner);
let name = announcement
.name
.clone()
.unwrap_or_else(|| SharedString::from(announcement.id.clone()));
let description = announcement
.description
.clone()
.unwrap_or(SharedString::from("No description"));
let activity = last_activity
.map(relative_time)
.map(|label| SharedString::from(format!("Updated {label}")))
.unwrap_or_default();
v_flex()
.id(ix)
.px_4()
.w_full()
.border_b(px(1.))
.border_color(cx.theme().border)
.hover(|this| this.bg(cx.theme().list_hover))
.child(
h_flex()
.h_12()
.text_sm()
.font_semibold()
.whitespace_nowrap()
.text_ellipsis()
.child(name),
)
.child(
div()
.h_16()
.text_sm()
.text_color(cx.theme().muted_foreground)
.line_clamp(2)
.child(description),
)
.child(
h_flex()
.h_12()
.gap_2()
.items_center()
.overflow_hidden()
.child(
h_flex()
.gap_2()
.items_center()
.child(
Avatar::new()
.name(owner.name())
.when_some(owner.picture(), |this, url| this.src(url))
.rounded(cx.theme().radius)
.small(),
)
.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.whitespace_nowrap()
.child(owner.name()),
),
)
.child(
h_flex()
.flex_1()
.justify_end()
.text_xs()
.text_color(cx.theme().muted_foreground)
.whitespace_nowrap()
.child(activity),
),
)
.on_click(cx.listener({
let announcement = announcement.clone();
move |this, _ev, window, cx| {
this.open_repo(&announcement, window, cx);
}
}))
.into_any_element()
}
}
impl BasePanel for RepoListView {
fn panel_name(&self) -> &'static str {
"repo_list"
}
}
impl Panel for RepoListView {
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().text_sm().child(SharedString::from("Explore"))
}
}
impl EventEmitter<PanelEvent> for RepoListView {}
impl Focusable for RepoListView {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for RepoListView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let announcements = self.store.read(cx).announcements.clone();
let last_activity = self.store.read(cx).last_activity.clone();
let has_announcements = !announcements.is_empty();
let count = announcements.len();
v_flex()
.relative()
.image_cache(image_cache("repos", MAX_IMAGES))
.size_full()
.child(
h_flex()
.px_4()
.py_2()
.items_center()
.child(div().text_sm().font_semibold().child("Repositories"))
.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(format!(" ({count})"))),
),
)
.when(!has_announcements, |this| {
this.child(
v_flex().size_full().items_center().justify_center().child(
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child("No repositories found. Waiting for relays..."),
),
)
})
.when(has_announcements, |this| {
let view = cx.entity().clone();
let sizes = self.item_sizes.clone();
this.child(
v_virtual_list(view, "repos", sizes, move |this, range, _window, cx| {
let mut items = vec![];
for ix in range {
let announcement: &Announcement = &announcements[ix];
let activity = last_activity.get(&announcement.addr()).copied();
items.push(this.render_card(ix, announcement, activity, cx));
}
items
})
.track_scroll(&self.scroll_handle)
.size_full(),
)
})
.child(
div()
.absolute()
.top_0()
.left_0()
.right_0()
.bottom_0()
.child(Scrollbar::vertical(&self.scroll_handle)),
)
}
}
@@ -0,0 +1,11 @@
use gpui::{App, Window, px};
use gpui_component::WindowExt;
/// Open the Import Identity dialog.
///
/// Currently a placeholder — the dialog only shows a title for now.
pub fn open(window: &mut Window, cx: &mut App) {
window.open_dialog(cx, move |dialog, _window, _cx| {
dialog.title("Import identity").width(px(400.))
});
}
+331
View File
@@ -0,0 +1,331 @@
use assets::CustomIconName;
use dock::{
BasePanel, DockArea, DockPlacement, Panel, PanelEvent, TAB_BAR_HEIGHT, panel_handle,
title_bar_drag_handlers,
};
use gpui::prelude::*;
use gpui::{
App, ClickEvent, Context, ElementId, EventEmitter, FocusHandle, Focusable, Render,
SharedString, StyleRefinement, Subscription, WeakEntity, Window, div, px,
};
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 super::RepoListView;
use crate::image_cache::{MAX_IMAGES, image_cache};
mod import_dialog;
mod onboarding_dialog;
pub(crate) mod passphrase_dialog;
use self::onboarding_dialog::OnboardingState;
/// Left-dock panel with navigation entries. Entries open content panels in
/// the dock area.
pub struct SidebarPanel {
focus_handle: FocusHandle,
dock_area: WeakEntity<DockArea>,
explore: Option<WeakEntity<RepoListView>>,
logged_in: bool,
_subscription: Subscription,
}
impl SidebarPanel {
pub fn new(dock_area: WeakEntity<DockArea>, cx: &mut Context<Self>) -> Self {
let backend = Backend::global(cx);
let logged_in = backend.read(cx).current_user().is_some();
let subscription = cx.subscribe(&backend, |this, backend, event, cx| {
match event {
BackendEvent::SignerChanged => {
this.logged_in = backend.read(cx).current_user().is_some();
}
BackendEvent::SignerRequired => {
this.logged_in = false;
}
_ => return,
}
cx.notify();
});
Self {
focus_handle: cx.focus_handle(),
dock_area,
explore: None,
logged_in,
_subscription: subscription,
}
}
/// Open the Explore (repository list) panel in the center of the dock
/// area. No-op if it's already open.
pub fn open_explore(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self
.explore
.as_ref()
.and_then(WeakEntity::upgrade)
.is_some()
{
return;
}
let panel = cx.new(|cx| RepoListView::new(self.dock_area.clone(), window, cx));
self.explore = Some(panel.downgrade());
let _ = self.dock_area.update(cx, |dock_area, cx| {
dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx);
});
}
/// Show the Onboarding dialog.
fn open_onboarding(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Enter desired name"));
let pass_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("Passphrase to protect your keys")
.masked(true)
});
let repass_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("Repeat passphrase")
.masked(true)
});
let state = cx.new(|_| OnboardingState::default());
onboarding_dialog::open(name_input, pass_input, repass_input, state, window, cx);
}
/// Show the Import Identity dialog.
fn open_import(&mut self, window: &mut Window, cx: &mut Context<Self>) {
import_dialog::open(window, cx);
}
/// Render the user avatar and name in the sidebar, wrapped in the window titlebar drag area.
fn render_user(
&self,
profile: &Profile,
window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
let name = profile.name();
let picture = profile.picture();
title_bar_drag_handlers(
h_flex()
.id("user")
.h(TAB_BAR_HEIGHT)
.when(cfg!(target_os = "macos"), |this| this.pl(px(80.)))
.child(
div().child(
Button::new("user").text().dropdown_caret(true).child(
h_flex()
.gap_1()
.child(
Avatar::new()
.name(name.clone())
.when_some(picture, |this, url| this.src(url))
.rounded(cx.theme().radius)
.small(),
)
.child(div().text_xs().font_semibold().child(name)),
),
),
),
window,
cx,
)
}
}
impl BasePanel for SidebarPanel {
fn panel_name(&self) -> &'static str {
"sidebar"
}
fn closable(&self, _cx: &App) -> bool {
false
}
}
impl Panel for SidebarPanel {
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
}
}
impl EventEmitter<PanelEvent> for SidebarPanel {}
impl Focusable for SidebarPanel {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for SidebarPanel {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
if !self.logged_in {
return v_flex()
.p_4()
.size_full()
.items_center()
.justify_center()
.gap_2()
.child(
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child("Sign in to continue"),
)
.child(
Button::new("onboarding")
.label("Join now")
.primary()
.w_full()
.on_click(
cx.listener(|this, _ev, window, cx| this.open_onboarding(window, cx)),
),
)
.child(
Button::new("import-identity")
.label("Import identity")
.secondary()
.w_full()
.on_click(
cx.listener(|this, _ev, window, cx| this.open_import(window, cx)),
),
);
}
let backend = Backend::global(cx);
let profile_store = ProfileStore::global(cx);
let profile = backend
.read(cx)
.current_user()
.map(|public_key| profile_store.read(cx).get(&public_key));
v_flex()
.size_full()
.justify_between()
.image_cache(image_cache("sidebar", MAX_IMAGES))
.bg(cx.theme().sidebar)
.text_color(cx.theme().sidebar_foreground)
.child(
div()
.flex_1()
.when_some(profile.as_ref(), |this, profile| {
this.child(self.render_user(profile, window, cx))
})
.child(
v_flex()
.px_2()
.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(),
),
),
),
),
)
.child(
v_flex()
.p_2()
.flex_shrink_0()
.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(
cx.listener(|this, _ev, window, cx| this.open_explore(window, cx)),
),
),
)
}
}
/// A single navigation entry in the sidebar: an icon and label with a hover
/// highlight and an optional click handler.
#[allow(clippy::type_complexity)]
#[derive(IntoElement)]
struct NavItem {
id: ElementId,
style: StyleRefinement,
icon: IconName,
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
where
I: Into<ElementId>,
L: Into<SharedString>,
{
Self {
id: id.into(),
icon,
label: label.into(),
style: StyleRefinement::default(),
on_click: None,
}
}
fn on_click(mut self, listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self {
self.on_click = Some(Box::new(listener));
self
}
}
impl RenderOnce for NavItem {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
h_flex()
.id(self.id)
.refine_style(&self.style)
.px_2()
.py_1()
.w_full()
.gap_2()
.rounded(cx.theme().radius)
.child(Icon::new(self.icon).small())
.child(div().text_sm().child(self.label))
.hover(|this| this.bg(cx.theme().list_hover))
.when_some(self.on_click, |this, listener| this.on_click(listener))
}
}
@@ -0,0 +1,136 @@
use gpui::prelude::*;
use gpui::{App, Entity, SharedString, Window, div, px};
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
use gpui_component::form::{field, v_form};
use gpui_component::input::{Input, InputState};
use gpui_component::{ActiveTheme, Disableable, WindowExt};
use signed_state::Backend;
/// Shared state for the Onboarding dialog, so async results can be rendered.
#[derive(Default)]
pub struct OnboardingState {
pub busy: bool,
pub error: Option<SharedString>,
}
/// Open the Onboarding dialog for creating a new identity.
///
/// The caller is responsible for creating the input and state entities and
/// passing them in. This function only builds the dialog UI and wires up
/// the continue-button handler.
pub fn open(
name_input: Entity<InputState>,
pass_input: Entity<InputState>,
repass_input: Entity<InputState>,
state: Entity<OnboardingState>,
window: &mut Window,
cx: &mut App,
) {
window.open_dialog(cx, move |dialog, _window, _cx| {
let name_input = name_input.clone();
let pass_input = pass_input.clone();
let repass_input = repass_input.clone();
let state = state.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();
content
.child(
DialogHeader::new()
.child(DialogTitle::new().child("Create identity"))
.child(
DialogDescription::new()
.child("Set up your Signed identity to get started."),
),
)
.child(
v_form()
.child(
field()
.label("Name")
.description("Max 255 characters")
.required(true)
.child(Input::new(&name_input)),
)
.child(
field()
.label("Passphrase")
.required(true)
.child(Input::new(&pass_input)),
)
.child(field().required(true).child(Input::new(&repass_input))),
)
.children(error.map(|message| {
div().text_sm().text_color(cx.theme().danger).child(message)
}))
.child(
DialogFooter::new().justify_end().child(
Button::new("continue")
.primary()
.label("Create new identity")
.tooltip("Create identity")
.loading(busy)
.disabled(busy)
.on_click({
let name_input = name_input.clone();
let pass_input = pass_input.clone();
let repass_input = repass_input.clone();
let state = state.clone();
move |_ev, window, cx| {
let backend = Backend::global(cx);
let name = name_input.read(cx).value().to_string();
let pass = pass_input.read(cx).value().to_string();
let repass = repass_input.read(cx).value().to_string();
if pass != repass {
state.update(cx, |state, _| {
state.busy = false;
state.error =
Some("Passphrases do not match".into());
});
return;
}
state.update(cx, |state, _| {
state.busy = true;
state.error = None;
});
let task = backend.update(cx, |backend, cx| {
backend.create_identity(&name, &pass, cx)
});
let handle = window.window_handle();
let state = state.clone();
cx.spawn(async move |cx| match task.await {
Ok(_) => {
cx.update_window(handle, |_, window, cx| {
window.close_dialog(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();
}
}),
),
)
})
});
}
@@ -0,0 +1,146 @@
use assets::CustomIconName;
use gpui::prelude::*;
use gpui::{AnyWindowHandle, App, Entity, SharedString, Subscription, Window, div};
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
use gpui_component::form::{field, v_form};
use gpui_component::input::{Input, InputEvent, InputState};
use gpui_component::{ActiveTheme, Disableable, WindowExt};
use signed_state::Backend;
/// Shared state for the passphrase dialog, so async results can be rendered.
#[derive(Default)]
pub struct PassphraseState {
pub busy: bool,
pub error: Option<SharedString>,
/// Keeps the Enter-to-submit subscription alive while the dialog is open.
_enter_subscription: Option<Subscription>,
}
/// Open the dialog asking for the passphrase that protects the stored
/// NIP-49 encrypted identity (`ncryptsec1...`).
///
/// Called when the backend emits [`signed_state::BackendEvent::PassphraseRequired`].
pub fn open(window: &mut Window, cx: &mut App) {
let pass_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("Passphrase to unlock your identity")
.masked(true)
});
let handle = window.window_handle();
let state = cx.new(|_| PassphraseState::default());
// Enter in the passphrase field submits, same as the Unlock button.
let enter_pass_input = pass_input.clone();
let enter_state = state.clone();
let enter_subscription = cx.subscribe(&pass_input, move |_input, event, cx| {
if matches!(event, InputEvent::PressEnter { .. }) {
unlock(&enter_pass_input, &enter_state, &handle, cx);
}
});
state.update(cx, |state, _| {
state._enter_subscription = Some(enter_subscription)
});
window.open_dialog(cx, move |dialog, _window, _cx| {
let pass_input = pass_input.clone();
let state = state.clone();
dialog
.close_button(false)
.overlay_closable(false)
.keyboard(false)
.content(move |content, _window, cx| {
let busy = state.read(cx).busy;
let error = state.read(cx).error.clone();
content
.child(
DialogHeader::new()
.child(DialogTitle::new().child("Unlock your identity"))
.child(
DialogDescription::new()
.child("Enter the passphrase used to encrypt this identity."),
),
)
.child(
v_form().child(
field()
.label("Passphrase")
.required(true)
.child(Input::new(&pass_input)),
),
)
.children(error.map(|message| {
div().text_sm().text_color(cx.theme().danger).child(message)
}))
.child(
DialogFooter::new().justify_end().child(
Button::new("unlock")
.primary()
.icon(CustomIconName::Unlock)
.label("Unlock")
.loading(busy)
.disabled(busy)
.on_click({
let pass_input = pass_input.clone();
let state = state.clone();
move |_ev, _window, cx| {
unlock(&pass_input, &state, &handle, cx);
}
}),
),
)
})
});
}
/// Submit the passphrase to the backend. On success the dialog is closed;
/// on failure the error is rendered inline and the dialog stays open.
fn unlock(
pass_input: &Entity<InputState>,
state: &Entity<PassphraseState>,
handle: &AnyWindowHandle,
cx: &mut App,
) {
let backend = Backend::global(cx);
let pass = pass_input.read(cx).value().to_string();
if pass.is_empty() {
state.update(cx, |state, _| {
state.error = Some("Passphrase must not be empty".into());
});
return;
}
state.update(cx, |state, _| {
state.busy = true;
state.error = None;
});
let task = backend.update(cx, |backend, cx| backend.restore_with_passphrase(&pass, cx));
let handle = *handle;
let state = state.clone();
cx.spawn(async move |cx| match task.await {
Ok(_) => {
cx.update_window(handle, |_this, window, cx| {
window.close_dialog(cx);
})
.ok();
}
Err(e) => {
cx.update_window(handle, |_this, _window, cx| {
state.update(cx, |state, _| {
state.busy = false;
state.error = Some(e.to_string().into());
});
})
.ok();
}
})
.detach();
}
+125
View File
@@ -0,0 +1,125 @@
use dock::{DockArea, DockEvent, DockLayout, DockPlacement, SignedDockSkin, panel_handle};
use gpui::prelude::*;
use gpui::{Context, Entity, Render, Subscription, Window, div, px};
use gpui_component::{Root, StyledExt, Theme};
use signed_state::{Backend, BackendEvent};
use crate::image_cache::{MAX_IMAGES, image_cache};
use crate::views::SidebarPanel;
use crate::views::sidebar::passphrase_dialog;
/// Root view of the app: dock area (whose center tab bar doubles as the
/// window title bar), overlays.
pub struct Workspace {
dock: Entity<DockArea>,
_subscriptions: Vec<Subscription>,
_passphrase_subscription: Subscription,
}
impl Workspace {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let dock = cx.new(|cx| {
let skin = SignedDockSkin::new(cx);
DockArea::new("dock", Some(1), window, cx).with_renderer(skin)
});
let weak_dock = dock.downgrade();
let sidebar = cx.new(|cx| SidebarPanel::new(weak_dock.clone(), cx));
let weak_sidebar = sidebar.downgrade();
dock.update(cx, |dock_area, cx| {
dock_area.set_dock(
DockPlacement::Left,
DockLayout::tabs().panel_view(panel_handle(sidebar), cx),
window,
cx,
);
dock_area.set_dock_size(DockPlacement::Left, px(240.), window, cx);
});
let backend = Backend::global(cx);
let mut subscriptions = vec![];
// A bottom/right dock whose last panel was dragged away is removed
// entirely: base keeps the emptied region, which would otherwise
// linger as a bare strip. Deferred, because the event arrives while
// the area is mid-update.
let dock_for_pruning = dock.clone();
subscriptions.push(cx.subscribe_in(
&dock,
window,
move |_, _, event: &DockEvent, window, cx| {
if !matches!(event, DockEvent::LayoutChanged) {
return;
}
let dock = dock_for_pruning.clone();
cx.spawn_in(window, async move |_, window| {
dock.update_in(window, |area, window, cx| {
for placement in [DockPlacement::Bottom, DockPlacement::Right] {
if area.is_empty(placement, cx) {
area.remove_dock(placement, window, cx);
}
}
})
.ok();
})
.detach();
},
));
subscriptions.push(cx.observe_window_appearance(window, |_this, window, cx| {
Theme::sync_system_appearance(Some(window), cx);
}));
// Ask for the passphrase when the stored identity is NIP-49
// encrypted. Subscribed via the window, since opening a dialog
// needs one.
let passphrase_subscription =
window.subscribe(&backend, cx, |_backend, event, window, cx| {
if matches!(event, BackendEvent::PassphraseRequired) {
passphrase_dialog::open(window, cx);
}
});
// The event may have fired before this window existed (the backend
// is initialized before the first window opens); fall back to the
// backend state in that case.
if backend.read(cx).passphrase_required() {
passphrase_dialog::open(window, cx);
}
// Open the explore panel after the sidebar has been initialized.
cx.defer_in(window, move |_, window, cx| {
weak_sidebar
.update(cx, |this, cx| {
this.open_explore(window, cx);
})
.ok();
});
Self {
dock,
_subscriptions: subscriptions,
_passphrase_subscription: passphrase_subscription,
}
}
}
impl Render for Workspace {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let dialog_layer = Root::render_dialog_layer(window, cx);
let notification_layer = Root::render_notification_layer(window, cx);
div()
.image_cache(image_cache("workspace", MAX_IMAGES))
.id("workspace")
.v_flex()
.size_full()
.child(self.dock.clone())
// Notifications
.children(notification_layer)
// Modals
.children(dialog_layer)
}
}