add repository history and commit diff views
Rust / build (macos-latest, stable) (push) Waiting to run
Rust / build (ubuntu-latest, stable) (push) Waiting to run
Rust / build (windows-latest, stable) (push) Waiting to run
Rust / build (macos-latest, stable) (pull_request) Waiting to run
Rust / build (ubuntu-latest, stable) (pull_request) Waiting to run
Rust / build (windows-latest, stable) (pull_request) Waiting to run
Rust / build (macos-latest, stable) (push) Waiting to run
Rust / build (ubuntu-latest, stable) (push) Waiting to run
Rust / build (windows-latest, stable) (push) Waiting to run
Rust / build (macos-latest, stable) (pull_request) Waiting to run
Rust / build (ubuntu-latest, stable) (pull_request) Waiting to run
Rust / build (windows-latest, stable) (pull_request) Waiting to run
This commit is contained in:
@@ -4,7 +4,7 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
use gpui_native_kit::icons::IconRole;
|
use gpui_native_kit::icons::IconRole;
|
||||||
use gpui_native_kit::layout::dock::{Dock, DockState};
|
use gpui_native_kit::layout::dock::{Dock, DockState};
|
||||||
use gpui_native_kit::layout::shell::{LayoutState, Shell};
|
use gpui_native_kit::layout::shell::{LayoutState, Shell, SidebarToggles};
|
||||||
use gpui_native_kit::theme::{ActiveTheme as _, ThemeVariant, follow_system, set_mode_override};
|
use gpui_native_kit::theme::{ActiveTheme as _, ThemeVariant, follow_system, set_mode_override};
|
||||||
use gpui_native_kit::toast::Toast;
|
use gpui_native_kit::toast::Toast;
|
||||||
use settings::{AppearanceMode, Settings, SettingsStore};
|
use settings::{AppearanceMode, Settings, SettingsStore};
|
||||||
@@ -24,8 +24,12 @@ pub struct AppRoot {
|
|||||||
|
|
||||||
impl AppRoot {
|
impl AppRoot {
|
||||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
let layout = LayoutState::new(cx);
|
|
||||||
let dock = DockState::new(cx);
|
let dock = DockState::new(cx);
|
||||||
|
let layout = LayoutState::new(cx);
|
||||||
|
|
||||||
|
layout.update(cx, |layout, cx| {
|
||||||
|
layout.set_sidebar_toggles(SidebarToggles::Hidden, cx);
|
||||||
|
});
|
||||||
|
|
||||||
dock.update(cx, |dock, cx| {
|
dock.update(cx, |dock, cx| {
|
||||||
dock.register_panel("welcome", |_, cx| {
|
dock.register_panel("welcome", |_, cx| {
|
||||||
@@ -42,8 +46,11 @@ impl AppRoot {
|
|||||||
let dialogs = cx.new(DialogHost::new);
|
let dialogs = cx.new(DialogHost::new);
|
||||||
DialogHost::set_global(dialogs.clone(), cx);
|
DialogHost::set_global(dialogs.clone(), cx);
|
||||||
|
|
||||||
let mut subscriptions = Vec::new();
|
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
|
let settings = SettingsStore::global(cx);
|
||||||
|
|
||||||
|
let mut subscriptions = Vec::new();
|
||||||
|
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
cx.subscribe(&backend, |this, _backend, event, cx| match event {
|
cx.subscribe(&backend, |this, _backend, event, cx| match event {
|
||||||
BackendEvent::PassphraseRequired => dialogs::passphrase::open(cx),
|
BackendEvent::PassphraseRequired => dialogs::passphrase::open(cx),
|
||||||
@@ -56,15 +63,13 @@ impl AppRoot {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
let settings = SettingsStore::global(cx);
|
|
||||||
subscriptions.push(cx.observe(&settings, |_this, store, cx| {
|
subscriptions.push(cx.observe(&settings, |_this, store, cx| {
|
||||||
let settings = store.read(cx).settings().clone();
|
let settings = store.read(cx).settings().clone();
|
||||||
apply_appearance(&settings, cx);
|
apply_appearance(&settings, cx);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
window.defer(cx, |_window, cx| {
|
window.defer(cx, move |_window, cx| {
|
||||||
let required = Backend::global(cx).read(cx).passphrase_required();
|
if backend.read(cx).passphrase_required() {
|
||||||
if required {
|
|
||||||
dialogs::passphrase::open(cx);
|
dialogs::passphrase::open(cx);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,661 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use gpui::prelude::FluentBuilder as _;
|
||||||
|
use gpui::{
|
||||||
|
AnyElement, App, AppContext as _, Axis, Context, Entity, IntoElement, ParentElement as _,
|
||||||
|
Render, ScrollStrategy, SharedString, Styled as _, Task, UniformListScrollHandle, Window, div,
|
||||||
|
px, uniform_list,
|
||||||
|
};
|
||||||
|
use gpui_native_kit::badge::{Badge, BadgeVariant};
|
||||||
|
use gpui_native_kit::clipboard::ClipboardButton;
|
||||||
|
use gpui_native_kit::common::{h_flex, v_flex};
|
||||||
|
use gpui_native_kit::data::tree::{Tree, TreeItem};
|
||||||
|
use gpui_native_kit::empty_state::EmptyState;
|
||||||
|
use gpui_native_kit::icons::{Icon, IconRole};
|
||||||
|
use gpui_native_kit::layout::dock::Panel;
|
||||||
|
use gpui_native_kit::layout::resizable::{Resizable, ResizableState};
|
||||||
|
use gpui_native_kit::scrollbar::Scrollbar;
|
||||||
|
use gpui_native_kit::spinner::Spinner;
|
||||||
|
use gpui_native_kit::theme::ActiveTheme as _;
|
||||||
|
use metadata::ProfileDirectory;
|
||||||
|
use signed_git::{
|
||||||
|
CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileCommit, FileDiff, Repo,
|
||||||
|
};
|
||||||
|
use signed_state::RepoStore;
|
||||||
|
use utils::relative_time_secs;
|
||||||
|
|
||||||
|
use crate::views::tree::{TreeRow, build_tree_items, collect_expanded, flatten};
|
||||||
|
|
||||||
|
pub struct DiffPane {
|
||||||
|
diff: Option<CommitDiff>,
|
||||||
|
seeds: Vec<crate::views::tree::TreeItemSeed>,
|
||||||
|
expanded: HashSet<String>,
|
||||||
|
rows: Vec<TreeRow>,
|
||||||
|
selected_file: Option<SharedString>,
|
||||||
|
selected_index: Option<usize>,
|
||||||
|
/// Rows of the selected file's diff, hunk headers and lines.
|
||||||
|
diff_rows: Vec<DiffRow>,
|
||||||
|
scroll_handle: UniformListScrollHandle,
|
||||||
|
split: Entity<ResizableState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DiffPane {
|
||||||
|
pub fn new(cx: &mut Context<Self>) -> Self {
|
||||||
|
Self {
|
||||||
|
diff: None,
|
||||||
|
seeds: Vec::new(),
|
||||||
|
expanded: HashSet::new(),
|
||||||
|
rows: Vec::new(),
|
||||||
|
selected_file: None,
|
||||||
|
selected_index: None,
|
||||||
|
diff_rows: Vec::new(),
|
||||||
|
scroll_handle: UniformListScrollHandle::new(),
|
||||||
|
split: {
|
||||||
|
let split = ResizableState::new(cx);
|
||||||
|
split.update(cx, |split, cx| split.set_sizes([px(260.)], cx));
|
||||||
|
split
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn diff(&self) -> Option<&CommitDiff> {
|
||||||
|
self.diff.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_diff(&mut self, diff: CommitDiff, cx: &mut Context<Self>) {
|
||||||
|
let mut paths: Vec<PathBuf> = diff
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.map(|file| PathBuf::from(&file.path))
|
||||||
|
.collect();
|
||||||
|
paths.sort();
|
||||||
|
self.seeds = build_tree_items(&paths);
|
||||||
|
self.expanded = collect_expanded(&self.seeds);
|
||||||
|
self.rows = flatten(&self.seeds, &self.expanded);
|
||||||
|
|
||||||
|
let first = diff
|
||||||
|
.files
|
||||||
|
.first()
|
||||||
|
.map(|file| SharedString::from(file.path.as_str()));
|
||||||
|
|
||||||
|
self.selected_file = first.clone();
|
||||||
|
self.selected_index = first
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|path| self.rows.iter().position(|row| row.id == path.as_ref()));
|
||||||
|
self.diff = Some(diff);
|
||||||
|
|
||||||
|
if let Some(path) = first {
|
||||||
|
self.set_diff_rows(path.as_ref());
|
||||||
|
} else {
|
||||||
|
self.diff_rows.clear();
|
||||||
|
}
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_file(&mut self, path: &str, cx: &mut Context<Self>) {
|
||||||
|
self.selected_file = Some(path.into());
|
||||||
|
self.selected_index = self.rows.iter().position(|row| row.id == path);
|
||||||
|
self.set_diff_rows(path);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_diff_rows(&mut self, path: &str) {
|
||||||
|
let Some(diff) = self.diff.as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(file) = diff.files.iter().find(|file| file.path == path) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
self.diff_rows = diff_rows(file);
|
||||||
|
self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toggle_folder(&mut self, id: &str, cx: &mut Context<Self>) {
|
||||||
|
if !self.expanded.remove(id) {
|
||||||
|
self.expanded.insert(id.to_owned());
|
||||||
|
}
|
||||||
|
self.rows = flatten(&self.seeds, &self.expanded);
|
||||||
|
self.selected_index = self
|
||||||
|
.selected_file
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|path| self.rows.iter().position(|row| row.id == path.as_ref()));
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_tree_column(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||||
|
let rows = self.rows.clone();
|
||||||
|
let selected = self.selected_index;
|
||||||
|
let view = cx.entity().downgrade();
|
||||||
|
let change_view = view.clone();
|
||||||
|
|
||||||
|
v_flex()
|
||||||
|
.h_full()
|
||||||
|
.min_h_0()
|
||||||
|
.p_2()
|
||||||
|
.child(
|
||||||
|
Tree::new("commit-diff-tree")
|
||||||
|
.selected(selected)
|
||||||
|
.items(move |_, _| {
|
||||||
|
rows.iter()
|
||||||
|
.map(|row| {
|
||||||
|
let item = TreeItem::new(row.id.clone(), row.label.clone())
|
||||||
|
.depth(row.depth)
|
||||||
|
.expandable(row.expandable)
|
||||||
|
.expanded(row.expanded);
|
||||||
|
|
||||||
|
if row.expandable {
|
||||||
|
item.icon(Icon::named(IconRole::Folder))
|
||||||
|
} else {
|
||||||
|
item
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.on_change(move |index, window, cx| {
|
||||||
|
if let Some(index) = index
|
||||||
|
&& let Err(error) = change_view.update(cx, |this, cx| {
|
||||||
|
this.activate_row(index, window, cx);
|
||||||
|
})
|
||||||
|
{
|
||||||
|
log::warn!("failed to activate diff tree row: {error}");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on_disclosure({
|
||||||
|
let view = view;
|
||||||
|
move |index, _, cx| {
|
||||||
|
if let Err(error) = view.update(cx, |this, cx| {
|
||||||
|
let id = this.rows.get(index).map(|row| row.id.clone());
|
||||||
|
if let Some(id) = id {
|
||||||
|
this.toggle_folder(&id, cx);
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
log::warn!("failed to toggle diff folder: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn activate_row(&mut self, index: usize, _window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let Some(row) = self.rows.get(index) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let id = row.id.clone();
|
||||||
|
let expandable = row.expandable;
|
||||||
|
self.selected_index = Some(index);
|
||||||
|
|
||||||
|
if expandable {
|
||||||
|
self.toggle_folder(&id, cx);
|
||||||
|
} else {
|
||||||
|
self.select_file(&id, cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_detail_column(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||||
|
let Some(diff) = self.diff.as_ref() else {
|
||||||
|
return placeholder("No changes");
|
||||||
|
};
|
||||||
|
let Some(path) = self.selected_file.clone() else {
|
||||||
|
return if diff.files.is_empty() {
|
||||||
|
placeholder("No files changed")
|
||||||
|
} else {
|
||||||
|
placeholder("Select a file")
|
||||||
|
};
|
||||||
|
};
|
||||||
|
let Some(file) = diff.files.iter().find(|file| file.path == path.as_ref()) else {
|
||||||
|
return placeholder("File not found");
|
||||||
|
};
|
||||||
|
self.render_file_diff(file, cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_file_diff(&self, file: &FileDiff, cx: &App) -> AnyElement {
|
||||||
|
let status_label = match file.status {
|
||||||
|
DiffStatus::Added => "A",
|
||||||
|
DiffStatus::Modified => "M",
|
||||||
|
DiffStatus::Deleted => "D",
|
||||||
|
DiffStatus::Renamed => "R",
|
||||||
|
DiffStatus::Copied => "C",
|
||||||
|
};
|
||||||
|
let status_color = match file.status {
|
||||||
|
DiffStatus::Added => cx.theme().ramps.success.base,
|
||||||
|
DiffStatus::Modified => cx.theme().ramps.info.base,
|
||||||
|
DiffStatus::Deleted => cx.theme().ramps.destructive.base,
|
||||||
|
DiffStatus::Renamed | DiffStatus::Copied => cx.theme().colors.muted_foreground,
|
||||||
|
};
|
||||||
|
let title = match &file.old_path {
|
||||||
|
Some(old) => format!("{old} → {}", file.path),
|
||||||
|
None => file.path.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let body: AnyElement = if file.binary {
|
||||||
|
placeholder("Diff not available")
|
||||||
|
} else if file.hunks.is_empty() {
|
||||||
|
placeholder("No content changes")
|
||||||
|
} else {
|
||||||
|
let hunks = file.hunks.clone();
|
||||||
|
let rows = self.diff_rows.clone();
|
||||||
|
|
||||||
|
v_flex()
|
||||||
|
.size_full()
|
||||||
|
.relative()
|
||||||
|
.child(
|
||||||
|
uniform_list("commit-diff-rows", rows.len(), move |range, _, cx| {
|
||||||
|
range
|
||||||
|
.map(|ix| render_diff_row(&hunks, rows[ix], cx))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.track_scroll(&self.scroll_handle)
|
||||||
|
.size_full(),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.absolute()
|
||||||
|
.top_0()
|
||||||
|
.left_0()
|
||||||
|
.right_0()
|
||||||
|
.bottom_0()
|
||||||
|
.child(Scrollbar::vertical(
|
||||||
|
"commit-diff-scroll",
|
||||||
|
self.scroll_handle.clone(),
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
};
|
||||||
|
|
||||||
|
v_flex()
|
||||||
|
.flex_1()
|
||||||
|
.min_w_0()
|
||||||
|
.h_full()
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.px_3()
|
||||||
|
.h_9()
|
||||||
|
.gap_2()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.font_weight(gpui::FontWeight::SEMIBOLD)
|
||||||
|
.text_color(status_color)
|
||||||
|
.child(status_label),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_1()
|
||||||
|
.min_w_0()
|
||||||
|
.text_xs()
|
||||||
|
.font_weight(gpui::FontWeight::SEMIBOLD)
|
||||||
|
.text_ellipsis()
|
||||||
|
.whitespace_nowrap()
|
||||||
|
.child(title),
|
||||||
|
)
|
||||||
|
.when(!file.binary, |this| {
|
||||||
|
this.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_2()
|
||||||
|
.text_xs()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_color(cx.theme().ramps.success.base)
|
||||||
|
.child(format!("+{}", file.insertions)),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_color(cx.theme().ramps.destructive.base)
|
||||||
|
.child(format!("-{}", file.deletions)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.child(div().flex_1().min_h_0().child(body))
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for DiffPane {
|
||||||
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let tree = self.render_tree_column(cx);
|
||||||
|
let detail = self.render_detail_column(cx);
|
||||||
|
|
||||||
|
h_flex()
|
||||||
|
.size_full()
|
||||||
|
.min_h_0()
|
||||||
|
.bg(cx.theme().colors.background)
|
||||||
|
.child(
|
||||||
|
Resizable::new("commit-diff-split", self.split.clone())
|
||||||
|
.axis(Axis::Horizontal)
|
||||||
|
.pane_with_min(tree, px(120.))
|
||||||
|
.pane_with_min(detail, px(240.)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CommitDiffView {
|
||||||
|
store: Entity<RepoStore>,
|
||||||
|
worktree: PathBuf,
|
||||||
|
repo_name: SharedString,
|
||||||
|
commit: FileCommit,
|
||||||
|
/// The diff is being computed on a background task.
|
||||||
|
loading: bool,
|
||||||
|
error: Option<SharedString>,
|
||||||
|
pane: Entity<DiffPane>,
|
||||||
|
/// Profile for the repository owner in the tab title.
|
||||||
|
directory: Entity<ProfileDirectory>,
|
||||||
|
tasks: Vec<Task<Result<(), anyhow::Error>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommitDiffView {
|
||||||
|
pub fn new(
|
||||||
|
store: Entity<RepoStore>,
|
||||||
|
worktree: PathBuf,
|
||||||
|
repo_name: SharedString,
|
||||||
|
commit_id: String,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> Self {
|
||||||
|
let pane = cx.new(DiffPane::new);
|
||||||
|
let directory = ProfileDirectory::new(cx);
|
||||||
|
|
||||||
|
if let Some(owner) = store.read(cx).announcement.as_ref().map(|a| a.owner) {
|
||||||
|
directory.update(cx, |directory, cx| {
|
||||||
|
directory.track([owner], cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let weak = cx.entity().downgrade();
|
||||||
|
window.defer(cx, move |window, cx| {
|
||||||
|
if let Err(error) = weak.update(cx, |this, cx| this.load(window, cx)) {
|
||||||
|
log::warn!("failed to load commit diff: {error}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
store,
|
||||||
|
worktree,
|
||||||
|
repo_name,
|
||||||
|
commit: FileCommit {
|
||||||
|
id: commit_id,
|
||||||
|
summary: String::new(),
|
||||||
|
description: None,
|
||||||
|
author: String::new(),
|
||||||
|
time: 0,
|
||||||
|
},
|
||||||
|
loading: true,
|
||||||
|
error: None,
|
||||||
|
pane,
|
||||||
|
directory,
|
||||||
|
tasks: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
self.loading = true;
|
||||||
|
self.error = None;
|
||||||
|
cx.notify();
|
||||||
|
|
||||||
|
let worktree = self.worktree.clone();
|
||||||
|
let id = self.commit.id.clone();
|
||||||
|
|
||||||
|
let task: Task<Result<(), anyhow::Error>> = cx.spawn_in(window, async move |this, cx| {
|
||||||
|
let commit = cx
|
||||||
|
.background_spawn({
|
||||||
|
let worktree = worktree.clone();
|
||||||
|
let id = id.clone();
|
||||||
|
async move { Repo::open(&worktree).and_then(|repo| repo.commit(&id)) }
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let diff = cx
|
||||||
|
.background_spawn({
|
||||||
|
let worktree = worktree.clone();
|
||||||
|
let id = id.clone();
|
||||||
|
async move { Repo::open(&worktree).and_then(|repo| repo.commit_diff(&id)) }
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
this.update_in(cx, |this, _window, cx| {
|
||||||
|
this.loading = false;
|
||||||
|
if let Ok(Some(commit)) = commit {
|
||||||
|
this.commit = commit;
|
||||||
|
}
|
||||||
|
match diff {
|
||||||
|
Ok(diff) => {
|
||||||
|
this.pane.update(cx, |pane, cx| pane.set_diff(diff, cx));
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
this.error = Some(error.to_string().into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cx.notify();
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
|
||||||
|
self.tasks.push(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||||
|
let commit = &self.commit;
|
||||||
|
let (files, insertions, deletions) = self.pane.read(cx).diff().map_or((0, 0, 0), |diff| {
|
||||||
|
(
|
||||||
|
diff.files.len(),
|
||||||
|
diff.files.iter().map(|file| file.insertions).sum(),
|
||||||
|
diff.files.iter().map(|file| file.deletions).sum(),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
v_flex()
|
||||||
|
.flex_none()
|
||||||
|
.px_4()
|
||||||
|
.py_3()
|
||||||
|
.w_full()
|
||||||
|
.gap_2()
|
||||||
|
.border_b_1()
|
||||||
|
.border_color(cx.theme().colors.border)
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.font_weight(gpui::FontWeight::SEMIBOLD)
|
||||||
|
.text_ellipsis()
|
||||||
|
.whitespace_nowrap()
|
||||||
|
.child(commit.summary.clone()),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_2()
|
||||||
|
.text_sm()
|
||||||
|
.child(SharedString::from(format!("{} committed", commit.author)))
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_1()
|
||||||
|
.text_color(cx.theme().colors.muted_foreground)
|
||||||
|
.child(SharedString::from(&commit.id))
|
||||||
|
.child(ClipboardButton::new("commit-id", &commit.id)),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_color(cx.theme().colors.muted_foreground)
|
||||||
|
.child(relative_time_secs(commit.time)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.when_some(commit.description.as_ref(), |this, description| {
|
||||||
|
this.child(div().text_sm().child(SharedString::from(description)))
|
||||||
|
})
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_2()
|
||||||
|
.text_xs()
|
||||||
|
.child(Badge::new("commit-files", format!("{files} files changed")))
|
||||||
|
.when(insertions > 0, |this| {
|
||||||
|
this.child(
|
||||||
|
Badge::new("commit-insertions", format!("+ {insertions}"))
|
||||||
|
.variant(BadgeVariant::Success),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.when(deletions > 0, |this| {
|
||||||
|
this.child(
|
||||||
|
Badge::new("commit-deletions", format!("- {deletions}"))
|
||||||
|
.variant(BadgeVariant::Danger),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Panel for CommitDiffView {
|
||||||
|
fn title(&self, _cx: &App) -> Option<SharedString> {
|
||||||
|
Some(SharedString::from(format!(
|
||||||
|
"{}/{}",
|
||||||
|
self.repo_name, self.commit.id
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lead(&self, cx: &App) -> Option<AnyElement> {
|
||||||
|
Some(super::repo_tab_avatar(
|
||||||
|
self.store.read(cx),
|
||||||
|
&self.directory,
|
||||||
|
cx,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for CommitDiffView {
|
||||||
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let body: AnyElement = if self.loading {
|
||||||
|
v_flex()
|
||||||
|
.size_full()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.child(Spinner::new("commit-diff-loading"))
|
||||||
|
.into_any_element()
|
||||||
|
} else if let Some(error) = self.error.clone() {
|
||||||
|
placeholder(&error)
|
||||||
|
} else {
|
||||||
|
self.pane.clone().into_any_element()
|
||||||
|
};
|
||||||
|
|
||||||
|
v_flex()
|
||||||
|
.size_full()
|
||||||
|
.min_h_0()
|
||||||
|
.child(self.render_header(cx))
|
||||||
|
.child(div().flex_1().min_h_0().child(body))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const GUTTER_WIDTH: f32 = 44.;
|
||||||
|
pub const DIFF_ROW_HEIGHT: f32 = 20.;
|
||||||
|
pub const COMMIT_ROW_HEIGHT: f32 = 56.;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum DiffRow {
|
||||||
|
Hunk {
|
||||||
|
old_start: u32,
|
||||||
|
old_lines: u32,
|
||||||
|
new_start: u32,
|
||||||
|
new_lines: u32,
|
||||||
|
},
|
||||||
|
Line {
|
||||||
|
hunk: usize,
|
||||||
|
line: usize,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
fn diff_rows(file: &FileDiff) -> Vec<DiffRow> {
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
for (hunk_ix, hunk) in file.hunks.iter().enumerate() {
|
||||||
|
rows.push(DiffRow::Hunk {
|
||||||
|
old_start: hunk.old_start,
|
||||||
|
old_lines: hunk.old_lines,
|
||||||
|
new_start: hunk.new_start,
|
||||||
|
new_lines: hunk.new_lines,
|
||||||
|
});
|
||||||
|
rows.extend((0..hunk.lines.len()).map(|line| DiffRow::Line {
|
||||||
|
hunk: hunk_ix,
|
||||||
|
line,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
rows
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> AnyElement {
|
||||||
|
match row {
|
||||||
|
DiffRow::Hunk {
|
||||||
|
old_start,
|
||||||
|
old_lines,
|
||||||
|
new_start,
|
||||||
|
new_lines,
|
||||||
|
} => div()
|
||||||
|
.px_2()
|
||||||
|
.w_full()
|
||||||
|
.h(px(DIFF_ROW_HEIGHT))
|
||||||
|
.font_family(cx.theme().mono_font)
|
||||||
|
.text_xs()
|
||||||
|
.bg(cx.theme().colors.muted)
|
||||||
|
.border_y_1()
|
||||||
|
.border_color(cx.theme().colors.border)
|
||||||
|
.text_color(cx.theme().colors.muted_foreground)
|
||||||
|
.child(SharedString::from(format!(
|
||||||
|
"@@ -{},{} +{},{} @@",
|
||||||
|
old_start, old_lines, new_start, new_lines
|
||||||
|
)))
|
||||||
|
.into_any_element(),
|
||||||
|
DiffRow::Line { hunk, line } => render_diff_line(&hunks[hunk].lines[line], cx),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement {
|
||||||
|
let bg = match line.kind {
|
||||||
|
DiffLineKind::Addition => Some(cx.theme().ramps.success.base.opacity(0.2)),
|
||||||
|
DiffLineKind::Deletion => Some(cx.theme().ramps.destructive.base.opacity(0.2)),
|
||||||
|
DiffLineKind::Context => None,
|
||||||
|
};
|
||||||
|
let gutter = cx.theme().colors.muted_foreground;
|
||||||
|
|
||||||
|
h_flex()
|
||||||
|
.w_full()
|
||||||
|
.h(px(DIFF_ROW_HEIGHT))
|
||||||
|
.font_family(cx.theme().mono_font)
|
||||||
|
.text_xs()
|
||||||
|
.when_some(bg, |this, bg| this.bg(bg))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.w(px(GUTTER_WIDTH))
|
||||||
|
.flex_none()
|
||||||
|
.pr_2()
|
||||||
|
.text_right()
|
||||||
|
.text_color(gutter)
|
||||||
|
.child(line.old.map(|n| n.to_string()).unwrap_or_default()),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.w(px(GUTTER_WIDTH))
|
||||||
|
.flex_none()
|
||||||
|
.pr_2()
|
||||||
|
.text_right()
|
||||||
|
.text_color(gutter)
|
||||||
|
.child(line.new.map(|n| n.to_string()).unwrap_or_default()),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_1()
|
||||||
|
.min_w_0()
|
||||||
|
.overflow_hidden()
|
||||||
|
.whitespace_nowrap()
|
||||||
|
.text_color(cx.theme().colors.foreground)
|
||||||
|
.child(line.text.clone()),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn placeholder(message: impl Into<SharedString>) -> AnyElement {
|
||||||
|
v_flex()
|
||||||
|
.size_full()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.child(EmptyState::new(message))
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use anyhow::Error;
|
||||||
|
use gpui::prelude::FluentBuilder as _;
|
||||||
|
use gpui::{
|
||||||
|
AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _,
|
||||||
|
Render, SharedString, StatefulInteractiveElement as _, Styled as _, Task,
|
||||||
|
UniformListScrollHandle, WeakEntity, Window, div, px, uniform_list,
|
||||||
|
};
|
||||||
|
use gpui_native_kit::common::{h_flex, v_flex};
|
||||||
|
use gpui_native_kit::empty_state::EmptyState;
|
||||||
|
use gpui_native_kit::layout::dock::DockState;
|
||||||
|
use gpui_native_kit::scrollbar::Scrollbar;
|
||||||
|
use gpui_native_kit::spinner::Spinner;
|
||||||
|
use gpui_native_kit::theme::ActiveTheme as _;
|
||||||
|
use signed_git::{CommitList, FileCommit, Repo};
|
||||||
|
use signed_state::RepoStore;
|
||||||
|
use utils::relative_time_secs;
|
||||||
|
|
||||||
|
use super::commit_diff::{COMMIT_ROW_HEIGHT, CommitDiffView};
|
||||||
|
use super::repo_display_name;
|
||||||
|
use crate::panels;
|
||||||
|
|
||||||
|
pub struct RepoHistoryView {
|
||||||
|
store: Entity<RepoStore>,
|
||||||
|
dock: WeakEntity<DockState>,
|
||||||
|
worktree: Option<PathBuf>,
|
||||||
|
all_commits: Option<CommitList>,
|
||||||
|
loading_all_commits: bool,
|
||||||
|
scroll_handle: UniformListScrollHandle,
|
||||||
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RepoHistoryView {
|
||||||
|
pub fn new(store: Entity<RepoStore>, dock: WeakEntity<DockState>) -> Self {
|
||||||
|
Self {
|
||||||
|
store,
|
||||||
|
dock,
|
||||||
|
worktree: None,
|
||||||
|
all_commits: None,
|
||||||
|
loading_all_commits: false,
|
||||||
|
scroll_handle: UniformListScrollHandle::new(),
|
||||||
|
tasks: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_worktree(&mut self, path: Option<PathBuf>) {
|
||||||
|
self.worktree = path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of commits reachable from HEAD, for the Commits tab label.
|
||||||
|
pub fn commit_count(&self) -> Option<usize> {
|
||||||
|
self.all_commits.as_ref().map(|list| list.total)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop the current list and walk HEAD again.
|
||||||
|
pub fn reload(&mut self, cx: &mut Context<Self>) {
|
||||||
|
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 task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||||
|
let result = cx
|
||||||
|
.background_spawn(async move {
|
||||||
|
Repo::open_cached(&worktree).and_then(|repo| repo.all_commits())
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
if let Ok(list) = result {
|
||||||
|
this.all_commits = Some(list);
|
||||||
|
}
|
||||||
|
this.loading_all_commits = false;
|
||||||
|
cx.notify();
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
|
||||||
|
self.tasks.push(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open_commit_diff(
|
||||||
|
&mut self,
|
||||||
|
commit_id: &str,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
let Some(worktree) = self.worktree.clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Same display name as the repo detail panel's title.
|
||||||
|
let repo_name = repo_display_name(self.store.read(cx));
|
||||||
|
let store = self.store.clone();
|
||||||
|
let commit_id = commit_id.to_owned();
|
||||||
|
let id = match store.read(cx).addr() {
|
||||||
|
Some(addr) => SharedString::from(format!("commit:{addr}:{commit_id}")),
|
||||||
|
None => SharedString::from(format!("commit:{}:{}", worktree.display(), commit_id)),
|
||||||
|
};
|
||||||
|
let dock = self.dock.clone();
|
||||||
|
|
||||||
|
panels::open(
|
||||||
|
&dock,
|
||||||
|
id,
|
||||||
|
move |window, cx| {
|
||||||
|
cx.new(|cx| {
|
||||||
|
CommitDiffView::new(
|
||||||
|
store.clone(),
|
||||||
|
worktree.clone(),
|
||||||
|
repo_name.clone(),
|
||||||
|
commit_id.clone(),
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
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()
|
||||||
|
.size_full()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.child(Spinner::new("commits-loading"))
|
||||||
|
.into_any_element()
|
||||||
|
} else {
|
||||||
|
empty_state("Failed to load commits")
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
if list.commits.is_empty() {
|
||||||
|
return empty_state("No commits found");
|
||||||
|
}
|
||||||
|
|
||||||
|
let commits = list.commits.clone();
|
||||||
|
let shown = commits.len();
|
||||||
|
let total = list.total;
|
||||||
|
let view = cx.entity().downgrade();
|
||||||
|
|
||||||
|
v_flex()
|
||||||
|
.relative()
|
||||||
|
.flex_1()
|
||||||
|
.w_full()
|
||||||
|
.min_h_0()
|
||||||
|
.child(
|
||||||
|
uniform_list("commits", shown, move |range, _, cx| {
|
||||||
|
range
|
||||||
|
.map(|ix| {
|
||||||
|
let commit = commits[ix].clone();
|
||||||
|
let id = commit.id.clone();
|
||||||
|
let view = view.clone();
|
||||||
|
commit_row(
|
||||||
|
ix,
|
||||||
|
&commit,
|
||||||
|
move |window, cx| {
|
||||||
|
if let Err(error) = view.update(cx, |this, cx| {
|
||||||
|
this.open_commit_diff(&id, window, cx);
|
||||||
|
}) {
|
||||||
|
log::warn!("failed to open commit diff: {error}");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.track_scroll(&self.scroll_handle)
|
||||||
|
.size_full(),
|
||||||
|
)
|
||||||
|
.when(shown < total, |this| {
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.py_2()
|
||||||
|
.w_full()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().colors.muted_foreground)
|
||||||
|
.child(format!("Showing {shown} of {total} commits")),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.absolute()
|
||||||
|
.top_0()
|
||||||
|
.left_0()
|
||||||
|
.right_0()
|
||||||
|
.bottom_0()
|
||||||
|
.child(Scrollbar::vertical(
|
||||||
|
"commits-scroll",
|
||||||
|
self.scroll_handle.clone(),
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn empty_state(message: &'static str) -> gpui::AnyElement {
|
||||||
|
v_flex()
|
||||||
|
.size_full()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.child(EmptyState::new(message))
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One commit row shared by the repository history and pull-request commit list.
|
||||||
|
pub fn commit_row(
|
||||||
|
ix: usize,
|
||||||
|
commit: &FileCommit,
|
||||||
|
on_click: impl Fn(&mut Window, &mut gpui::App) + 'static,
|
||||||
|
cx: &gpui::App,
|
||||||
|
) -> gpui::AnyElement {
|
||||||
|
h_flex()
|
||||||
|
.id(ix)
|
||||||
|
.px_4()
|
||||||
|
.h(px(COMMIT_ROW_HEIGHT))
|
||||||
|
.w_full()
|
||||||
|
.gap_3()
|
||||||
|
.border_b_1()
|
||||||
|
.border_color(cx.theme().colors.border)
|
||||||
|
.hover(|this| this.bg(cx.theme().colors.muted_hover))
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.flex_1()
|
||||||
|
.min_w_0()
|
||||||
|
.gap_1()
|
||||||
|
.justify_center()
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_2()
|
||||||
|
.overflow_hidden()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.font_family(cx.theme().mono_font)
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().colors.muted_foreground)
|
||||||
|
.child(commit.id.clone()),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_1()
|
||||||
|
.min_w_0()
|
||||||
|
.text_sm()
|
||||||
|
.text_ellipsis()
|
||||||
|
.whitespace_nowrap()
|
||||||
|
.child(commit.summary.clone()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_2()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().colors.muted_foreground)
|
||||||
|
.child(commit.author.clone())
|
||||||
|
.child(relative_time_secs(commit.time)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.on_click(move |_, window, cx| on_click(window, cx))
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ use gpui_native_kit::badge::Badge;
|
|||||||
use gpui_native_kit::button::{Button, ButtonVariant};
|
use gpui_native_kit::button::{Button, ButtonVariant};
|
||||||
use gpui_native_kit::clipboard::ClipboardButton;
|
use gpui_native_kit::clipboard::ClipboardButton;
|
||||||
use gpui_native_kit::common::{Sizable as _, h_flex, v_flex};
|
use gpui_native_kit::common::{Sizable as _, h_flex, v_flex};
|
||||||
|
use gpui_native_kit::data::tabs::{Tab, Tabs};
|
||||||
use gpui_native_kit::dropdown_menu::DropdownMenu;
|
use gpui_native_kit::dropdown_menu::DropdownMenu;
|
||||||
use gpui_native_kit::icons::{Icon, IconRole};
|
use gpui_native_kit::icons::{Icon, IconRole};
|
||||||
use gpui_native_kit::layout::dock::{DockState, Panel};
|
use gpui_native_kit::layout::dock::{DockState, Panel};
|
||||||
@@ -36,13 +37,16 @@ use utils::middle_truncate;
|
|||||||
|
|
||||||
pub mod actions;
|
pub mod actions;
|
||||||
pub mod banners;
|
pub mod banners;
|
||||||
|
pub mod commit_diff;
|
||||||
pub mod files;
|
pub mod files;
|
||||||
|
pub mod history;
|
||||||
pub mod refs;
|
pub mod refs;
|
||||||
|
|
||||||
use super::tree::{TreeItemSeed, build_tree_items, sorted_worktree_paths};
|
use super::tree::{TreeItemSeed, build_tree_items, sorted_worktree_paths};
|
||||||
use crate::dialogs;
|
use crate::dialogs;
|
||||||
use banners::Banners;
|
use banners::Banners;
|
||||||
use files::RepoFilesView;
|
use files::RepoFilesView;
|
||||||
|
use history::RepoHistoryView;
|
||||||
use refs::RefSwitcher;
|
use refs::RefSwitcher;
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -60,6 +64,10 @@ pub struct RepoDetailView {
|
|||||||
repo_started: bool,
|
repo_started: bool,
|
||||||
/// The Files tab, which owns the explorer, previews and the per-file commit map.
|
/// The Files tab, which owns the explorer, previews and the per-file commit map.
|
||||||
files: Entity<RepoFilesView>,
|
files: Entity<RepoFilesView>,
|
||||||
|
/// The Commits tab, which walks HEAD and opens commit diffs.
|
||||||
|
history: Entity<RepoHistoryView>,
|
||||||
|
/// The active tab: Files or Commits.
|
||||||
|
active_tab: usize,
|
||||||
/// The checked-out worktree path.
|
/// The checked-out worktree path.
|
||||||
worktree: Option<PathBuf>,
|
worktree: Option<PathBuf>,
|
||||||
loading: bool,
|
loading: bool,
|
||||||
@@ -134,6 +142,7 @@ impl RepoDetailView {
|
|||||||
) -> Self {
|
) -> Self {
|
||||||
let checkouts = CheckoutsStore::global(cx);
|
let checkouts = CheckoutsStore::global(cx);
|
||||||
let files = cx.new(RepoFilesView::new);
|
let files = cx.new(RepoFilesView::new);
|
||||||
|
let history = cx.new(|_| RepoHistoryView::new(store.clone(), dock.clone()));
|
||||||
let weak = cx.entity().downgrade();
|
let weak = cx.entity().downgrade();
|
||||||
let refs = RefSwitcher::new(weak.clone(), window, cx);
|
let refs = RefSwitcher::new(weak.clone(), window, cx);
|
||||||
|
|
||||||
@@ -154,6 +163,8 @@ impl RepoDetailView {
|
|||||||
store: store.clone(),
|
store: store.clone(),
|
||||||
repo_started: false,
|
repo_started: false,
|
||||||
files,
|
files,
|
||||||
|
history,
|
||||||
|
active_tab: 0,
|
||||||
worktree: None,
|
worktree: None,
|
||||||
loading: true,
|
loading: true,
|
||||||
error: None,
|
error: None,
|
||||||
@@ -468,6 +479,11 @@ impl RepoDetailView {
|
|||||||
files.set_readme(readme_path, readme, cx);
|
files.set_readme(readme_path, readme, cx);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
self.history.update(cx, |history, cx| {
|
||||||
|
history.set_worktree(Some(worktree.clone()));
|
||||||
|
history.reload(cx);
|
||||||
|
});
|
||||||
|
|
||||||
let branches: Vec<SharedString> = branches.into_iter().map(Into::into).collect();
|
let branches: Vec<SharedString> = branches.into_iter().map(Into::into).collect();
|
||||||
let tags: Vec<SharedString> = tags.into_iter().map(Into::into).collect();
|
let tags: Vec<SharedString> = tags.into_iter().map(Into::into).collect();
|
||||||
|
|
||||||
@@ -637,6 +653,7 @@ impl RepoDetailView {
|
|||||||
files.apply_entries(tree, paths, cx);
|
files.apply_entries(tree, paths, cx);
|
||||||
files.set_readme(readme_path, readme, cx);
|
files.set_readme(readme_path, readme, cx);
|
||||||
});
|
});
|
||||||
|
this.history.update(cx, |history, cx| history.reload(cx));
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
this.error = Some(error.to_string().into());
|
this.error = Some(error.to_string().into());
|
||||||
@@ -861,6 +878,7 @@ impl RepoDetailView {
|
|||||||
let git_for_popover = git_commands.clone();
|
let git_for_popover = git_commands.clone();
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
|
.flex_none()
|
||||||
.p_4()
|
.p_4()
|
||||||
.w_full()
|
.w_full()
|
||||||
.gap_6()
|
.gap_6()
|
||||||
@@ -1104,6 +1122,7 @@ impl RepoDetailView {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
.child(self.render_tabs(cx))
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1135,6 +1154,7 @@ impl RepoDetailView {
|
|||||||
};
|
};
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
|
.flex_none()
|
||||||
.px_4()
|
.px_4()
|
||||||
.pb_4()
|
.pb_4()
|
||||||
.w_full()
|
.w_full()
|
||||||
@@ -1169,6 +1189,37 @@ impl RepoDetailView {
|
|||||||
)
|
)
|
||||||
.child(action),
|
.child(action),
|
||||||
)
|
)
|
||||||
|
.child(self.render_tabs(cx))
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_tabs(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||||
|
let count = self.history.read(cx).commit_count();
|
||||||
|
let muted = cx.theme().colors.muted_foreground;
|
||||||
|
let weak = cx.entity().downgrade();
|
||||||
|
let commits_label = match count {
|
||||||
|
Some(count) => SharedString::from(format!("Commits ({count})")),
|
||||||
|
None => SharedString::from("Commits"),
|
||||||
|
};
|
||||||
|
|
||||||
|
Tabs::new("repo-tabs")
|
||||||
|
.selected(self.active_tab)
|
||||||
|
.tabs(move |_, _| {
|
||||||
|
vec![
|
||||||
|
Tab::new("files", "Files")
|
||||||
|
.lead(Icon::named(CustomIconName::GitFile).color(muted)),
|
||||||
|
Tab::new("commits", commits_label.clone())
|
||||||
|
.lead(Icon::named(CustomIconName::GitCommit).color(muted)),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.on_change(move |index, _, cx| {
|
||||||
|
if let Err(error) = weak.update(cx, |this, cx| {
|
||||||
|
this.active_tab = index;
|
||||||
|
cx.notify();
|
||||||
|
}) {
|
||||||
|
log::warn!("failed to switch repo tab: {error}");
|
||||||
|
}
|
||||||
|
})
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1569,7 +1620,10 @@ impl Render for RepoDetailView {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.child(self.render_files_tab(cx))
|
.map(|this| match self.active_tab {
|
||||||
|
0 => this.child(self.render_files_tab(cx)),
|
||||||
|
_ => this.child(self.history.clone()),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1797,7 +1851,7 @@ fn bound_repo_label(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The profile avatar shown on the repository tab.
|
/// The profile avatar shown on the repository tab.
|
||||||
fn repo_tab_avatar(
|
pub(crate) fn repo_tab_avatar(
|
||||||
store: &RepoStore,
|
store: &RepoStore,
|
||||||
directory: &Entity<ProfileDirectory>,
|
directory: &Entity<ProfileDirectory>,
|
||||||
cx: &App,
|
cx: &App,
|
||||||
|
|||||||
@@ -49,6 +49,24 @@ pub fn flatten(seeds: &[TreeItemSeed], expanded: &HashSet<String>) -> Vec<TreeRo
|
|||||||
rows
|
rows
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every folder id, for starting with the tree fully expanded.
|
||||||
|
pub fn collect_expanded(seeds: &[TreeItemSeed]) -> HashSet<String> {
|
||||||
|
fn push(seed: &TreeItemSeed, expanded: &mut HashSet<String>) {
|
||||||
|
if !seed.children.is_empty() {
|
||||||
|
expanded.insert(seed.id.clone());
|
||||||
|
for child in &seed.children {
|
||||||
|
push(child, expanded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut expanded = HashSet::new();
|
||||||
|
for seed in seeds {
|
||||||
|
push(seed, &mut expanded);
|
||||||
|
}
|
||||||
|
expanded
|
||||||
|
}
|
||||||
|
|
||||||
/// Build nested tree items from a flat entry list sorted dirs-first.
|
/// Build nested tree items from a flat entry list sorted dirs-first.
|
||||||
pub fn build_tree_items(entries: &[PathBuf]) -> Vec<TreeItemSeed> {
|
pub fn build_tree_items(entries: &[PathBuf]) -> Vec<TreeItemSeed> {
|
||||||
// Node indices by full path, so parents resolve in constant time while inserting.
|
// Node indices by full path, so parents resolve in constant time while inserting.
|
||||||
|
|||||||
Reference in New Issue
Block a user