From 4465bbf4eb4b9cd8241b109ef4e8a35605cc0767 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 11 Nov 2026 20:31:41 +0700 Subject: [PATCH] add repository history and commit diff views --- crates/signed_app/src/app.rs | 19 +- .../signed_app/src/views/repo/commit_diff.rs | 661 ++++++++++++++++++ crates/signed_app/src/views/repo/history.rs | 279 ++++++++ crates/signed_app/src/views/repo/mod.rs | 58 +- crates/signed_app/src/views/tree.rs | 18 + 5 files changed, 1026 insertions(+), 9 deletions(-) create mode 100644 crates/signed_app/src/views/repo/commit_diff.rs create mode 100644 crates/signed_app/src/views/repo/history.rs diff --git a/crates/signed_app/src/app.rs b/crates/signed_app/src/app.rs index 0e8877a..23b6278 100644 --- a/crates/signed_app/src/app.rs +++ b/crates/signed_app/src/app.rs @@ -4,7 +4,7 @@ use gpui::{ }; use gpui_native_kit::icons::IconRole; 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::toast::Toast; use settings::{AppearanceMode, Settings, SettingsStore}; @@ -24,8 +24,12 @@ pub struct AppRoot { impl AppRoot { pub fn new(window: &mut Window, cx: &mut Context) -> Self { - let layout = LayoutState::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.register_panel("welcome", |_, cx| { @@ -42,8 +46,11 @@ impl AppRoot { let dialogs = cx.new(DialogHost::new); DialogHost::set_global(dialogs.clone(), cx); - let mut subscriptions = Vec::new(); let backend = Backend::global(cx); + let settings = SettingsStore::global(cx); + + let mut subscriptions = Vec::new(); + subscriptions.push( cx.subscribe(&backend, |this, _backend, event, cx| match event { 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| { let settings = store.read(cx).settings().clone(); apply_appearance(&settings, cx); })); - window.defer(cx, |_window, cx| { - let required = Backend::global(cx).read(cx).passphrase_required(); - if required { + window.defer(cx, move |_window, cx| { + if backend.read(cx).passphrase_required() { dialogs::passphrase::open(cx); } }); diff --git a/crates/signed_app/src/views/repo/commit_diff.rs b/crates/signed_app/src/views/repo/commit_diff.rs new file mode 100644 index 0000000..8959e84 --- /dev/null +++ b/crates/signed_app/src/views/repo/commit_diff.rs @@ -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, + seeds: Vec, + expanded: HashSet, + rows: Vec, + selected_file: Option, + selected_index: Option, + /// Rows of the selected file's diff, hunk headers and lines. + diff_rows: Vec, + scroll_handle: UniformListScrollHandle, + split: Entity, +} + +impl DiffPane { + pub fn new(cx: &mut Context) -> 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) { + let mut paths: Vec = 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.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) { + 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) -> 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) { + 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) -> 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) -> 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, + worktree: PathBuf, + repo_name: SharedString, + commit: FileCommit, + /// The diff is being computed on a background task. + loading: bool, + error: Option, + pane: Entity, + /// Profile for the repository owner in the tab title. + directory: Entity, + tasks: Vec>>, +} + +impl CommitDiffView { + pub fn new( + store: Entity, + worktree: PathBuf, + repo_name: SharedString, + commit_id: String, + window: &mut Window, + cx: &mut Context, + ) -> 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.loading = true; + self.error = None; + cx.notify(); + + let worktree = self.worktree.clone(); + let id = self.commit.id.clone(); + + let task: Task> = 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) -> 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 { + Some(SharedString::from(format!( + "{}/{}", + self.repo_name, self.commit.id + ))) + } + + fn lead(&self, cx: &App) -> Option { + 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) -> 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 { + 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) -> AnyElement { + v_flex() + .size_full() + .items_center() + .justify_center() + .child(EmptyState::new(message)) + .into_any_element() +} diff --git a/crates/signed_app/src/views/repo/history.rs b/crates/signed_app/src/views/repo/history.rs new file mode 100644 index 0000000..198a9bb --- /dev/null +++ b/crates/signed_app/src/views/repo/history.rs @@ -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, + dock: WeakEntity, + worktree: Option, + all_commits: Option, + loading_all_commits: bool, + scroll_handle: UniformListScrollHandle, + tasks: Vec>>, +} + +impl RepoHistoryView { + pub fn new(store: Entity, dock: WeakEntity) -> 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) { + self.worktree = path; + } + + /// Number of commits reachable from HEAD, for the Commits tab label. + pub fn commit_count(&self) -> Option { + 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.all_commits = None; + self.loading_all_commits = false; + self.load(cx); + } + + fn load(&mut self, cx: &mut Context) { + if self.loading_all_commits || self.all_commits.is_some() { + return; + } + + let Some(worktree) = self.worktree.clone() else { + return; + }; + + self.loading_all_commits = true; + + let task: Task> = 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, + ) { + 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) -> 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() +} diff --git a/crates/signed_app/src/views/repo/mod.rs b/crates/signed_app/src/views/repo/mod.rs index 37d0dd7..15b2320 100644 --- a/crates/signed_app/src/views/repo/mod.rs +++ b/crates/signed_app/src/views/repo/mod.rs @@ -15,6 +15,7 @@ use gpui_native_kit::badge::Badge; use gpui_native_kit::button::{Button, ButtonVariant}; use gpui_native_kit::clipboard::ClipboardButton; 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::icons::{Icon, IconRole}; use gpui_native_kit::layout::dock::{DockState, Panel}; @@ -36,13 +37,16 @@ use utils::middle_truncate; pub mod actions; pub mod banners; +pub mod commit_diff; pub mod files; +pub mod history; pub mod refs; use super::tree::{TreeItemSeed, build_tree_items, sorted_worktree_paths}; use crate::dialogs; use banners::Banners; use files::RepoFilesView; +use history::RepoHistoryView; use refs::RefSwitcher; #[derive(Clone, Copy, PartialEq, Eq)] @@ -60,6 +64,10 @@ pub struct RepoDetailView { repo_started: bool, /// The Files tab, which owns the explorer, previews and the per-file commit map. files: Entity, + /// The Commits tab, which walks HEAD and opens commit diffs. + history: Entity, + /// The active tab: Files or Commits. + active_tab: usize, /// The checked-out worktree path. worktree: Option, loading: bool, @@ -134,6 +142,7 @@ impl RepoDetailView { ) -> Self { let checkouts = CheckoutsStore::global(cx); let files = cx.new(RepoFilesView::new); + let history = cx.new(|_| RepoHistoryView::new(store.clone(), dock.clone())); let weak = cx.entity().downgrade(); let refs = RefSwitcher::new(weak.clone(), window, cx); @@ -154,6 +163,8 @@ impl RepoDetailView { store: store.clone(), repo_started: false, files, + history, + active_tab: 0, worktree: None, loading: true, error: None, @@ -468,6 +479,11 @@ impl RepoDetailView { 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 = branches.into_iter().map(Into::into).collect(); let tags: Vec = tags.into_iter().map(Into::into).collect(); @@ -637,6 +653,7 @@ impl RepoDetailView { files.apply_entries(tree, paths, cx); files.set_readme(readme_path, readme, cx); }); + this.history.update(cx, |history, cx| history.reload(cx)); } Err(error) => { this.error = Some(error.to_string().into()); @@ -861,6 +878,7 @@ impl RepoDetailView { let git_for_popover = git_commands.clone(); v_flex() + .flex_none() .p_4() .w_full() .gap_6() @@ -1104,6 +1122,7 @@ impl RepoDetailView { ), ), ) + .child(self.render_tabs(cx)) .into_any_element() } @@ -1135,6 +1154,7 @@ impl RepoDetailView { }; v_flex() + .flex_none() .px_4() .pb_4() .w_full() @@ -1169,6 +1189,37 @@ impl RepoDetailView { ) .child(action), ) + .child(self.render_tabs(cx)) + .into_any_element() + } + + fn render_tabs(&self, cx: &mut Context) -> 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() } @@ -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. -fn repo_tab_avatar( +pub(crate) fn repo_tab_avatar( store: &RepoStore, directory: &Entity, cx: &App, diff --git a/crates/signed_app/src/views/tree.rs b/crates/signed_app/src/views/tree.rs index 6ce3fa3..d1f38bf 100644 --- a/crates/signed_app/src/views/tree.rs +++ b/crates/signed_app/src/views/tree.rs @@ -49,6 +49,24 @@ pub fn flatten(seeds: &[TreeItemSeed], expanded: &HashSet) -> Vec HashSet { + fn push(seed: &TreeItemSeed, expanded: &mut HashSet) { + 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. pub fn build_tree_items(entries: &[PathBuf]) -> Vec { // Node indices by full path, so parents resolve in constant time while inserting.