This commit is contained in:
2026-09-13 15:46:34 +07:00
parent 33e9429cd0
commit b59f6a95de
9 changed files with 566 additions and 539 deletions
+9 -23
View File
@@ -22,9 +22,8 @@ impl RepoDetailView {
self.error = None;
cx.notify();
self.store
.update(cx, |store, cx| store.push_repository(cx))
.detach();
let task = self.store.update(cx, |store, cx| store.push_repository(cx));
self.tasks.push(task);
}
pub(super) fn push_unpushed_checkout(
@@ -43,13 +42,10 @@ impl RepoDetailView {
cx.notify();
let task: gpui::Task<Result<(), Error>> = cx.spawn_in(window, async move |this, cx| {
// The store owns the push, its busy flag and error reporting.
let push = this.update_in(cx, |_this, _window, cx| {
store.update(cx, |store, cx| store.push_checkout(path.clone(), cx))
})?;
// The remote moved, refresh the mirror browsing.
// Failures already surfaced in the store's error banner.
if let Ok(()) = push.await {
this.update_in(cx, |this, window, cx| {
this.load_repo(window, cx);
@@ -59,14 +55,15 @@ impl RepoDetailView {
Ok(())
});
task.detach();
self.tasks.push(task);
}
/// Delete the repository from nostr, announcement, state and activity.
pub(super) fn delete_repository(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
self.store
.update(cx, |store, cx| store.delete_repository(cx))
.detach();
let task = self
.store
.update(cx, |store, cx| store.delete_repository(cx));
self.tasks.push(task);
}
pub(super) fn open_issue_detail(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -130,12 +127,7 @@ impl RepoDetailView {
}
}
/// Open `addr`'s repository as a panel in the dock's center.
///
/// `hint` is an announcement already in hand for `addr`. It seeds the store's
/// relays and lets the explorer load without waiting for the database; the
/// store loads the announcement itself when the hint is absent, so an entry
/// point with only an address works too.
/// Open repository as a panel in the dock's center.
pub(crate) fn open_repo_panel(
dock_area: &WeakEntity<DockArea>,
addr: &RepoAddr,
@@ -156,24 +148,18 @@ pub(crate) fn open_repo_panel(
}
/// The nostr store of `addr`'s repository, without opening a repository panel.
///
/// `hint` is an announcement already in hand for `addr`. It only seeds the
/// relays to connect to right away; the store loads the announcement from the
/// local database on its first pass, so the hint is optional.
fn repo_store(addr: &RepoAddr, hint: Option<&Announcement>, cx: &mut App) -> Entity<RepoStore> {
cx.new(|cx| RepoStore::new(addr.clone(), hint.cloned(), cx))
}
/// An item of a repository to open from outside its detail panel.
/// A patch has no detail view in Signed, so it opens nothing.
pub(crate) enum RepoItem {
Issue(EventId),
PullRequest(EventId),
Patch,
}
/// The repository store is built here, not taken from a `RepoDetailView`, so the
/// item panel is the only panel docked.
/// The repository store is built here.
pub(crate) fn open_repo_item(
dock_area: &WeakEntity<DockArea>,
addr: &RepoAddr,
@@ -11,11 +11,6 @@ use super::RepoDetailView;
use crate::views::pull_requests::new::open_new_pull_panel;
impl RepoDetailView {
/// The first checkout ready for a pull request on this repository.
/// Not covered by an open PR of the signed-in user.
/// Not dismissed in this panel.
/// The repository's own checkouts are not suggested here.
/// Their work is pushed, see [`Self::push_suggestion`].
fn ready_suggestion(&self, cx: &App) -> Option<CheckoutStatus> {
let store = self.store.read(cx);
let addr = store.addr()?;
+302 -95
View File
@@ -1,8 +1,9 @@
use std::path::{Component, Path};
use std::collections::{HashMap, HashSet, VecDeque};
use std::path::{Component, Path, PathBuf};
use anyhow::Error;
use gpui::prelude::*;
use gpui::{AnyElement, Context, Entity, SharedString, WeakEntity, Window, div, px};
use gpui::{AnyElement, Context, Entity, Render, SharedString, Task, WeakEntity, Window, div, px};
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::input::{Editor, EditorState};
use gpui_component::list::ListItem;
@@ -10,61 +11,204 @@ 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 signed_git::{FileCommit, WorktreeSnapshot};
use signed_ui::{placeholder, tree_row};
use super::RepoDetailView;
use crate::views::tree::{TreeItemSeed, tree_items};
const TREE_WIDTH: f32 = 240.;
pub(super) const MAX_PREVIEW_BYTES: usize = 1024 * 1024;
/// The oldest previews are evicted beyond the caps.
pub(super) const MAX_PREVIEWED_FILES: usize = 32;
pub(super) const MAX_PREVIEW_CACHE_BYTES: usize = 8 * 1024 * 1024;
const MAX_PREVIEW_BYTES: usize = 1024 * 1024;
const MAX_PREVIEWED_FILES: usize = 32;
const MAX_PREVIEW_CACHE_BYTES: usize = 8 * 1024 * 1024;
pub(super) enum FileContent {
enum FileContent {
Text(String),
Binary,
/// Bigger than [`MAX_PREVIEW_BYTES`].
TooLarge,
Failed(String),
}
pub(super) struct MarkdownView {
struct MarkdownView {
/// `None` means the repository README.
pub(super) path: Option<SharedString>,
pub(super) state: Entity<TextViewState>,
path: Option<SharedString>,
state: Entity<TextViewState>,
/// Hash of the source, so the same document is not re-parsed on a refresh.
source_hash: u64,
}
pub(super) struct CodeView {
struct CodeView {
/// Source path, relative to the worktree root.
pub(super) path: SharedString,
pub(super) state: Entity<EditorState>,
path: SharedString,
state: Entity<EditorState>,
/// Hash of the source, so the same document is not re-parsed on a refresh.
source_hash: u64,
}
/// Two loads of the same document produce the same hash, so the persistent
/// markdown/editor state can be kept instead of rebuilt, which would re-parse
/// and flash the pane.
fn source_hash(text: &str) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
text.hash(&mut hasher);
hasher.finish()
pub(super) struct RepoFilesView {
tree_state: Entity<TreeState>,
worktree: Option<PathBuf>,
worktree_paths: Vec<String>,
md: Option<MarkdownView>,
code: Option<CodeView>,
readme_name: Option<SharedString>,
selected_file: Option<SharedString>,
files: HashMap<String, FileContent>,
file_order: VecDeque<String>,
preview_bytes: usize,
loading_files: HashSet<String>,
commits: HashMap<String, FileCommit>,
pending_commits: Vec<String>,
loading_commits: bool,
generation: u64,
tasks: Vec<Task<Result<(), Error>>>,
}
fn preview_spinner() -> AnyElement {
v_flex()
.size_full()
.items_center()
.justify_center()
.child(Spinner::new().small())
.into_any_element()
}
impl RepoFilesView {
pub(super) fn new(cx: &mut Context<Self>) -> Self {
Self {
tree_state: cx.new(|cx| TreeState::new(cx)),
worktree: None,
worktree_paths: Vec::new(),
md: None,
code: None,
readme_name: None,
selected_file: None,
files: HashMap::new(),
file_order: VecDeque::new(),
preview_bytes: 0,
loading_files: HashSet::new(),
commits: HashMap::new(),
pending_commits: Vec::new(),
loading_commits: false,
generation: 0,
tasks: Vec::new(),
}
}
pub(super) fn set_worktree(&mut self, path: PathBuf) {
self.worktree = Some(path);
}
pub(super) fn apply_entries(
&mut self,
tree: Vec<TreeItemSeed>,
paths: Vec<String>,
cx: &mut Context<Self>,
) {
self.worktree_paths = paths;
self.tree_state.update(cx, |state, cx| {
state.set_items(tree_items(tree, false), cx);
});
}
/// Point the README pane at `path`/`bytes`, or clear it when absent.
///
/// Returns whether the pane changed.
pub(super) fn set_readme(
&mut self,
path: Option<PathBuf>,
bytes: Option<Vec<u8>>,
cx: &mut Context<Self>,
) -> bool {
let Some((path, bytes)) = path.zip(bytes) else {
let changed = self.readme_name.is_some() || self.md.is_some();
self.readme_name = None;
self.md = None;
return changed;
};
let name: SharedString = path.to_string_lossy().into();
let mut changed = self.readme_name.as_ref() != Some(&name);
self.readme_name = Some(name);
self.load_commit(&path.to_string_lossy(), cx);
if let Ok(text) = String::from_utf8(bytes) {
changed |= self.set_markdown(None, &text, cx);
}
changed
}
/// Drop every cached preview and the README, e.g. on a branch switch.
pub(super) fn clear_previews(&mut self) {
self.selected_file = None;
self.files.clear();
self.file_order.clear();
self.preview_bytes = 0;
self.loading_files.clear();
self.commits.clear();
self.pending_commits.clear();
self.loading_commits = false;
self.md = None;
self.code = None;
self.readme_name = None;
self.generation += 1;
}
/// Refresh after the mirror caught up with the remote.
///
/// Unlike a branch switch this keeps the selection and previews: it rebuilds
/// the tree, drops previews of files the refresh removed and re-renders the
/// README when it is on screen.
///
/// Returns whether the tree, a preview or the README changed.
pub(super) fn catch_up(
&mut self,
snapshot: &WorktreeSnapshot,
tree: Vec<TreeItemSeed>,
paths: Vec<String>,
cx: &mut Context<Self>,
) -> bool {
let mut changed = false;
if paths != self.worktree_paths {
self.apply_entries(tree, paths, cx);
changed = true;
}
let present: HashSet<String> = snapshot
.entries
.iter()
.map(|path| path.to_string_lossy().into_owned())
.collect();
let mut previewed: Vec<String> = Vec::new();
previewed.extend(self.files.keys().cloned());
previewed.extend(self.selected_file.clone().map(|path| path.to_string()));
if let Some(path) = self.md.as_ref().and_then(|md| md.path.clone()) {
previewed.push(path.to_string());
}
if let Some(path) = self.code.as_ref().map(|code| code.path.clone()) {
previewed.push(path.to_string());
}
previewed.sort();
previewed.dedup();
for path in previewed {
if !present.contains(&path) {
self.drop_preview_of(&path);
changed = true;
}
}
if self.selected_file.is_none() {
changed |= self.set_readme(snapshot.readme_path.clone(), snapshot.readme.clone(), cx);
}
changed
}
fn pane_title(&self) -> SharedString {
self.selected_file
.clone()
.or_else(|| self.readme_name.clone())
.unwrap_or_else(|| "Overview".into())
}
impl RepoDetailView {
fn render_tree_item(
ix: usize,
entry: &TreeEntry,
@@ -81,7 +225,7 @@ impl RepoDetailView {
})
}
pub(super) fn render_tree_column(
fn render_tree_column(
tree_state: Entity<TreeState>,
view: WeakEntity<Self>,
cx: &mut Context<Self>,
@@ -101,43 +245,12 @@ impl RepoDetailView {
)))
}
pub(super) fn render_content_column(
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 {
let body: AnyElement = if let Some(path) = self.selected_file.clone() {
match self.files.get(path.as_ref()) {
Some(FileContent::Text(_)) => {
if is_markdown_path(path.as_ref()) {
@@ -158,7 +271,6 @@ impl RepoDetailView {
};
// Latest commit for the current pane, the selected file or the README.
// 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
@@ -213,27 +325,31 @@ impl RepoDetailView {
.child(div().id("repo-content").flex_1().min_h_0().child(body))
}
pub(super) fn set_markdown(
fn set_markdown(
&mut self,
path: Option<SharedString>,
text: &str,
cx: &mut Context<Self>,
) {
) -> bool {
let hash = source_hash(text);
if let Some(md) = &self.md
&& md.path == path
&& md.source_hash == hash
{
return;
return false;
}
let state = cx.new(|cx| TextViewState::markdown("", cx));
state.update(cx, |state, cx| state.push_str(text, cx));
self.md = Some(MarkdownView {
path,
state,
source_hash: hash,
});
true
}
fn markdown_element(&self, path: Option<&str>, _cx: &mut Context<Self>) -> AnyElement {
@@ -258,7 +374,7 @@ impl RepoDetailView {
.into_any_element()
}
pub(super) fn set_code(
fn set_code(
&mut self,
path: SharedString,
text: &str,
@@ -266,6 +382,7 @@ impl RepoDetailView {
cx: &mut Context<Self>,
) {
let hash = source_hash(text);
if let Some(code) = &self.code
&& code.path == path
&& code.source_hash == hash
@@ -281,6 +398,7 @@ impl RepoDetailView {
.line_number(true)
.folding(true)
});
self.code = Some(CodeView {
path,
state,
@@ -304,16 +422,11 @@ impl RepoDetailView {
.text_sm()
.into_any_element()
}
}
impl RepoDetailView {
fn open_file(&mut self, path: &str, window: &mut Window, cx: &mut Context<Self>) {
self.selected_file = Some(path.into());
if self.files.contains_key(path) {
// The file is cached, but the markdown or code state may 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) {
@@ -332,8 +445,6 @@ impl RepoDetailView {
return;
}
// Paths come from our own tree walk, but never trust them.
// Refuse anything that could escape the worktree.
let rel = Path::new(path);
let unsafe_path = rel.is_absolute()
|| rel.components().any(|c| {
@@ -355,27 +466,28 @@ impl RepoDetailView {
let path = path.to_string();
self.load_commit(&path, cx);
let generation = self.ref_generation;
let generation = self.generation;
let task: gpui::Task<Result<(), Error>> = cx.spawn_in(window, async move |this, cx| {
let task: Task<Result<(), Error>> = cx.spawn_in(window, async move |this, cx| {
let path_for_read = path.clone();
let content = cx
.background_spawn(async move {
let full = worktree.join(&path_for_read);
// Refuse oversized files before reading them.
// Reading a multi-gigabyte file just to classify it is wasteful.
// It would burn disk and memory bandwidth.
let metadata = match std::fs::metadata(&full) {
Ok(metadata) => metadata,
Err(error) => return Err(anyhow::anyhow!("{}", error)),
};
if metadata.len() > MAX_PREVIEW_BYTES as u64 {
return Ok(FileContent::TooLarge);
}
let bytes = match std::fs::read(&full) {
Ok(bytes) => bytes,
Err(error) => return Err(anyhow::anyhow!("{}", error)),
};
match String::from_utf8(bytes) {
Ok(text) => Ok(FileContent::Text(text)),
Err(_) => Ok(FileContent::Binary),
@@ -384,15 +496,13 @@ impl RepoDetailView {
.await;
this.update_in(cx, |this, window, cx| {
// The worktree was switched while this file was reading.
// The result belongs to the previous branch.
// Clear the in-flight marker either way.
// Otherwise the path could never be loaded again.
if generation != this.ref_generation {
if generation != this.generation {
this.loading_files.remove(&path);
return;
}
this.loading_files.remove(&path);
match content {
Ok(kind) => {
if let FileContent::Text(text) = &kind {
@@ -426,45 +536,49 @@ impl RepoDetailView {
Ok(())
});
task.detach();
self.tasks.push(task);
}
pub(super) fn drop_preview_of(&mut self, path: &str) {
fn drop_preview_of(&mut self, path: &str) {
if let Some(FileContent::Text(text)) = self.files.remove(path) {
self.preview_bytes -= text.len();
}
self.commits.remove(path);
if self.selected_file.as_deref() == Some(path) {
self.selected_file = None;
}
if self.md.as_ref().and_then(|md| md.path.as_deref()) == Some(path) {
self.md = None;
}
if self.code.as_ref().map(|code| code.path.as_ref()) == Some(path) {
self.code = None;
}
}
/// Drop the oldest previews beyond the cache caps.
/// Keep the currently selected file.
/// An evicted file's parsed editor state drops with its entry.
/// Re-opening it re-parses on a background task.
fn evict_previews(&mut self) {
while (self.files.len() > MAX_PREVIEWED_FILES
|| self.preview_bytes > MAX_PREVIEW_CACHE_BYTES)
&& self.file_order.len() > 1
{
let path = self.file_order.pop_front().expect("non-empty");
if Some(path.as_str()) == self.selected_file.as_deref() {
self.file_order.push_back(path);
continue;
}
if let Some(FileContent::Text(text)) = self.files.remove(&path) {
self.preview_bytes -= text.len();
}
if self.md.as_ref().map(|md| md.path.as_deref()) == Some(Some(path.as_str())) {
self.md = None;
}
if self
.code
.as_ref()
@@ -472,9 +586,102 @@ impl RepoDetailView {
{
self.code = None;
}
self.commits.remove(&path);
}
}
fn load_commit(&mut self, path: &str, cx: &mut Context<Self>) {
if self.commits.contains_key(path) || self.pending_commits.iter().any(|p| p == path) {
return;
}
self.pending_commits.push(path.to_string());
if !self.loading_commits {
self.load_commits(cx);
}
}
fn load_commits(&mut self, cx: &mut Context<Self>) {
if self.pending_commits.is_empty() || self.loading_commits {
return;
}
let Some(worktree) = self.worktree.clone() else {
self.pending_commits.clear();
return;
};
self.loading_commits = true;
let paths = std::mem::take(&mut self.pending_commits);
let generation = self.generation;
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
let rels: Vec<PathBuf> = paths.iter().map(PathBuf::from).collect();
let result = cx
.background_spawn(
async move { signed_git::worktree_last_commits(&worktree, &rels) },
)
.await;
this.update(cx, |this, cx| {
this.loading_commits = false;
if generation == this.generation
&& let Ok(found) = result
{
for (path, commit) in found {
this.commits
.insert(path.to_string_lossy().into_owned(), commit);
}
}
if !this.pending_commits.is_empty() {
this.load_commits(cx);
}
cx.notify();
})?;
Ok(())
});
self.tasks.push(task);
}
}
impl Render for RepoFilesView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let tree_state = self.tree_state.clone();
let view = cx.entity().downgrade();
let pane_title = self.pane_title();
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))
}
}
fn source_hash(text: &str) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
text.hash(&mut hasher);
hasher.finish()
}
fn preview_spinner() -> AnyElement {
v_flex()
.size_full()
.items_center()
.justify_center()
.child(Spinner::new().small())
.into_any_element()
}
/// The markdown fence language for a file path, or `None` for plain text.
+7 -7
View File
@@ -20,7 +20,7 @@ use signed_ui::{
ref_selector_trigger,
};
use super::{RepoAction, RepoDetailView};
use super::{RepoAction, RepoDetailView, repo_display_name};
use crate::views::issues::open_new_issue_dialog;
use crate::views::pull_requests::new::open_new_pull_panel;
use crate::views::repo::about::open_about_dialog;
@@ -44,8 +44,6 @@ impl RepoDetailView {
return div().into_any_element();
};
// Derived NIP-34 header data, share targets and clone commands.
// Rebuilt per frame: two bech32 encodes and a couple of format strings.
let nip05 = ProfileStore::global(cx)
.read(cx)
.get(&source.owner)
@@ -62,7 +60,7 @@ impl RepoDetailView {
let nak_command = SharedString::from(format!("nak git clone {nostr_url}"));
let git_commands = Rc::new(announcement.clone_urls());
let name = self.display_name(cx);
let name = repo_display_name(self.store.read(cx));
let description = announcement.description();
let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id));
@@ -413,7 +411,7 @@ impl RepoDetailView {
}
fn render_local_header(&self, cx: &mut Context<Self>) -> AnyElement {
let name = self.display_name(cx);
let name = repo_display_name(self.store.read(cx));
let path = self
.store
.read(cx)
@@ -476,7 +474,7 @@ impl RepoDetailView {
}
fn render_header_tabs(&self, cx: &mut Context<Self>) -> AnyElement {
let commits_count = self.all_commits.as_ref().map(|list| list.total);
let commits_count = self.history.read(cx).commit_count();
let worktree_empty = self.switching_ref || self.worktree.is_none();
h_flex()
@@ -574,7 +572,9 @@ impl RepoDetailView {
.on_click(cx.listener(|this, _event, window, cx| {
if let Some(commit) = &this.head_commit {
let id = commit.id.clone();
this.open_commit_diff(&id, window, cx);
this.history.update(cx, |history, cx| {
history.open_commit_diff(&id, window, cx)
});
}
})),
)
+117 -133
View File
@@ -2,19 +2,130 @@ use std::path::PathBuf;
use std::rc::Rc;
use anyhow::Error;
use dock::{add_center_panel, panel_handle};
use dock::{DockArea, add_center_panel, panel_handle};
use gpui::prelude::*;
use gpui::{AnyElement, Context, Window, div, px, size};
use gpui::{Context, Entity, Pixels, Render, Size, Task, WeakEntity, Window, div, px, size};
use gpui_component::scroll::Scrollbar;
use gpui_component::spinner::Spinner;
use gpui_component::{ActiveTheme, Sizable, v_flex, v_virtual_list};
use gpui_component::{ActiveTheme, Sizable, VirtualListScrollHandle, v_flex, v_virtual_list};
use signed_git::CommitList;
use signed_state::RepoStore;
use signed_ui::placeholder;
use super::RepoDetailView;
use super::repo_display_name;
use crate::views::commit_diff::{COMMIT_ROW_HEIGHT, CommitDiffView, commit_row};
impl RepoDetailView {
pub(super) fn render_commits_tab(&self, cx: &mut Context<Self>) -> AnyElement {
pub(super) struct RepoHistoryView {
store: Entity<RepoStore>,
dock_area: WeakEntity<DockArea>,
worktree: Option<PathBuf>,
all_commits: Option<CommitList>,
loading_all_commits: bool,
scroll_handle: VirtualListScrollHandle,
item_sizes: Rc<Vec<Size<Pixels>>>,
/// Bumped on reload, so an in-flight walk of the previous HEAD is discarded.
generation: u64,
tasks: Vec<Task<Result<(), Error>>>,
}
impl RepoHistoryView {
pub(super) fn new(store: Entity<RepoStore>, dock_area: WeakEntity<DockArea>) -> Self {
Self {
store,
dock_area,
worktree: None,
all_commits: None,
loading_all_commits: false,
scroll_handle: VirtualListScrollHandle::new(),
item_sizes: Rc::new(Vec::new()),
generation: 0,
tasks: Vec::new(),
}
}
pub(super) fn set_worktree(&mut self, path: Option<PathBuf>) {
self.worktree = path;
}
/// Number of commits reachable from HEAD, for the Commits tab badge.
pub(super) fn commit_count(&self) -> Option<usize> {
self.all_commits.as_ref().map(|list| list.total)
}
/// Drop the current list and walk HEAD again.
pub(super) fn reload(&mut self, cx: &mut Context<Self>) {
self.generation += 1;
self.all_commits = None;
self.loading_all_commits = false;
self.load(cx);
}
fn load(&mut self, cx: &mut Context<Self>) {
if self.loading_all_commits || self.all_commits.is_some() {
return;
}
let Some(worktree) = self.worktree.clone() else {
return;
};
self.loading_all_commits = true;
let generation = self.generation;
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
let result = cx
.background_spawn(async move { signed_git::worktree_all_commits(&worktree) })
.await;
this.update(cx, |this, cx| {
if generation != this.generation {
return;
}
if let Ok(list) = result {
let count = list.commits.len();
this.item_sizes = Rc::new(vec![size(px(0.), px(COMMIT_ROW_HEIGHT)); count]);
this.all_commits = Some(list);
}
this.loading_all_commits = false;
cx.notify();
})?;
Ok(())
});
self.tasks.push(task);
}
pub(super) fn open_commit_diff(
&mut self,
commit_id: &str,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(worktree) = self.worktree.clone() else {
return;
};
let Some(dock_area) = self.dock_area.upgrade() else {
return;
};
// Same display name as the repo detail panel's title.
let repo_name = repo_display_name(self.store.read(cx));
let panel =
cx.new(|cx| CommitDiffView::new(worktree, repo_name, commit_id.into(), window, cx));
dock_area.update(cx, |dock_area, cx| {
add_center_panel(dock_area, panel_handle(panel), window, cx);
});
}
}
impl Render for RepoHistoryView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let Some(list) = self.all_commits.as_ref() else {
return if self.loading_all_commits {
v_flex()
@@ -32,9 +143,6 @@ impl RepoDetailView {
return placeholder("No commits found", cx);
}
// Copy only the values the element tree needs.
// The list is borrowed by the renderer below instead of 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();
@@ -79,8 +187,6 @@ impl RepoDetailView {
.size_full(),
)
.when(shown < total, |this| {
// The history is capped.
// Tell the user the list is truncated.
this.child(
div()
.py_2()
@@ -102,125 +208,3 @@ impl RepoDetailView {
.into_any_element()
}
}
impl RepoDetailView {
pub(super) fn load_commit(&mut self, path: &str, cx: &mut Context<Self>) {
if self.commits.contains_key(path) || self.pending_commits.iter().any(|p| p == path) {
return;
}
self.pending_commits.push(path.to_string());
if !self.loading_commits {
self.load_commits(cx);
}
}
fn load_commits(&mut self, cx: &mut Context<Self>) {
if self.pending_commits.is_empty() || self.loading_commits {
return;
}
let Some(worktree) = self.worktree.clone() else {
self.pending_commits.clear();
return;
};
self.loading_commits = true;
let paths = std::mem::take(&mut self.pending_commits);
let generation = self.ref_generation;
let task: gpui::Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
let rels: Vec<PathBuf> = paths.iter().map(PathBuf::from).collect();
let result = cx
.background_spawn(
async move { signed_git::worktree_last_commits(&worktree, &rels) },
)
.await;
this.update(cx, |this, cx| {
this.loading_commits = false;
if generation == this.ref_generation
&& let Ok(found) = result
{
for (path, commit) in found {
this.commits
.insert(path.to_string_lossy().into_owned(), commit);
}
}
// Paths queued while the walk was in flight start the next batch.
// A stale walk, branch switched mid-flight, must not strand them.
// This runs under the current generation regardless of the result.
if !this.pending_commits.is_empty() {
this.load_commits(cx);
}
cx.notify();
})?;
Ok(())
});
task.detach();
}
pub(super) fn load_all_commits(&mut self, cx: &mut Context<Self>) {
if self.loading_all_commits || self.all_commits.is_some() {
return;
}
let Some(worktree) = self.worktree.clone() else {
return;
};
self.loading_all_commits = true;
let generation = self.ref_generation;
let task: gpui::Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
let result = cx
.background_spawn(async move { signed_git::worktree_all_commits(&worktree) })
.await;
this.update(cx, |this, cx| {
// A stale walk, branch switched mid-flight, must not leave the flag set.
// Otherwise the Commits tab would spin forever.
if generation != this.ref_generation {
this.loading_all_commits = false;
return;
}
if let Ok(list) = result {
let count = list.commits.len();
this.item_sizes = Rc::new(vec![size(px(0.), px(COMMIT_ROW_HEIGHT)); count]);
this.all_commits = Some(list);
}
this.loading_all_commits = false;
cx.notify();
})?;
Ok(())
});
task.detach();
}
pub(super) fn open_commit_diff(
&mut self,
commit_id: &str,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(worktree) = self.worktree.clone() else {
return;
};
let Some(dock_area) = self.dock_area.upgrade() else {
return;
};
// Same display name as the repo detail panel's title.
let repo_name = self.display_name(cx);
let panel =
cx.new(|cx| CommitDiffView::new(worktree, repo_name, commit_id.into(), window, cx));
dock_area.update(cx, |dock_area, cx| {
add_center_panel(dock_area, panel_handle(panel), window, cx);
});
}
}
+21 -43
View File
@@ -11,11 +11,10 @@ use signed_git::FileCommit;
use signed_state::GitStore;
use super::RepoDetailView;
use crate::views::tree::{TreeItemSeed, build_tree_items, sorted_worktree_paths, tree_items};
use crate::views::tree::{TreeItemSeed, build_tree_items, sorted_worktree_paths};
struct RepoData {
tree: Vec<TreeItemSeed>,
/// Relative paths of the worktree entries, for [`RepoDetailView::worktree_paths`].
entries: Vec<PathBuf>,
readme_path: Option<PathBuf>,
readme: Option<Vec<u8>>,
@@ -28,9 +27,6 @@ struct RepoData {
impl RepoDetailView {
/// Load the repository and populate the file explorer.
///
/// An announced repository's clone, if any, loads first without touching
/// the network.
pub(super) fn load_repo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.loading = true;
self.error = None;
@@ -74,7 +70,7 @@ impl RepoDetailView {
Ok(())
});
task.detach();
self.tasks.push(task);
return;
}
@@ -141,6 +137,7 @@ impl RepoDetailView {
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);
@@ -210,23 +207,10 @@ impl RepoDetailView {
let head_changed = new_head_commit != current_head_commit;
this.head_commit = head_commit;
log::debug!(
"repo detail refresh reconcile: branches_changed={branches_changed} tags_changed={tags_changed} head_changed={head_changed} moved={moved}"
);
// Only a moved HEAD invalidates the commit list.
// Leaving an in-flight walk alone when HEAD did not move
// keeps a refresh that learned nothing new from flashing
// the commits tab.
if head_changed {
this.all_commits = None;
this.loading_all_commits = false;
this.load_all_commits(cx);
this.history.update(cx, |history, cx| history.reload(cx));
}
// A fast-forward may touch a branch that is not checked out.
// `catch_up_worktree` no-ops when the tree is unchanged and
// re-renders only when it actually rebuilt something.
if moved {
this.catch_up_worktree(cx);
}
@@ -240,7 +224,7 @@ impl RepoDetailView {
Ok(())
});
task.detach();
self.tasks.push(task);
}
fn apply_repo_data(&mut self, data: RepoData, window: &mut Window, cx: &mut Context<Self>) {
@@ -256,16 +240,24 @@ impl RepoDetailView {
current_branch,
head_commit,
} = data;
let Some(worktree) = worktree else {
self.error = Some("Repository has no worktree".into());
return;
};
self.worktree = Some(worktree);
self.worktree = Some(worktree.clone());
self.head_commit = head_commit;
self.worktree_paths = sorted_worktree_paths(&entries);
self.tree_state.update(cx, |state, cx| {
state.set_items(tree_items(tree, false), cx);
self.files.update(cx, |files, cx| {
files.set_worktree(worktree.clone());
files.apply_entries(tree, sorted_worktree_paths(&entries), cx);
files.set_readme(readme_path, readme, cx);
});
self.history.update(cx, |history, cx| {
history.set_worktree(Some(worktree));
history.reload(cx);
});
let branches: Vec<SharedString> = branches.into_iter().map(Into::into).collect();
@@ -279,26 +271,11 @@ impl RepoDetailView {
window,
cx,
);
Self::sync_ref_selector(&self.tag_select, &mut self.ref_tags, tags, None, 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);
}
}
}
/// Point a ref selector at `items`, selecting `selected` when given.
///
/// Updates the items and selection only when they differ from `cached` and
/// the current selection. `set_items`/`set_selected_values` notify the
/// combobox, which re-renders the header, so skipping the no-op keeps a
/// background refresh that learned nothing new from flashing the selectors.
/// Returns whether anything was set.
fn sync_ref_selector(
select: &Entity<ComboboxState<SearchableVec<SharedString>>>,
cached: &mut Vec<SharedString>,
@@ -308,6 +285,7 @@ impl RepoDetailView {
cx: &mut Context<Self>,
) -> bool {
let items_changed = *cached != items;
let selection_changed = selected
.as_ref()
.is_some_and(|value| select.read(cx).selected_value().as_ref() != Some(value));
@@ -340,7 +318,6 @@ impl RepoDetailView {
};
let addr = announcement.addr();
// Directory name, the display name falling back to the repo id.
// Both are sanitized to a safe single path component.
let name = announcement
.name
.as_ref()
@@ -348,6 +325,7 @@ impl RepoDetailView {
.filter(|name| !name.trim().is_empty())
.unwrap_or_else(|| addr.identifier.clone());
let name = signed_git::sanitize_path_component(&name);
if name.is_empty() {
"repository".to_owned()
} else {
@@ -392,7 +370,7 @@ impl RepoDetailView {
Ok(())
});
task.detach();
self.tasks.push(task);
}
}
+78 -114
View File
@@ -1,20 +1,20 @@
use std::collections::{HashMap, HashSet, VecDeque};
use std::collections::HashSet;
use std::path::PathBuf;
use std::rc::Rc;
use anyhow::Error;
use dock::{BasePanel, DockArea, Panel, PanelEvent};
use gpui::prelude::*;
use gpui::{
Action, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
SharedString, Size, Subscription, WeakEntity, Window,
Action, AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render,
SharedString, Subscription, Task, WeakEntity, Window, div,
};
use gpui_component::alert::Alert;
use gpui_component::combobox::{ComboboxEvent, ComboboxState};
use gpui_component::searchable_list::SearchableVec;
use gpui_component::tree::TreeState;
use gpui_component::{VirtualListScrollHandle, h_flex, v_flex};
use gpui_component::spinner::Spinner;
use gpui_component::{ActiveTheme, Sizable, v_flex};
use signed_core::{Announcement, RepoAddr};
use signed_git::{CommitList, FileCommit};
use signed_git::FileCommit;
use signed_state::{CheckoutStatus, CheckoutsStore, RepoStore};
mod about;
@@ -30,7 +30,8 @@ mod store;
pub(crate) use actions::{RepoItem, open_repo_item, open_repo_panel};
use self::files::{CodeView, FileContent, MarkdownView};
use self::files::RepoFilesView;
use self::history::RepoHistoryView;
#[derive(Clone, Copy, PartialEq, Eq)]
enum RefKind {
@@ -62,34 +63,14 @@ pub struct RepoDetailView {
/// A repository opened by address alone starts without an announcement; the
/// store observer starts the load once the first one lands.
repo_started: bool,
tree_state: Entity<TreeState>,
/// The Files tab, which owns the explorer, previews and the per-file commit map.
files: Entity<RepoFilesView>,
/// The checked-out worktree path, shared by the Files tab and the commit list.
worktree: Option<PathBuf>,
/// A background refresh that did not change the tree skips rebuilding it,
/// see [`Self::catch_up_worktree`], so a fetch that learned nothing new
/// does not flash the explorer.
worktree_paths: Vec<String>,
md: Option<MarkdownView>,
code: Option<CodeView>,
readme_name: Option<SharedString>,
selected_file: Option<SharedString>,
files: HashMap<String, FileContent>,
/// Paths of cached previews, oldest first. Feeds the eviction caps in
/// [`Self::evict_previews`].
file_order: VecDeque<String>,
preview_bytes: usize,
loading_files: HashSet<String>,
/// Latest commit touching a previewed file or the README, keyed by path.
commits: HashMap<String, FileCommit>,
pending_commits: Vec<String>,
loading_commits: bool,
/// 0 = Files tree, 1 = Commits.
active_tab: usize,
/// Commits reachable from HEAD, newest first. `None` until the walk
/// finishes or fails. `total` feeds the tab badge.
all_commits: Option<CommitList>,
loading_all_commits: bool,
scroll_handle: VirtualListScrollHandle,
item_sizes: Rc<Vec<Size<Pixels>>>,
/// The Commits tab, which owns the commit list and the commit diff panels.
history: Entity<RepoHistoryView>,
loading: bool,
error: Option<SharedString>,
head_commit: Option<FileCommit>,
@@ -102,6 +83,7 @@ pub struct RepoDetailView {
switching_ref: bool,
/// In-flight loads with an older generation are discarded when they complete.
ref_generation: u64,
tasks: Vec<Task<Result<(), Error>>>,
_subscriptions: Vec<Subscription>,
/// `(path, branch)` ready-suggestions dismissed by the user, per panel.
banner_dismissed: HashSet<(PathBuf, String)>,
@@ -151,10 +133,11 @@ impl RepoDetailView {
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let tree_state = cx.new(|cx| TreeState::new(cx));
let checkouts = CheckoutsStore::global(cx);
let files = cx.new(RepoFilesView::new);
let history = cx.new(|_cx| RepoHistoryView::new(store.clone(), dock_area.clone()));
// Empty until the clone completes, then filled with the local refs.
let branch_select: Entity<ComboboxState<SearchableVec<SharedString>>> = cx.new(|cx| {
let branch_select = cx.new(|cx| {
ComboboxState::new(
SearchableVec::new(Vec::<SharedString>::new()),
Vec::new(),
@@ -163,7 +146,8 @@ impl RepoDetailView {
)
.searchable(true)
});
let tag_select: Entity<ComboboxState<SearchableVec<SharedString>>> = cx.new(|cx| {
let tag_select = cx.new(|cx| {
ComboboxState::new(
SearchableVec::new(Vec::<SharedString>::new()),
Vec::new(),
@@ -175,9 +159,6 @@ impl RepoDetailView {
let mut subscriptions = vec![
cx.subscribe_in(&branch_select, window, |this, _state, event, window, cx| {
// `Change` fires only when the selection actually changed.
// Picking the already-selected branch emits nothing.
// A confirmed value always means a switch.
if let ComboboxEvent::Change(values) = event
&& let Some(name) = values.first()
{
@@ -193,11 +174,7 @@ impl RepoDetailView {
}),
];
// The ready-to-contribute and ready-to-push banners are driven by the
// global checkouts store. It notifies on every recompute; compare the
// statuses of this repository so unrelated updates (the sidebar badges,
// other open panels) do not re-render this panel.
let checkouts = CheckoutsStore::global(cx);
// The ready-to-contribute and ready-to-push banners are driven by the global checkouts store.
subscriptions.push(cx.observe(&checkouts, |this, _checkouts, cx| {
if this.refresh_statuses(cx) {
cx.notify();
@@ -213,25 +190,10 @@ impl RepoDetailView {
dock_area,
store: store.clone(),
repo_started: false,
tree_state,
files,
worktree: None,
worktree_paths: Vec::new(),
md: None,
code: None,
readme_name: None,
selected_file: None,
files: HashMap::new(),
file_order: VecDeque::new(),
preview_bytes: 0,
loading_files: HashSet::new(),
commits: HashMap::new(),
pending_commits: Vec::new(),
loading_commits: false,
active_tab: 0,
all_commits: None,
loading_all_commits: false,
scroll_handle: VirtualListScrollHandle::new(),
item_sizes: Rc::new(Vec::new()),
history,
loading: true,
error: None,
head_commit: None,
@@ -241,6 +203,7 @@ impl RepoDetailView {
ref_tags: Vec::new(),
switching_ref: false,
ref_generation: 0,
tasks: Vec::new(),
banner_dismissed: HashSet::new(),
ready_requested: false,
ready_head: None,
@@ -254,45 +217,65 @@ impl RepoDetailView {
view
}
/// The latest announcement of the repository, `None` while local-only or
/// until the store's first pass loads it.
/// The latest announcement of the repository,
///
/// `None` while local-only or until the store's first pass loads it.
fn announcement<'a>(&'a self, cx: &'a App) -> Option<&'a Announcement> {
self.store.read(cx).announcement.as_ref()
}
/// The announcement's name or ID for announced repositories, the directory
/// name for local ones.
fn display_name(&self, cx: &App) -> SharedString {
let store = self.store.read(cx);
if store.addr().is_none() {
return store
.path
.as_ref()
.map(|path| {
SharedString::from(
path.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string()),
)
})
.unwrap_or_default();
/// The Files tab body, or the clone/initial-load spinner.
fn render_files_tab(&self, cx: &mut Context<Self>) -> AnyElement {
if self.loading {
return v_flex()
.flex_1()
.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();
}
store
.announcement
.as_ref()
.map(|announcement| {
announcement
.name
.as_deref()
.map(SharedString::from)
.unwrap_or_else(|| SharedString::from(announcement.id.clone()))
})
.unwrap_or_default()
self.files.clone().into_any_element()
}
}
/// The announcement's name or ID for announced repositories, the directory name for local ones.
pub(super) fn repo_display_name(store: &RepoStore) -> SharedString {
if store.addr().is_none() {
return store
.path
.as_ref()
.map(|path| {
SharedString::from(
path.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string()),
)
})
.unwrap_or_default();
}
store
.announcement
.as_ref()
.map(|announcement| {
announcement
.name
.as_deref()
.map(SharedString::from)
.unwrap_or_else(|| SharedString::from(announcement.id.clone()))
})
.unwrap_or_default()
}
impl BasePanel for RepoDetailView {
fn panel_name(&self) -> &'static str {
"repo"
@@ -301,7 +284,7 @@ impl BasePanel for RepoDetailView {
impl Panel for RepoDetailView {
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
self.display_name(cx)
repo_display_name(self.store.read(cx))
}
}
@@ -315,21 +298,10 @@ impl Focusable for RepoDetailView {
impl Render for RepoDetailView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let tree_state = self.tree_state.clone();
let view = cx.entity().downgrade();
let pane_title = self
.selected_file
.clone()
.or_else(|| self.readme_name.clone())
.unwrap_or_else(|| "Overview".into());
let banner = self
.render_ready_banner(cx)
.or_else(|| self.render_push_banner(cx));
// View-level load/switch errors, plus the errors of the store-owned
// operations, republish, checkout push, delete and clone-to-folder.
let error = self.error.clone().or_else(|| {
self.store
.read(cx)
@@ -359,16 +331,8 @@ impl Render for RepoDetailView {
)
})
.map(|this| match self.active_tab {
0 => this.child(
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(),
),
_ => this.child(self.render_commits_tab(cx)),
0 => this.child(self.render_files_tab(cx)),
_ => this.child(self.history.clone()),
})
}
}
+31 -112
View File
@@ -1,5 +1,3 @@
use std::collections::HashSet;
use anyhow::Error;
use gpui::prelude::*;
use gpui::{Context, Entity, SharedString, Window};
@@ -7,26 +5,27 @@ use gpui_component::combobox::ComboboxState;
use gpui_component::searchable_list::SearchableVec;
use super::{RefKind, RepoDetailView};
use crate::views::tree::{build_tree_items, sorted_worktree_paths, tree_items};
use crate::views::tree::{build_tree_items, sorted_worktree_paths};
impl RepoDetailView {
pub(super) fn switch_ref(
pub(super) fn switch_ref<T>(
&mut self,
kind: RefKind,
name: SharedString,
name: T,
window: &mut Window,
cx: &mut Context<Self>,
) {
) where
T: Into<SharedString>,
{
if self.switching_ref {
return;
}
let Some(worktree) = self.worktree.clone() else {
return;
};
// Branches and tags are mutually exclusive states of HEAD.
// Selecting one clears the other selector.
// Remember the previous selections to restore them if the checkout fails.
let name = name.into();
let previous_branch = self.branch_select.read(cx).selected_value();
let previous_tag = self.tag_select.read(cx).selected_value();
@@ -40,8 +39,8 @@ impl RepoDetailView {
.update(cx, |state, cx| state.clear_selection(cx));
}
}
self.switching_ref = true;
// In-flight loads of the previous branch are discarded when they complete.
self.ref_generation += 1;
cx.notify();
@@ -76,7 +75,7 @@ impl RepoDetailView {
Ok(())
});
task.detach();
self.tasks.push(task);
}
fn restore_selection(
@@ -113,45 +112,21 @@ impl RepoDetailView {
match result {
Ok((snapshot, tree, paths)) => {
this.head_commit = snapshot.head_commit;
this.worktree_paths = paths;
// Rebuild the tree from scratch.
// Entries of the previous branch are gone.
// The expansion state goes with them.
this.tree_state.update(cx, |state, cx| {
state.set_items(tree_items(tree, false), cx);
let readme_path = snapshot.readme_path;
let readme = snapshot.readme;
this.files.update(cx, |files, cx| {
files.clear_previews();
files.apply_entries(tree, paths, cx);
files.set_readme(readme_path, readme, cx);
});
// Drop cached previews and commits of the old branch.
this.selected_file = None;
this.files.clear();
this.file_order.clear();
this.preview_bytes = 0;
this.loading_files.clear();
this.commits.clear();
this.pending_commits.clear();
this.loading_commits = false;
this.md = None;
this.code = None;
this.readme_name = None;
this.all_commits = None;
this.loading_all_commits = false;
if let Some((path, bytes)) = snapshot.readme_path.zip(snapshot.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);
}
}
this.load_all_commits(cx);
this.history.update(cx, |history, cx| history.reload(cx));
}
Err(error) => {
this.error = Some(error.to_string().into());
this.head_commit = None;
this.worktree_paths.clear();
// The tree may show files that no longer exist.
this.tree_state.update(cx, |state, cx| {
state.set_items(Vec::new(), cx);
this.files.update(cx, |files, cx| {
files.apply_entries(Vec::new(), Vec::new(), cx);
});
}
}
@@ -161,16 +136,10 @@ impl RepoDetailView {
Ok(())
});
task.detach();
self.tasks.push(task);
}
/// Refresh the file explorer, previews and commit list after the mirror
/// caught up with the remote.
///
/// The checked-out branch fast-forwarded in place, so unlike
/// [`Self::reload_worktree`] this keeps the panel's selection and previews:
/// it rebuilds the tree, drops previews of files the refresh removed and
/// re-renders the README when it is on screen.
/// Refresh the file explorer, previews and commit list after the mirror caught up with the remote.
pub(super) fn catch_up_worktree(&mut self, cx: &mut Context<Self>) {
let Some(worktree) = self.worktree.clone() else {
return;
@@ -192,87 +161,37 @@ impl RepoDetailView {
let head_changed = snapshot.head_commit.as_ref().map(|c| &c.id)
!= this.head_commit.as_ref().map(|c| &c.id);
let files_changed = this
.files
.update(cx, |files, cx| files.catch_up(&snapshot, tree, paths, cx));
// A fast-forward of a branch other than the checked-out
// one leaves the worktree untouched. Rebuilding the tree
// and re-parsing the README would flash the panel for
// nothing, so it is a no-op.
if !head_changed && paths == this.worktree_paths {
if !head_changed && !files_changed {
log::debug!("repo detail catch_up_worktree: no-op");
return;
}
log::debug!(
"repo detail catch_up_worktree: head_changed={head_changed} entries={}",
paths.len()
);
this.head_commit = snapshot.head_commit;
this.worktree_paths = paths;
this.tree_state.update(cx, |state, cx| {
state.set_items(tree_items(tree, false), cx);
});
// Drop previews of files the refresh removed from the worktree,
// everything else stays put.
let present: HashSet<String> = snapshot
.entries
.iter()
.map(|path| path.to_string_lossy().into_owned())
.collect();
let mut previewed: Vec<String> = Vec::new();
previewed.extend(this.files.keys().cloned());
previewed.extend(this.selected_file.clone().map(|p| p.to_string()));
if let Some(path) = this.md.as_ref().and_then(|md| md.path.clone()) {
previewed.push(path.to_string());
}
if let Some(path) = this.code.as_ref().map(|code| code.path.clone()) {
previewed.push(path.to_string());
}
previewed.sort();
previewed.dedup();
for path in previewed {
if !present.contains(&path) {
this.drop_preview_of(&path);
}
}
// Re-render the README when it is on screen, i.e. when no file preview is open.
if this.selected_file.is_none() {
match snapshot.readme_path.zip(snapshot.readme) {
Some((path, bytes)) => {
this.readme_name = Some(path.to_string_lossy().into());
if let Ok(text) = String::from_utf8(bytes) {
this.set_markdown(None, &text, cx);
}
}
None => {
this.md = None;
this.readme_name = None;
}
}
}
if head_changed {
this.all_commits = None;
this.loading_all_commits = false;
this.load_all_commits(cx);
this.history.update(cx, |history, cx| history.reload(cx));
}
cx.notify();
}
Err(error) => {
this.error = Some(error.to_string().into());
cx.notify();
}
}
cx.notify();
})?;
Ok(())
});
task.detach();
self.tasks.push(task);
}
}
+1 -7
View File
@@ -5,15 +5,13 @@ use signed_state::{Backend, CheckoutsStore, LocalReposStore, RepoStore};
use super::RepoDetailView;
impl RepoDetailView {
/// Switch a local repository into its NIP-34 mode after a successful init.
/// The store is kept, so the panel keeps its path and loaded worktree.
/// Drops the local scan identity so it leaves the sidebar's local section.
pub(crate) fn apply_announcement(
&mut self,
announcement: Announcement,
cx: &mut Context<Self>,
) {
let path = self.store.read(cx).path.clone();
if let Some(path) = path {
LocalReposStore::global(cx).update(cx, |store, cx| store.remove(&path, cx));
}
@@ -43,10 +41,6 @@ impl RepoDetailView {
self.refresh_ready_statuses(cx);
}
/// Request the statuses of this repository again when the announced HEAD changes.
/// The HEAD is the base the checkouts are compared against.
/// Owned repositories are watched for unpushed commits.
/// Other repositories for ready-to-contribute checkouts.
fn refresh_ready_statuses(&mut self, cx: &mut Context<Self>) {
let Some(addr) = self.store.read(cx).addr().cloned() else {
return;