feat: out-of-box experience #2

Merged
reya merged 64 commits from feat/ui into master 2026-08-25 13:23:08 +00:00
8 changed files with 796 additions and 579 deletions
Showing only changes of commit c1c7ddbca2 - Show all commits
Generated
+1
View File
@@ -10785,6 +10785,7 @@ version = "1.0.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"assets", "assets",
"gix",
"gpui", "gpui",
"gpui-component", "gpui-component",
"signed_core", "signed_core",
+45 -23
View File
@@ -240,17 +240,24 @@ fn open_with_cache(workdir: &Path) -> Result<gix::Repository> {
Ok(repo) Ok(repo)
} }
/// A [`FileCommit`] from a walk commit: author, message title and shortened id. /// A [`FileCommit`] from a walk commit: author, message title and shortened
fn file_commit(commit: &gix::Commit<'_>) -> Result<FileCommit> { /// id. `include_description` controls whether the message body is copied;
/// history lists never display it, so skipping it saves a string allocation
/// per listed commit (the diff panel fetches the full commit on demand).
fn file_commit(commit: &gix::Commit<'_>, include_description: bool) -> Result<FileCommit> {
let author = commit.author()?; let author = commit.author()?;
let message = commit.message()?; let message = commit.message()?;
Ok(FileCommit { Ok(FileCommit {
id: commit.id().shorten_or_id().to_string(), id: commit.id().shorten_or_id().to_string(),
summary: String::from_utf8_lossy(message.title).trim().to_string(), summary: String::from_utf8_lossy(message.title).trim().to_string(),
description: message description: if include_description {
message
.body .body
.map(|body| String::from_utf8_lossy(body).trim().to_string()) .map(|body| String::from_utf8_lossy(body).trim().to_string())
.filter(|body| !body.is_empty()), .filter(|body| !body.is_empty())
} else {
None
},
author: String::from_utf8_lossy(author.name).trim().to_string(), author: String::from_utf8_lossy(author.name).trim().to_string(),
time: author.time()?.seconds, time: author.time()?.seconds,
}) })
@@ -332,7 +339,7 @@ fn last_commits(repo: &gix::Repository, rels: &[PathBuf]) -> Result<Vec<(PathBuf
if blob.map(|entry| entry.id().detach()) != parent_blob.map(|entry| entry.id().detach()) if blob.map(|entry| entry.id().detach()) != parent_blob.map(|entry| entry.id().detach())
{ {
found.push((rel.clone(), file_commit(&commit)?)); found.push((rel.clone(), file_commit(&commit, true)?));
pending.swap_remove(ix); pending.swap_remove(ix);
} else { } else {
ix += 1; ix += 1;
@@ -381,7 +388,7 @@ pub fn all_commits(repo: &gix::Repository) -> Result<CommitList> {
let info = info?; let info = info?;
total += 1; total += 1;
if commits.len() < MAX_LISTED_COMMITS { if commits.len() < MAX_LISTED_COMMITS {
commits.push(file_commit(&info.object()?)?); commits.push(file_commit(&info.object()?, false)?);
} }
} }
Ok(CommitList { total, commits }) Ok(CommitList { total, commits })
@@ -681,23 +688,29 @@ pub fn head_commit(repo: &gix::Repository) -> Result<Option<FileCommit>> {
return Ok(None); return Ok(None);
}; };
let commit = head.object()?.into_commit(); let commit = head.object()?.into_commit();
let author = commit.author()?; Ok(Some(file_commit(&commit, true)?))
let message = commit.message()?;
Ok(Some(FileCommit {
id: commit.id().shorten_or_id().to_string(),
summary: String::from_utf8_lossy(message.title).trim().to_string(),
description: message
.body
.map(|body| String::from_utf8_lossy(body).trim().to_string())
.filter(|body| !body.is_empty()),
author: String::from_utf8_lossy(author.name).trim().to_string(),
time: author.time()?.seconds,
}))
} }
/// Short names of local branches (`refs/heads/*`), sorted alphabetically. /// Full metadata of the commit `id` (short or full) in the repository at
pub fn worktree_branches(workdir: &Path) -> Result<Vec<String>> { /// `workdir`, like [`head_commit`] for an arbitrary commit. Returns
/// `Ok(None)` when the id cannot be resolved.
///
/// The commit list ([`all_commits`]) omits message bodies to keep the walk
/// cheap; the diff panel uses this to fetch the full commit on demand.
pub fn worktree_commit(workdir: &Path, id: &str) -> Result<Option<FileCommit>> {
let repo = open_with_cache(workdir)?; let repo = open_with_cache(workdir)?;
match repo.rev_parse_single(id.as_bytes()) {
Ok(commit_id) => {
let commit = commit_id.object()?.into_commit();
Ok(Some(file_commit(&commit, true)?))
}
Err(_) => Ok(None),
}
}
/// Short names of local branches (`refs/heads/*`) of `repo`, sorted
/// alphabetically.
pub fn repo_branches(repo: &gix::Repository) -> Result<Vec<String>> {
let mut names = Vec::new(); let mut names = Vec::new();
for reference in repo.references()?.local_branches()? { for reference in repo.references()?.local_branches()? {
let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?;
@@ -707,9 +720,8 @@ pub fn worktree_branches(workdir: &Path) -> Result<Vec<String>> {
Ok(names) Ok(names)
} }
/// Short names of tags (`refs/tags/*`), sorted alphabetically. /// Short names of tags (`refs/tags/*`) of `repo`, sorted alphabetically.
pub fn worktree_tags(workdir: &Path) -> Result<Vec<String>> { pub fn repo_tags(repo: &gix::Repository) -> Result<Vec<String>> {
let repo = open_with_cache(workdir)?;
let mut names = Vec::new(); let mut names = Vec::new();
for reference in repo.references()?.tags()? { for reference in repo.references()?.tags()? {
let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?;
@@ -719,6 +731,16 @@ pub fn worktree_tags(workdir: &Path) -> Result<Vec<String>> {
Ok(names) Ok(names)
} }
/// Short names of local branches (`refs/heads/*`), sorted alphabetically.
pub fn worktree_branches(workdir: &Path) -> Result<Vec<String>> {
repo_branches(&open_with_cache(workdir)?)
}
/// Short names of tags (`refs/tags/*`), sorted alphabetically.
pub fn worktree_tags(workdir: &Path) -> Result<Vec<String>> {
repo_tags(&open_with_cache(workdir)?)
}
/// Short name of the branch HEAD points to, or `None` when detached (e.g. /// Short name of the branch HEAD points to, or `None` when detached (e.g.
/// after checking out a tag or a commit directly). /// after checking out a tag or a commit directly).
pub fn current_branch(repo: &gix::Repository) -> Result<Option<String>> { pub fn current_branch(repo: &gix::Repository) -> Result<Option<String>> {
+1
View File
@@ -13,5 +13,6 @@ utils = { path = "../utils" }
gpui.workspace = true gpui.workspace = true
gpui-component.workspace = true gpui-component.workspace = true
gix.workspace = true
anyhow.workspace = true anyhow.workspace = true
@@ -11,10 +11,10 @@ use gpui_component::list::ListItem;
use gpui_component::spinner::Spinner; use gpui_component::spinner::Spinner;
use gpui_component::text::{TextView, TextViewState}; use gpui_component::text::{TextView, TextViewState};
use gpui_component::tree::{TreeEntry, TreeState, tree}; use gpui_component::tree::{TreeEntry, TreeState, tree};
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex}; use gpui_component::{ActiveTheme, Sizable, StyledExt, h_flex, v_flex};
use super::RepoDetailView; use super::RepoDetailView;
use super::helpers::{code_language, is_markdown_path, placeholder}; use super::helpers::{code_language, is_markdown_path, placeholder, tree_row};
/// Width of the file explorer column. /// Width of the file explorer column.
const TREE_WIDTH: f32 = 240.; const TREE_WIDTH: f32 = 240.;
@@ -82,37 +82,10 @@ impl RepoDetailView {
selected: bool, selected: bool,
view: &WeakEntity<Self>, view: &WeakEntity<Self>,
) -> ListItem { ) -> ListItem {
let item = entry.item();
let id = item.id.clone();
let is_folder = entry.is_folder();
let icon = if is_folder {
if entry.is_expanded() {
IconName::FolderOpen
} else {
IconName::FolderClosed
}
} else {
IconName::File
};
let view = view.clone(); let view = view.clone();
let id = entry.item().id.clone();
ListItem::new(ix) tree_row(ix, entry, selected, move |window, cx| {
.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;
}
if let Some(view) = view.upgrade() { if let Some(view) = view.upgrade() {
view.update(cx, |this, cx| this.open_file(&id, window, cx)); view.update(cx, |this, cx| this.open_file(&id, window, cx));
} }
@@ -121,7 +94,6 @@ impl RepoDetailView {
/// Left column: the file tree. /// Left column: the file tree.
pub(super) fn render_tree_column( pub(super) fn render_tree_column(
&mut self,
tree_state: Entity<TreeState>, tree_state: Entity<TreeState>,
view: WeakEntity<Self>, view: WeakEntity<Self>,
cx: &mut Context<Self>, cx: &mut Context<Self>,
@@ -143,7 +115,7 @@ impl RepoDetailView {
/// Right column: README, selected file preview, or status text. /// Right column: README, selected file preview, or status text.
pub(super) fn render_content_column( pub(super) fn render_content_column(
&mut self, &self,
pane_title: SharedString, pane_title: SharedString,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> impl IntoElement { ) -> impl IntoElement {
@@ -190,12 +162,7 @@ impl RepoDetailView {
Some(FileContent::Binary) => placeholder("Binary file — preview not supported", cx), Some(FileContent::Binary) => placeholder("Binary file — preview not supported", cx),
Some(FileContent::TooLarge) => placeholder("File is too large to preview", cx), Some(FileContent::TooLarge) => placeholder("File is too large to preview", cx),
Some(FileContent::Failed(message)) => placeholder(message, cx), Some(FileContent::Failed(message)) => placeholder(message, cx),
None => v_flex() None => preview_spinner(),
.size_full()
.items_center()
.justify_center()
.child(Spinner::new().small())
.into_any_element(),
} }
} else if self.readme_name.is_some() { } else if self.readme_name.is_some() {
self.markdown_element(None, cx) self.markdown_element(None, cx)
@@ -206,19 +173,13 @@ impl RepoDetailView {
// Latest commit for the current pane: the selected file, or the README // Latest commit for the current pane: the selected file, or the README
// while nothing is selected. Computed after the body above, which // while nothing is selected. Computed after the body above, which
// needs `&mut self`. // needs `&mut self`.
let commit = self let commit = match &self.selected_file {
.selected_file Some(path) => self.commits.get(path.as_ref()),
None => self
.readme_name
.as_ref() .as_ref()
.and_then(|path| self.commits.get(path.as_ref())) .and_then(|name| self.commits.get(name.as_ref())),
.or_else(|| { };
if self.selected_file.is_none() {
self.readme_name
.as_ref()
.and_then(|name| self.commits.get(name.as_ref()))
} else {
None
}
});
v_flex() v_flex()
.flex_1() .flex_1()
@@ -285,7 +246,7 @@ impl RepoDetailView {
/// The persistent markdown TextView for `path` (`None` = README), or a /// The persistent markdown TextView for `path` (`None` = README), or a
/// spinner while the document is being loaded/parsed. /// spinner while the document is being loaded/parsed.
fn markdown_element(&mut self, path: Option<&str>, _cx: &mut Context<Self>) -> AnyElement { fn markdown_element(&self, path: Option<&str>, _cx: &mut Context<Self>) -> AnyElement {
let Some(md) = &self.md else { let Some(md) = &self.md else {
return preview_spinner(); return preview_spinner();
}; };
@@ -325,7 +286,7 @@ impl RepoDetailView {
/// The persistent code editor for `path`, or a spinner while the file is /// The persistent code editor for `path`, or a spinner while the file is
/// being loaded/parsed. /// being loaded/parsed.
fn code_element(&mut self, path: &str, _cx: &mut Context<Self>) -> AnyElement { fn code_element(&self, path: &str, _cx: &mut Context<Self>) -> AnyElement {
let Some(code) = &self.code else { let Some(code) = &self.code else {
return preview_spinner(); return preview_spinner();
}; };
@@ -25,7 +25,9 @@ fn commit_row(
cx: &App, cx: &App,
) -> AnyElement { ) -> AnyElement {
let view = view.clone(); let view = view.clone();
let commit = commit.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() h_flex()
.id(ix) .id(ix)
@@ -76,7 +78,7 @@ fn commit_row(
) )
.on_click(move |_event, window, cx| { .on_click(move |_event, window, cx| {
if let Some(view) = view.upgrade() { if let Some(view) = view.upgrade() {
view.update(cx, |this, cx| this.open_commit_diff(&commit, window, cx)); view.update(cx, |this, cx| this.open_commit_diff(&id, window, cx));
} }
}) })
.into_any_element() .into_any_element()
@@ -85,7 +87,7 @@ fn commit_row(
impl RepoDetailView { impl RepoDetailView {
/// Full-height body of the Commits tab: all commits in a virtual /// Full-height body of the Commits tab: all commits in a virtual
/// list, or a status message while loading / when there are none. /// list, or a status message while loading / when there are none.
pub(super) fn render_commits_tab(&mut self, cx: &mut Context<Self>) -> AnyElement { pub(super) fn render_commits_tab(&self, cx: &mut Context<Self>) -> AnyElement {
let Some(list) = self.all_commits.as_ref() else { let Some(list) = self.all_commits.as_ref() else {
return if self.loading_all_commits { return if self.loading_all_commits {
v_flex() v_flex()
+175 -96
View File
@@ -4,29 +4,48 @@
//! commit in the Commits tab or the latest-commit button in the header. //! commit in the Commits tab or the latest-commit button in the header.
use std::path::PathBuf; use std::path::PathBuf;
use std::rc::Rc;
use gpui::prelude::*; use gpui::prelude::*;
use gpui::{ use gpui::{
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString, AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
WeakEntity, Window, div, px, ScrollStrategy, SharedString, Size, WeakEntity, Window, div, px, size,
}; };
use gpui_component::clipboard::Clipboard; use gpui_component::clipboard::Clipboard;
use gpui_component::dock::{Panel, PanelEvent}; use gpui_component::dock::{Panel, PanelEvent};
use gpui_component::list::ListItem; use gpui_component::list::ListItem;
use gpui_component::resizable::{resizable_panel, v_resizable}; use gpui_component::resizable::{resizable_panel, v_resizable};
use gpui_component::scroll::{ScrollableElement, Scrollbar};
use gpui_component::spinner::Spinner; use gpui_component::spinner::Spinner;
use gpui_component::tag::Tag; use gpui_component::tag::Tag;
use gpui_component::tree::{TreeEntry, TreeItem, TreeState, tree}; use gpui_component::tree::{TreeEntry, TreeItem, TreeState, tree};
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex}; use gpui_component::{
ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list,
};
use signed_git::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileCommit, FileDiff}; use signed_git::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileCommit, FileDiff};
use utils::relative_time_secs; use utils::relative_time_secs;
use super::helpers::{build_tree_items, placeholder, tree_items}; use super::helpers::{build_tree_items, placeholder, track, tree_items, tree_row};
/// Width of the changed-files column. /// Width of the changed-files column.
const TREE_WIDTH: f32 = 260.; const TREE_WIDTH: f32 = 260.;
/// Width of one line-number gutter in a diff row. /// Width of one line-number gutter in a diff row.
const GUTTER_WIDTH: f32 = 44.; const GUTTER_WIDTH: f32 = 44.;
/// Height of one row in the virtual diff list.
const DIFF_ROW_HEIGHT: f32 = 20.;
/// One row of the virtual diff list: a hunk header, or a line of a hunk.
#[derive(Clone, Copy)]
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 },
}
/// Detail panel showing the diff of one commit. /// Detail panel showing the diff of one commit.
pub struct CommitDiffView { pub struct CommitDiffView {
@@ -35,7 +54,9 @@ pub struct CommitDiffView {
worktree: PathBuf, worktree: PathBuf,
/// Display name of the repository the commit belongs to. /// Display name of the repository the commit belongs to.
repo_name: SharedString, repo_name: SharedString,
/// The commit being shown (header and tab title). /// 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, commit: FileCommit,
/// Loaded diff; `None` while loading or after a failure. /// Loaded diff; `None` while loading or after a failure.
diff: Option<CommitDiff>, diff: Option<CommitDiff>,
@@ -46,7 +67,14 @@ pub struct CommitDiffView {
tree_state: Entity<TreeState>, tree_state: Entity<TreeState>,
/// Path of the file whose diff is shown in the detail column. /// Path of the file whose diff is shown in the detail column.
selected_file: Option<SharedString>, selected_file: Option<SharedString>,
/// In-flight tasks; pruned on every push (see [`Self::track`]). /// 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>>>, tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
} }
@@ -54,7 +82,7 @@ impl CommitDiffView {
pub fn new( pub fn new(
worktree: PathBuf, worktree: PathBuf,
repo_name: SharedString, repo_name: SharedString,
commit: FileCommit, commit_id: String,
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Self { ) -> Self {
@@ -69,17 +97,27 @@ impl CommitDiffView {
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
worktree, worktree,
repo_name, repo_name,
commit, commit: FileCommit {
id: commit_id,
summary: String::new(),
description: None,
author: String::new(),
time: 0,
},
diff: None, diff: None,
loading: true, loading: true,
error: None, error: None,
tree_state, tree_state,
selected_file: None, selected_file: None,
rows: Vec::new(),
item_sizes: Rc::new(Vec::new()),
scroll_handle: VirtualListScrollHandle::new(),
tasks: Vec::new(), tasks: Vec::new(),
} }
} }
/// Load the commit diff on a background task and populate the tree. /// 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>) { fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.loading = true; self.loading = true;
self.error = None; self.error = None;
@@ -89,13 +127,27 @@ impl CommitDiffView {
let id = self.commit.id.clone(); let id = self.commit.id.clone();
let task = cx.spawn_in(window, async move |this, cx| { let task = cx.spawn_in(window, async move |this, cx| {
let result = cx let commit = cx
.background_spawn(async move { signed_git::worktree_commit_diff(&worktree, &id) }) .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; .await;
this.update_in(cx, |this, _window, cx| { this.update_in(cx, |this, _window, cx| {
this.loading = false; this.loading = false;
match result { if let Ok(Some(commit)) = commit {
this.commit = commit;
}
match diff {
Ok(diff) => { Ok(diff) => {
let mut paths: Vec<PathBuf> = diff let mut paths: Vec<PathBuf> = diff
.files .files
@@ -113,8 +165,11 @@ impl CommitDiffView {
let item = find_item(&items, first.as_deref()); let item = find_item(&items, first.as_deref());
state.set_selected_item(item, cx); state.set_selected_item(item, cx);
}); });
this.selected_file = first; this.selected_file = first.clone();
this.diff = Some(diff); this.diff = Some(diff);
if let Some(path) = first {
this.set_diff_rows(path.as_ref());
}
} }
Err(error) => { Err(error) => {
this.error = Some(error.to_string().into()); this.error = Some(error.to_string().into());
@@ -126,20 +181,28 @@ impl CommitDiffView {
Ok(()) Ok(())
}); });
self.track(task); track(&mut self.tasks, task);
} }
/// Show the diff of the file at `path` (selected in the tree). /// Show the diff of the file at `path` (selected in the tree).
fn select_file(&mut self, path: &str, cx: &mut Context<Self>) { fn select_file(&mut self, path: &str, cx: &mut Context<Self>) {
self.selected_file = Some(path.into()); self.selected_file = Some(path.into());
self.set_diff_rows(path);
cx.notify(); cx.notify();
} }
/// Track `task` until it completes; finished tasks are pruned on every /// Rebuild the virtual list state for the file at `path` and scroll back
/// push so the vec stays bounded by the number of in-flight loads. /// to the top.
fn track(&mut self, task: gpui::Task<Result<(), anyhow::Error>>) { fn set_diff_rows(&mut self, path: &str) {
self.tasks.retain(|task| !task.is_ready()); let Some(diff) = self.diff.as_ref() else {
self.tasks.push(task); 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. /// One row of the changed-files tree: icon + name, indented by depth.
@@ -149,37 +212,10 @@ impl CommitDiffView {
selected: bool, selected: bool,
view: &WeakEntity<Self>, view: &WeakEntity<Self>,
) -> ListItem { ) -> ListItem {
let item = entry.item();
let id = item.id.clone();
let is_folder = entry.is_folder();
let icon = if is_folder {
if entry.is_expanded() {
IconName::FolderOpen
} else {
IconName::FolderClosed
}
} else {
IconName::File
};
let view = view.clone(); let view = view.clone();
let id = entry.item().id.clone();
ListItem::new(ix) tree_row(ix, entry, selected, move |_window, cx| {
.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;
}
if let Some(view) = view.upgrade() { if let Some(view) = view.upgrade() {
view.update(cx, |this, cx| this.select_file(&id, cx)); view.update(cx, |this, cx| this.select_file(&id, cx));
} }
@@ -187,7 +223,7 @@ impl CommitDiffView {
} }
/// Left column: the changed-files tree. /// Left column: the changed-files tree.
fn render_tree_column(&mut self, cx: &mut Context<Self>) -> AnyElement { fn render_tree_column(&self, cx: &mut Context<Self>) -> AnyElement {
let tree_state = self.tree_state.clone(); let tree_state = self.tree_state.clone();
let view = cx.entity().downgrade(); let view = cx.entity().downgrade();
@@ -210,26 +246,14 @@ impl CommitDiffView {
) )
}) })
.when(self.diff.is_none() && !self.loading, |this| { .when(self.diff.is_none() && !self.loading, |this| {
this.child( this.child(placeholder("Failed to load diff", cx))
v_flex()
.size_full()
.items_center()
.justify_center()
.p_4()
.child(
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child("Failed to load diff"),
),
)
}), }),
) )
.into_any_element() .into_any_element()
} }
/// Right column: header of the selected file plus its diff. /// Right column: header of the selected file plus its diff.
fn render_detail_column(&mut self, cx: &mut Context<Self>) -> AnyElement { fn render_detail_column(&self, cx: &mut Context<Self>) -> AnyElement {
if self.loading { if self.loading {
return v_flex() return v_flex()
.size_full() .size_full()
@@ -254,11 +278,12 @@ impl CommitDiffView {
let Some(file) = diff.files.iter().find(|file| file.path == path.as_ref()) else { let Some(file) = diff.files.iter().find(|file| file.path == path.as_ref()) else {
return placeholder("File not found", cx); return placeholder("File not found", cx);
}; };
self.render_file_diff(file, cx) self.render_file_diff(file, cx.entity(), cx)
} }
/// The diff of one file: a header with status and stats, then the hunks. /// The diff of one file: a header with status and stats, then the hunks
fn render_file_diff(&self, file: &FileDiff, cx: &App) -> AnyElement { /// 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 { let status_label = match file.status {
DiffStatus::Added => "A", DiffStatus::Added => "A",
DiffStatus::Modified => "M", DiffStatus::Modified => "M",
@@ -282,11 +307,44 @@ impl CommitDiffView {
} else if file.hunks.is_empty() { } else if file.hunks.is_empty() {
placeholder("No content changes", cx) placeholder("No content changes", cx)
} else { } else {
let sizes = self.item_sizes.clone();
let scroll_handle = self.scroll_handle.clone();
v_flex() v_flex()
.w_full() .size_full()
.font_family(cx.theme().mono_font_family.clone()) .relative()
.text_xs() .child(
.children(file.hunks.iter().map(|hunk| Self::render_hunk(hunk, cx))) 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| Self::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() .into_any_element()
}; };
@@ -335,41 +393,35 @@ impl CommitDiffView {
) )
}), }),
) )
.child( .child(div().id("commit-diff-body").flex_1().min_h_0().child(body))
div()
.id("commit-diff-body")
.flex_1()
.min_h_0()
.overflow_scroll()
.child(body),
)
.into_any_element() .into_any_element()
} }
/// One hunk: the `@@ -a,b +c,d @@` header row followed by its lines. /// One row of the virtual diff list: a hunk header or a single line.
fn render_hunk(hunk: &DiffHunk, cx: &App) -> AnyElement { fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> AnyElement {
v_flex() match row {
.w_full() DiffRow::Hunk {
.child( old_start,
div() old_lines,
new_start,
new_lines,
} => div()
.px_2() .px_2()
.py_0p5()
.w_full() .w_full()
.h(px(DIFF_ROW_HEIGHT))
.font_family(cx.theme().mono_font_family.clone())
.text_xs()
.bg(cx.theme().muted) .bg(cx.theme().muted)
.border_y(px(1.)) .border_y(px(1.))
.border_color(cx.theme().border) .border_color(cx.theme().border)
.text_color(cx.theme().muted_foreground) .text_color(cx.theme().muted_foreground)
.child(SharedString::from(format!( .child(SharedString::from(format!(
"@@ -{},{} +{},{} @@", "@@ -{},{} +{},{} @@",
hunk.old_start, hunk.old_lines, hunk.new_start, hunk.new_lines old_start, old_lines, new_start, new_lines
))), )))
) .into_any_element(),
.children( DiffRow::Line { hunk, line } => Self::render_diff_line(&hunks[hunk].lines[line], cx),
hunk.lines }
.iter()
.map(|line| Self::render_diff_line(line, cx)),
)
.into_any_element()
} }
/// One diff line: old and new line numbers in gutters, then the content, /// One diff line: old and new line numbers in gutters, then the content,
@@ -382,8 +434,14 @@ impl CommitDiffView {
}; };
let gutter = cx.theme().muted_foreground; 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() h_flex()
.w_full() .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)) .when_some(bg, |this, bg| this.bg(bg))
.child( .child(
div() div()
@@ -407,6 +465,8 @@ impl CommitDiffView {
div() div()
.flex_1() .flex_1()
.min_w_0() .min_w_0()
.overflow_hidden()
.whitespace_nowrap()
.text_color(cx.theme().foreground) .text_color(cx.theme().foreground)
.child(line.text.clone()), .child(line.text.clone()),
) )
@@ -414,7 +474,7 @@ impl CommitDiffView {
} }
/// Header: commit id, summary, author/time and overall change stats. /// Header: commit id, summary, author/time and overall change stats.
fn render_header(&mut self, cx: &mut Context<Self>) -> AnyElement { fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
let commit = &self.commit; let commit = &self.commit;
let (files, insertions, deletions) = self.diff.as_ref().map_or((0, 0, 0), |diff| { let (files, insertions, deletions) = self.diff.as_ref().map_or((0, 0, 0), |diff| {
( (
@@ -484,10 +544,11 @@ impl CommitDiffView {
Tag::danger() Tag::danger()
.outline() .outline()
.small() .small()
.child(format!("- {insertions}")), .child(format!("- {deletions}")),
) )
}), }),
) )
.overflow_y_scrollbar()
.into_any_element() .into_any_element()
} }
} }
@@ -504,6 +565,24 @@ fn find_item<'a>(items: &'a [TreeItem], id: Option<&str>) -> Option<&'a TreeItem
}) })
} }
/// The rows of `file`'s diff: one header row per hunk, then its lines.
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
}
impl Panel for CommitDiffView { impl Panel for CommitDiffView {
fn panel_name(&self) -> &'static str { fn panel_name(&self) -> &'static str {
"commit_diff" "commit_diff"
@@ -4,10 +4,12 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use anyhow::Error;
use gpui::prelude::*; use gpui::prelude::*;
use gpui::{AnyElement, App, div}; use gpui::{AnyElement, App, Task, Window, div, px};
use gpui_component::tree::TreeItem; use gpui_component::list::ListItem;
use gpui_component::{ActiveTheme, v_flex}; use gpui_component::tree::{TreeEntry, TreeItem};
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex, v_flex};
/// A `Send` file-tree node: the tree is built on a background thread and /// 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 /// converted into [`TreeItem`]s (which hold `Rc` state, so they cannot
@@ -20,14 +22,6 @@ pub(super) struct TreeItemSeed {
children: Vec<TreeItemSeed>, children: Vec<TreeItemSeed>,
} }
impl From<TreeItemSeed> for TreeItem {
fn from(seed: TreeItemSeed) -> Self {
let mut item = TreeItem::new(seed.id, seed.label);
item.children = seed.children.into_iter().map(Into::into).collect();
item
}
}
/// Convert tree seeds into [`TreeItem`]s, expanding every folder when /// Convert tree seeds into [`TreeItem`]s, expanding every folder when
/// `expand_folders` is set. /// `expand_folders` is set.
/// ///
@@ -54,6 +48,51 @@ pub(super) fn tree_items(seeds: Vec<TreeItemSeed>, expand_folders: bool) -> Vec<
.collect() .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);
})
}
/// Track `task` until it completes; finished tasks are pruned on every push
/// so the vec stays bounded by the number of in-flight loads.
pub(super) fn track(tasks: &mut Vec<Task<Result<(), Error>>>, task: Task<Result<(), Error>>) {
tasks.retain(|task| !task.is_ready());
tasks.push(task);
}
/// Build nested tree items from a flat, sorted (dirs-first) entry list. /// 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 /// Returns [`TreeItemSeed`]s so the build can run off the main thread; a
@@ -259,10 +298,7 @@ mod tests {
PathBuf::from("README.md"), PathBuf::from("README.md"),
]; ];
let items: Vec<TreeItem> = build_tree_items(&entries) let items: Vec<TreeItem> = tree_items(build_tree_items(&entries), false);
.into_iter()
.map(Into::into)
.collect();
assert_eq!(items.len(), 2); assert_eq!(items.len(), 2);
assert_eq!(items[0].label, "src"); assert_eq!(items[0].label, "src");
assert_eq!(items[0].children.len(), 1); assert_eq!(items[0].children.len(), 1);
+294 -179
View File
@@ -5,6 +5,7 @@ use std::sync::Arc;
use anyhow::Error; use anyhow::Error;
use assets::CustomIconName; use assets::CustomIconName;
use gix::Repository;
use gpui::prelude::*; use gpui::prelude::*;
use gpui::{ use gpui::{
AnyElement, App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, AnyElement, App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels,
@@ -17,7 +18,7 @@ use gpui_component::menu::PopupMenuItem;
use gpui_component::searchable_list::SearchableVec; use gpui_component::searchable_list::SearchableVec;
use gpui_component::tab::{Tab, TabBar}; use gpui_component::tab::{Tab, TabBar};
use gpui_component::tag::Tag; use gpui_component::tag::Tag;
use gpui_component::tree::{TreeItem, TreeState}; use gpui_component::tree::TreeState;
use gpui_component::{ use gpui_component::{
ActiveTheme, Icon, IconName, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, ActiveTheme, Icon, IconName, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex,
}; };
@@ -36,7 +37,7 @@ use browser::{
}; };
use commits::COMMIT_ROW_HEIGHT; use commits::COMMIT_ROW_HEIGHT;
use diff::CommitDiffView; use diff::CommitDiffView;
use helpers::{build_tree_items, is_markdown_path}; use helpers::{TreeItemSeed, build_tree_items, is_markdown_path, track, tree_items};
/// What kind of ref the header selectors switch to. /// What kind of ref the header selectors switch to.
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq)]
@@ -47,6 +48,20 @@ enum RefKind {
Tag, Tag,
} }
/// Everything loaded from the local clone for the explorer: the tree seeds,
/// README, refs and HEAD commit. Computed on a background thread (see
/// [`load_repo_data`]) and applied on the main thread.
struct RepoData {
tree: Vec<TreeItemSeed>,
readme_path: Option<PathBuf>,
readme: Option<Vec<u8>>,
worktree: Option<PathBuf>,
branches: Vec<String>,
tags: Vec<String>,
current_branch: Option<String>,
head_commit: Option<FileCommit>,
}
/// Detail view of a repository: header, stats, a file explorer with README /// Detail view of a repository: header, stats, a file explorer with README
/// preview (cloned from the announcement's `clone` URLs), and metadata. /// preview (cloned from the announcement's `clone` URLs), and metadata.
pub struct RepoDetailView { pub struct RepoDetailView {
@@ -261,7 +276,11 @@ impl RepoDetailView {
} }
} }
/// Clone (or fetch) the repository and populate the file explorer. /// Load the repository and populate the file explorer. The local clone
/// (if any) is loaded first without touching the network, so an
/// unreachable server can't block the panel; a background fetch then
/// refreshes the refs and commit list (a fetch never changes the
/// checked-out files, so the tree and previews are left alone).
fn load_repo(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn load_repo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.loading = true; self.loading = true;
self.error = None; self.error = None;
@@ -270,70 +289,86 @@ impl RepoDetailView {
let cache = GitStore::global(cx).cache().clone(); let cache = GitStore::global(cx).cache().clone();
let addr = self.initial.addr(); let addr = self.initial.addr();
let clone_urls: Vec<String> = self.initial.clone.iter().map(ToString::to_string).collect(); let clone_urls: Vec<String> = self.initial.clone.iter().map(ToString::to_string).collect();
// Captured before the loads start: a branch/tag switch bumps it, and
// the refresh below is discarded when that happens.
let refresh_generation = self.ref_generation;
let load = cx.background_spawn(async move { let disk = {
let repo = cache.ensure_clone(&addr, &clone_urls)?; let cache = cache.clone();
let entries = signed_git::worktree_entries(&repo)?; let addr = addr.clone();
// The tree is built off the main thread; the seeds are plain cx.background_spawn(async move {
// owned strings and convert to `TreeItem`s (which hold `Rc` match cache.open(&addr)? {
// state) on the main thread. Some(repo) => Ok(Some(load_repo_data(&repo)?)),
let tree = build_tree_items(&entries); None => Ok(None),
let readme_path = signed_git::find_readme(&repo)?; }
let readme = match &readme_path { })
Some(path) => signed_git::worktree_read(&repo, path)?,
None => None,
}; };
let worktree = repo.workdir().map(Path::to_path_buf);
// Ref listing is auxiliary UI: a broken ref must not prevent the
// explorer from loading, so failures degrade to empty selectors.
let (branches, tags, current_branch) = match &worktree {
Some(worktree) => (
signed_git::worktree_branches(worktree).unwrap_or_default(),
signed_git::worktree_tags(worktree).unwrap_or_default(),
signed_git::current_branch(&repo).unwrap_or(None),
),
None => (Vec::new(), Vec::new(), None),
};
let head_commit = signed_git::head_commit(&repo).unwrap_or(None);
Ok::<_, Error>((
tree,
readme_path,
readme,
worktree,
branches,
tags,
current_branch,
head_commit,
))
});
let task = cx.spawn_in(window, async move |this, cx| { let task = cx.spawn_in(window, async move |this, cx| {
let result = load.await; let disk = disk.await;
let had_clone = matches!(&disk, Ok(Some(_)));
// No local clone yet: clone from the network (blocking), then load.
let data = match disk {
Ok(Some(data)) => Ok(data),
Ok(None) => {
let cache = cache.clone();
let addr = addr.clone();
let clone_urls = clone_urls.clone();
cx.background_spawn(async move {
let repo = cache.ensure_clone(&addr, &clone_urls)?;
load_repo_data(&repo)
})
.await
}
Err(error) => Err(error),
};
this.update_in(cx, |this, window, cx| { this.update_in(cx, |this, window, cx| {
match result { match data {
Ok(( Ok(data) => this.apply_repo_data(data, window, cx),
tree, Err(error) => this.error = Some(error.to_string().into()),
readme_path, }
readme, this.loading = false;
Some(worktree), cx.notify();
branches, })?;
tags,
current_branch,
head_commit,
)) => {
this.worktree = Some(worktree);
this.head_commit = head_commit;
this.tree_state.update(cx, |state, cx| {
state.set_items(
tree.into_iter().map(Into::into).collect::<Vec<TreeItem>>(),
cx,
);
});
// Populate the branch/tag selectors with the local // Refresh the clone from the network in the background; when it
// refs, selecting the branch HEAD points to. // completes, update the refs and commit list. Loads started
// before a branch/tag switch are discarded via the generation.
if !had_clone {
return Ok(());
}
let refresh = {
let cache = cache.clone();
let addr = addr.clone();
cx.background_spawn(async move {
let Some(repo) = cache.open(&addr)? else {
return Ok::<_, Error>(None);
};
// Best-effort: a failed fetch (e.g. offline) keeps the
// cached state, which is already shown.
signed_git::fetch_all(&repo).ok();
let worktree = repo.workdir().map(Path::to_path_buf);
let (branches, tags) = match &worktree {
Some(_) => (
signed_git::repo_branches(&repo).unwrap_or_default(),
signed_git::repo_tags(&repo).unwrap_or_default(),
),
None => (Vec::new(), Vec::new()),
};
let current_branch = signed_git::current_branch(&repo).unwrap_or(None);
let head_commit = signed_git::head_commit(&repo).unwrap_or(None);
Ok::<_, Error>(Some((branches, tags, current_branch, head_commit)))
})
}
.await;
this.update_in(cx, |this, window, cx| {
if refresh_generation != this.ref_generation {
return;
}
if let Ok(Some((branches, tags, current_branch, head_commit))) = refresh {
let branches: Vec<SharedString> = let branches: Vec<SharedString> =
branches.into_iter().map(Into::into).collect(); branches.into_iter().map(Into::into).collect();
let tags: Vec<SharedString> = tags.into_iter().map(Into::into).collect(); let tags: Vec<SharedString> = tags.into_iter().map(Into::into).collect();
@@ -347,38 +382,93 @@ impl RepoDetailView {
this.tag_select.update(cx, |state, cx| { this.tag_select.update(cx, |state, cx| {
state.set_items(SearchableVec::from(tags), window, cx); state.set_items(SearchableVec::from(tags), window, cx);
}); });
this.head_commit = head_commit;
// The fetch may have brought new commits: reload the list.
this.all_commits = None;
this.loading_all_commits = false;
this.load_all_commits(cx); this.load_all_commits(cx);
if let Some((path, bytes)) = readme_path.zip(readme) {
this.readme_name = Some(path.to_string_lossy().into());
this.load_commit(&path.to_string_lossy(), cx);
if let Ok(text) = String::from_utf8(bytes) {
this.set_markdown(None, &text, cx);
}
}
}
Ok((_, _, _, None, _, _, _, _)) => {
this.error = Some("Repository has no worktree".into());
}
Err(error) => {
this.error = Some(error.to_string().into());
}
}
this.loading = false;
cx.notify(); cx.notify();
}
})?; })?;
Ok(()) Ok(())
}); });
self.track(task); track(&mut self.tasks, task);
}
/// Apply the loaded repository data: explorer tree, README preview, ref
/// selectors and HEAD commit, then start the commit-list walk.
fn apply_repo_data(&mut self, data: RepoData, window: &mut Window, cx: &mut Context<Self>) {
let RepoData {
tree,
readme_path,
readme,
worktree,
branches,
tags,
current_branch,
head_commit,
} = data;
let Some(worktree) = worktree else {
self.error = Some("Repository has no worktree".into());
return;
};
self.worktree = Some(worktree);
self.head_commit = head_commit;
self.tree_state.update(cx, |state, cx| {
state.set_items(tree_items(tree, false), cx);
});
// Populate the branch/tag selectors with the local refs, selecting
// the branch HEAD points to.
let branches: Vec<SharedString> = branches.into_iter().map(Into::into).collect();
let tags: Vec<SharedString> = tags.into_iter().map(Into::into).collect();
self.branch_select.update(cx, |state, cx| {
state.set_items(SearchableVec::from(branches), window, cx);
if let Some(branch) = current_branch {
let branch: SharedString = branch.into();
state.set_selected_values(&[branch], window, cx);
}
});
self.tag_select.update(cx, |state, cx| {
state.set_items(SearchableVec::from(tags), window, cx);
});
self.load_all_commits(cx);
if let Some((path, bytes)) = readme_path.zip(readme) {
self.readme_name = Some(path.to_string_lossy().into());
self.load_commit(&path.to_string_lossy(), cx);
if let Ok(text) = String::from_utf8(bytes) {
self.set_markdown(None, &text, cx);
}
}
} }
/// Preview the file at `path` (relative to the worktree root). /// Preview the file at `path` (relative to the worktree root).
fn open_file(&mut self, path: &str, window: &mut Window, cx: &mut Context<Self>) { fn open_file(&mut self, path: &str, window: &mut Window, cx: &mut Context<Self>) {
self.selected_file = Some(path.into()); self.selected_file = Some(path.into());
if self.files.contains_key(path) || self.loading_files.contains(path) { if self.files.contains_key(path) {
// The file is cached, but the persistent markdown/code state may
// still hold a different file; re-point it at this one (the parse
// runs on a background task either way). Without this, the pane
// would show a spinner forever.
if let Some(FileContent::Text(text)) = self.files.get(path) {
let text = text.clone();
if is_markdown_path(path) {
if self.md.as_ref().map(|md| md.path.as_deref()) != Some(Some(path)) {
self.set_markdown(Some(path.into()), &text, cx);
}
} else if self.code.as_ref().map(|code| code.path.as_str()) != Some(path) {
self.set_code(path.into(), &text, window, cx);
}
}
cx.notify();
return;
}
if self.loading_files.contains(path) {
cx.notify(); cx.notify();
return; return;
} }
@@ -435,8 +525,11 @@ impl RepoDetailView {
this.update_in(cx, |this, window, cx| { this.update_in(cx, |this, window, cx| {
// The worktree was switched while this file was reading; // The worktree was switched while this file was reading;
// the result belongs to the previous branch. // the result belongs to the previous branch. Clear the
// in-flight marker either way, or the path could never be
// loaded again.
if generation != this.ref_generation { if generation != this.ref_generation {
this.loading_files.remove(&path);
return; return;
} }
this.loading_files.remove(&path); this.loading_files.remove(&path);
@@ -473,7 +566,7 @@ impl RepoDetailView {
Ok(()) Ok(())
}); });
self.track(task); track(&mut self.tasks, task);
} }
/// Queue `path` for the per-file commit query; requests are batched into /// Queue `path` for the per-file commit query; requests are batched into
@@ -517,18 +610,19 @@ impl RepoDetailView {
.await; .await;
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
if generation != this.ref_generation {
return;
}
this.loading_commits = false; this.loading_commits = false;
if let Ok(found) = result { if generation == this.ref_generation
&& let Ok(found) = result
{
for (path, commit) in found { for (path, commit) in found {
this.commits this.commits
.insert(path.to_string_lossy().into_owned(), commit); .insert(path.to_string_lossy().into_owned(), commit);
} }
} }
// Paths queued while the walk was in flight start the next // Paths queued while the walk was in flight start the next
// batch. // batch. A stale walk (branch switched mid-flight) must not
// strand them, so this runs under the current generation
// regardless of whether the result was applied.
if !this.pending_commits.is_empty() { if !this.pending_commits.is_empty() {
this.load_commits(cx); this.load_commits(cx);
} }
@@ -538,7 +632,7 @@ impl RepoDetailView {
Ok(()) Ok(())
}); });
self.track(task); track(&mut self.tasks, task);
} }
/// Walk all commits reachable from HEAD on a background task, for the /// Walk all commits reachable from HEAD on a background task, for the
@@ -562,7 +656,10 @@ impl RepoDetailView {
.await; .await;
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
// A stale walk (branch switched mid-flight) must not leave
// the flag set, or the Commits tab would spin forever.
if generation != this.ref_generation { if generation != this.ref_generation {
this.loading_all_commits = false;
return; return;
} }
if let Ok(list) = result { if let Ok(list) = result {
@@ -577,37 +674,31 @@ impl RepoDetailView {
Ok(()) Ok(())
}); });
self.track(task); track(&mut self.tasks, task);
} }
/// Open a new panel showing the diff of `commit` (all files it changed, /// Open a new panel showing the diff of `commit_id` (all files it
/// with the line diff of each). Called from the Commits tab rows and the /// changed, with the line diff of each). Called from the Commits tab
/// latest-commit button in the header. /// rows and the latest-commit button in the header.
fn open_commit_diff( fn open_commit_diff(&mut self, commit_id: &str, window: &mut Window, cx: &mut Context<Self>) {
&mut self,
commit: &FileCommit,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(worktree) = self.worktree.clone() else { let Some(worktree) = self.worktree.clone() else {
return; return;
}; };
let Some(dock_area) = self.dock_area.upgrade() else {
return;
};
// Same display name as the repo detail panel's title. // Same display name as the repo detail panel's title.
let announcement = self.announcement.as_ref().unwrap_or(&self.initial); let repo_name = self.display_name();
let repo_name = announcement
.name
.clone()
.unwrap_or_else(|| SharedString::from(announcement.id.clone()));
let panel = let panel =
cx.new(|cx| CommitDiffView::new(worktree, repo_name, commit.clone(), window, cx)); cx.new(|cx| CommitDiffView::new(worktree, repo_name, commit_id.into(), window, cx));
if let Some(dock_area) = self.dock_area.upgrade() {
dock_area.update(cx, |dock_area, cx| { dock_area.update(cx, |dock_area, cx| {
dock_area.add_panel(Arc::new(panel), DockPlacement::Bottom, None, window, cx); dock_area.add_panel(Arc::new(panel), DockPlacement::Center, None, window, cx);
}); });
} }
}
/// Check out `name` (a branch or tag picked in the header) and refresh /// Check out `name` (a branch or tag picked in the header) and refresh
/// the explorer once the switch completes. /// the explorer once the switch completes.
@@ -678,7 +769,7 @@ impl RepoDetailView {
Ok(()) Ok(())
}); });
self.track(task); track(&mut self.tasks, task);
} }
/// Restore a selector to `previous`, or clear it (after a failed switch). /// Restore a selector to `previous`, or clear it (after a failed switch).
@@ -760,10 +851,7 @@ impl RepoDetailView {
// previous branch are gone, and with them the // previous branch are gone, and with them the
// expansion state. // expansion state.
this.tree_state.update(cx, |state, cx| { this.tree_state.update(cx, |state, cx| {
state.set_items( state.set_items(tree_items(tree, false), cx);
tree.into_iter().map(Into::into).collect::<Vec<TreeItem>>(),
cx,
);
}); });
// Drop cached previews and commits of the old branch. // Drop cached previews and commits of the old branch.
@@ -805,14 +893,7 @@ impl RepoDetailView {
Ok(()) Ok(())
}); });
self.track(task); track(&mut self.tasks, task);
}
/// Track `task` until it completes; finished tasks are pruned on every
/// push so the vec stays bounded by the number of in-flight loads.
fn track(&mut self, task: Task<Result<(), Error>>) {
self.tasks.retain(|task| !task.is_ready());
self.tasks.push(task);
} }
/// Drop the oldest previews beyond the cache caps, keeping the currently /// Drop the oldest previews beyond the cache caps, keeping the currently
@@ -844,6 +925,20 @@ impl RepoDetailView {
self.commits.remove(&path); self.commits.remove(&path);
} }
} }
/// The latest announcement from the store, or the open-time snapshot.
fn announcement(&self) -> &Announcement {
self.announcement.as_ref().unwrap_or(&self.initial)
}
/// Display name: the announcement's name, or the ID if no name is set.
fn display_name(&self) -> SharedString {
let announcement = self.announcement();
announcement
.name
.clone()
.unwrap_or_else(|| SharedString::from(announcement.id.clone()))
}
} }
impl Panel for RepoDetailView { impl Panel for RepoDetailView {
@@ -852,15 +947,46 @@ impl Panel for RepoDetailView {
} }
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement { fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
let announcement = self.announcement.as_ref().unwrap_or(&self.initial); self.display_name()
announcement
.name
.clone()
.unwrap_or_else(|| SharedString::from(announcement.id.clone()))
} }
} }
/// Read the worktree state of `repo` (no network): entries, README, refs
/// and HEAD commit. The tree is built off the main thread; the seeds are
/// plain owned strings and convert to `TreeItem`s (which hold `Rc` state)
/// on the main thread.
fn load_repo_data(repo: &Repository) -> Result<RepoData, Error> {
let entries = signed_git::worktree_entries(repo)?;
let tree = build_tree_items(&entries);
let readme_path = signed_git::find_readme(repo)?;
let readme = match &readme_path {
Some(path) => signed_git::worktree_read(repo, path)?,
None => None,
};
let worktree = repo.workdir().map(Path::to_path_buf);
// Ref listing is auxiliary UI: a broken ref must not prevent the
// explorer from loading, so failures degrade to empty selectors.
let (branches, tags, current_branch) = match &worktree {
Some(_) => (
signed_git::repo_branches(repo).unwrap_or_default(),
signed_git::repo_tags(repo).unwrap_or_default(),
signed_git::current_branch(repo).unwrap_or(None),
),
None => (Vec::new(), Vec::new(), None),
};
let head_commit = signed_git::head_commit(repo).unwrap_or(None);
Ok(RepoData {
tree,
readme_path,
readme,
worktree,
branches,
tags,
current_branch,
head_commit,
})
}
impl EventEmitter<PanelEvent> for RepoDetailView {} impl EventEmitter<PanelEvent> for RepoDetailView {}
impl Focusable for RepoDetailView { impl Focusable for RepoDetailView {
@@ -874,33 +1000,47 @@ impl Render for RepoDetailView {
let tree_state = self.tree_state.clone(); let tree_state = self.tree_state.clone();
let view = cx.entity().downgrade(); let view = cx.entity().downgrade();
let announcement = self.announcement.as_ref().unwrap_or(&self.initial);
let relays = self.relays.clone();
let web = self.web.clone();
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 pane_title = self let pane_title = self
.selected_file .selected_file
.clone() .clone()
.or_else(|| self.readme_name.clone()) .or_else(|| self.readme_name.clone())
.unwrap_or_else(|| "Overview".into()); .unwrap_or_else(|| "Overview".into());
let commits_count = self.all_commits.as_ref().map(|list| list.total);
let worktree_empty = self.switching_ref || self.worktree.is_none();
v_flex() v_flex()
.id("repo") .id("repo")
.size_full() .size_full()
.child( .child(self.render_header(cx))
.child(match self.active_tab {
0 => h_flex()
.flex_1()
.w_full()
.overflow_hidden()
.child(Self::render_tree_column(tree_state, view, cx))
.child(self.render_content_column(pane_title, cx))
.into_any_element(),
_ => self.render_commits_tab(cx),
})
}
}
impl RepoDetailView {
/// Header: repository name and description, relay/web/clone buttons, the
/// Files/Commits tab bar and the branch/tag selectors with the
/// latest-commit button.
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
let announcement = self.announcement();
let relays = self.relays.clone();
let web = self.web.clone();
let name = self.display_name();
let description = announcement
.description
.clone()
.unwrap_or(SharedString::from("No description"));
let commits_count = self.all_commits.as_ref().map(|list| list.total);
let worktree_empty = self.switching_ref || self.worktree.is_none();
v_flex() v_flex()
.px_4() .px_4()
.pt_2() .pt_2()
@@ -945,8 +1085,7 @@ impl Render for RepoDetailView {
let mut menu = menu; let mut menu = menu;
if relays.is_empty() { if relays.is_empty() {
return menu.item( return menu.item(
PopupMenuItem::new("No relays") PopupMenuItem::new("No relays").disabled(true),
.disabled(true),
); );
} }
for relay in relays.iter() { for relay in relays.iter() {
@@ -955,9 +1094,7 @@ impl Render for RepoDetailView {
PopupMenuItem::new(url.clone()).on_click( PopupMenuItem::new(url.clone()).on_click(
move |_, _, cx| { move |_, _, cx| {
cx.write_to_clipboard( cx.write_to_clipboard(
ClipboardItem::new_string( ClipboardItem::new_string(url.clone()),
url.clone(),
),
); );
}, },
), ),
@@ -968,26 +1105,18 @@ impl Render for RepoDetailView {
) )
.child( .child(
DropdownButton::new("web") DropdownButton::new("web")
.button( .button(Button::new("web-trigger").label("Websites").ghost())
Button::new("web-trigger")
.label("Websites")
.ghost(),
)
.dropdown_menu(move |menu, _window, _cx| { .dropdown_menu(move |menu, _window, _cx| {
let mut menu = menu; let mut menu = menu;
if web.is_empty() { if web.is_empty() {
return menu.item( return menu
PopupMenuItem::new("No web").disabled(true), .item(PopupMenuItem::new("No web").disabled(true));
);
} }
for url in web.iter() { for url in web.iter() {
let href = url.to_string(); let href = url.to_string();
menu = menu.item( menu = menu.item(
PopupMenuItem::new(href.clone()).on_click( PopupMenuItem::new(href.clone())
move |_, _, cx| { .on_click(move |_, _, cx| cx.open_url(&href)),
cx.open_url(&href);
},
),
); );
} }
menu menu
@@ -1064,11 +1193,7 @@ impl Render for RepoDetailView {
.bg(cx.theme().muted) .bg(cx.theme().muted)
.rounded(cx.theme().radius) .rounded(cx.theme().radius)
.render_trigger(|ctx, _window, cx| { .render_trigger(|ctx, _window, cx| {
Self::render_ref_trigger( Self::render_ref_trigger(ctx, CustomIconName::Tag, cx)
ctx,
CustomIconName::Tag,
cx,
)
}), }),
), ),
) )
@@ -1101,23 +1226,13 @@ impl Render for RepoDetailView {
) )
.on_click(cx.listener(|this, _event, window, cx| { .on_click(cx.listener(|this, _event, window, cx| {
if let Some(commit) = &this.head_commit { if let Some(commit) = &this.head_commit {
let commit = commit.clone(); let id = commit.id.clone();
this.open_commit_diff(&commit, window, cx); this.open_commit_diff(&id, window, cx);
} }
})), })),
), ),
), ),
),
) )
.child(match self.active_tab { .into_any_element()
0 => h_flex()
.flex_1()
.w_full()
.overflow_hidden()
.child(self.render_tree_column(tree_state, view, cx))
.child(self.render_content_column(pane_title, cx))
.into_any_element(),
_ => self.render_commits_tab(cx),
})
} }
} }