.
This commit is contained in:
@@ -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()?;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
})),
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user