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
@@ -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()
}
}