From b59f6a95deb2d06e99d768b4648ff67fb9742271 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 13 Sep 2026 15:46:34 +0700 Subject: [PATCH] . --- crates/workspace/src/views/repo/actions.rs | 32 +- crates/workspace/src/views/repo/banners.rs | 5 - crates/workspace/src/views/repo/files.rs | 397 ++++++++++++++++----- crates/workspace/src/views/repo/header.rs | 14 +- crates/workspace/src/views/repo/history.rs | 250 ++++++------- crates/workspace/src/views/repo/loading.rs | 64 ++-- crates/workspace/src/views/repo/mod.rs | 192 ++++------ crates/workspace/src/views/repo/refs.rs | 143 ++------ crates/workspace/src/views/repo/store.rs | 8 +- 9 files changed, 566 insertions(+), 539 deletions(-) diff --git a/crates/workspace/src/views/repo/actions.rs b/crates/workspace/src/views/repo/actions.rs index 8682bf2..cc4ffb9 100644 --- a/crates/workspace/src/views/repo/actions.rs +++ b/crates/workspace/src/views/repo/actions.rs @@ -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> = 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.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) { @@ -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, 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 { 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, addr: &RepoAddr, diff --git a/crates/workspace/src/views/repo/banners.rs b/crates/workspace/src/views/repo/banners.rs index ee9e1c7..db16bf0 100644 --- a/crates/workspace/src/views/repo/banners.rs +++ b/crates/workspace/src/views/repo/banners.rs @@ -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 { let store = self.store.read(cx); let addr = store.addr()?; diff --git a/crates/workspace/src/views/repo/files.rs b/crates/workspace/src/views/repo/files.rs index d9c85d2..1ff85c0 100644 --- a/crates/workspace/src/views/repo/files.rs +++ b/crates/workspace/src/views/repo/files.rs @@ -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, - pub(super) state: Entity, + path: Option, + state: Entity, /// 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, + path: SharedString, + state: Entity, /// 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, + worktree: Option, + worktree_paths: Vec, + md: Option, + code: Option, + readme_name: Option, + selected_file: Option, + files: HashMap, + file_order: VecDeque, + preview_bytes: usize, + loading_files: HashSet, + commits: HashMap, + pending_commits: Vec, + loading_commits: bool, + generation: u64, + tasks: Vec>>, } -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 { + 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, + paths: Vec, + cx: &mut Context, + ) { + 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, + bytes: Option>, + cx: &mut Context, + ) -> 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, + paths: Vec, + cx: &mut Context, + ) -> bool { + let mut changed = false; + + if paths != self.worktree_paths { + self.apply_entries(tree, paths, cx); + changed = true; + } + + let present: HashSet = snapshot + .entries + .iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect(); + + let mut previewed: Vec = 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, view: WeakEntity, cx: &mut Context, @@ -101,43 +245,12 @@ impl RepoDetailView { ))) } - pub(super) fn render_content_column( + fn render_content_column( &self, pane_title: SharedString, cx: &mut Context, ) -> 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, text: &str, cx: &mut Context, - ) { + ) -> 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) -> 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, ) { 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.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> = cx.spawn_in(window, async move |this, cx| { + let task: Task> = 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) { + 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) { + 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> = cx.spawn(async move |this, cx| { + let rels: Vec = 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) -> 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. diff --git a/crates/workspace/src/views/repo/header.rs b/crates/workspace/src/views/repo/header.rs index e0560df..f2f82d5 100644 --- a/crates/workspace/src/views/repo/header.rs +++ b/crates/workspace/src/views/repo/header.rs @@ -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) -> 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) -> 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) + }); } })), ) diff --git a/crates/workspace/src/views/repo/history.rs b/crates/workspace/src/views/repo/history.rs index 599806d..9e47eb9 100644 --- a/crates/workspace/src/views/repo/history.rs +++ b/crates/workspace/src/views/repo/history.rs @@ -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) -> AnyElement { +pub(super) struct RepoHistoryView { + store: Entity, + dock_area: WeakEntity, + worktree: Option, + all_commits: Option, + loading_all_commits: bool, + scroll_handle: VirtualListScrollHandle, + item_sizes: Rc>>, + /// Bumped on reload, so an in-flight walk of the previous HEAD is discarded. + generation: u64, + tasks: Vec>>, +} + +impl RepoHistoryView { + pub(super) fn new(store: Entity, dock_area: WeakEntity) -> 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) { + self.worktree = path; + } + + /// Number of commits reachable from HEAD, for the Commits tab badge. + pub(super) fn commit_count(&self) -> Option { + 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.generation += 1; + self.all_commits = None; + self.loading_all_commits = false; + self.load(cx); + } + + fn load(&mut self, cx: &mut Context) { + 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> = 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, + ) { + 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) -> 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) { - 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) { - 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> = cx.spawn(async move |this, cx| { - let rels: Vec = 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) { - 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> = 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, - ) { - 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); - }); - } -} diff --git a/crates/workspace/src/views/repo/loading.rs b/crates/workspace/src/views/repo/loading.rs index edab3a8..e0726d1 100644 --- a/crates/workspace/src/views/repo/loading.rs +++ b/crates/workspace/src/views/repo/loading.rs @@ -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, - /// Relative paths of the worktree entries, for [`RepoDetailView::worktree_paths`]. entries: Vec, readme_path: Option, readme: Option>, @@ -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.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) { @@ -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 = 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>>, cached: &mut Vec, @@ -308,6 +285,7 @@ impl RepoDetailView { cx: &mut Context, ) -> 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); } } diff --git a/crates/workspace/src/views/repo/mod.rs b/crates/workspace/src/views/repo/mod.rs index 5702bf9..d30cdb5 100644 --- a/crates/workspace/src/views/repo/mod.rs +++ b/crates/workspace/src/views/repo/mod.rs @@ -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, + /// The Files tab, which owns the explorer, previews and the per-file commit map. + files: Entity, + /// The checked-out worktree path, shared by the Files tab and the commit list. worktree: Option, - /// 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, - md: Option, - code: Option, - readme_name: Option, - selected_file: Option, - files: HashMap, - /// Paths of cached previews, oldest first. Feeds the eviction caps in - /// [`Self::evict_previews`]. - file_order: VecDeque, - preview_bytes: usize, - loading_files: HashSet, - /// Latest commit touching a previewed file or the README, keyed by path. - commits: HashMap, - pending_commits: Vec, - 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, - loading_all_commits: bool, - scroll_handle: VirtualListScrollHandle, - item_sizes: Rc>>, + /// The Commits tab, which owns the commit list and the commit diff panels. + history: Entity, loading: bool, error: Option, head_commit: Option, @@ -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>>, _subscriptions: Vec, /// `(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 { - 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>> = cx.new(|cx| { + let branch_select = cx.new(|cx| { ComboboxState::new( SearchableVec::new(Vec::::new()), Vec::new(), @@ -163,7 +146,8 @@ impl RepoDetailView { ) .searchable(true) }); - let tag_select: Entity>> = cx.new(|cx| { + + let tag_select = cx.new(|cx| { ComboboxState::new( SearchableVec::new(Vec::::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) -> 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) -> 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) -> 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()), }) } } diff --git a/crates/workspace/src/views/repo/refs.rs b/crates/workspace/src/views/repo/refs.rs index 05ddb52..cc5ab79 100644 --- a/crates/workspace/src/views/repo/refs.rs +++ b/crates/workspace/src/views/repo/refs.rs @@ -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( &mut self, kind: RefKind, - name: SharedString, + name: T, window: &mut Window, cx: &mut Context, - ) { + ) where + T: Into, + { 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) { 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 = snapshot - .entries - .iter() - .map(|path| path.to_string_lossy().into_owned()) - .collect(); - - let mut previewed: Vec = 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); } } diff --git a/crates/workspace/src/views/repo/store.rs b/crates/workspace/src/views/repo/store.rs index b67bb06..77bf495 100644 --- a/crates/workspace/src/views/repo/store.rs +++ b/crates/workspace/src/views/repo/store.rs @@ -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, ) { 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) { let Some(addr) = self.store.read(cx).addr().cloned() else { return;