From f8a1c3f42b3839399168c13739ccbfb40d4e8068 Mon Sep 17 00:00:00 2001 From: Kenneth Date: Thu, 28 May 2026 00:37:35 +0100 Subject: [PATCH] feat: impl pull request file tree --- src/api/issues.rs | 267 +++++++++--------- src/asset/font_icon/chevron_right.svg | 1 + src/asset/font_icon/folder_closed.svg | 1 + src/asset/font_icon/folder_open.svg | 1 + src/component/file_tree.rs | 176 ++++++++++++ src/component/font_icon.rs | 3 + src/component/mod.rs | 1 + src/screen/dashboard/mod.rs | 1 + .../dashboard/pull_request_diff_view.rs | 67 +++-- .../dashboard/pull_request_file_tree.rs | 81 ++++++ src/util/file.rs | 221 +++++++++------ src/util/file_tests.rs | 71 ++++- 12 files changed, 642 insertions(+), 249 deletions(-) create mode 100644 src/asset/font_icon/chevron_right.svg create mode 100644 src/asset/font_icon/folder_closed.svg create mode 100644 src/asset/font_icon/folder_open.svg create mode 100644 src/component/file_tree.rs create mode 100644 src/screen/dashboard/pull_request_file_tree.rs diff --git a/src/api/issues.rs b/src/api/issues.rs index 605f4b2..be9963f 100644 --- a/src/api/issues.rs +++ b/src/api/issues.rs @@ -11,7 +11,7 @@ use crate::{ pull_request_query::PullRequestQueryNode, }, }, - query, + query, util, }; type DateTime = String; @@ -318,8 +318,8 @@ impl query::QueryFn for ListPullRequests { } let query_string = match self.filter { - | Some(filter) => format!("is:pr archived:false sort:updated-desc {}", filter), - | None => "is:pr archived:false sort:updated-desc".into(), + | Some(filter) => format!("is:pr archived:false sort:updated-desc {}", filter), + | None => "is:pr archived:false sort:updated-desc".into(), }; let gql = @@ -341,20 +341,20 @@ impl query::QueryFn for ListPullRequests { .flatten() .filter_map(|edge| { edge.node.and_then(|n| match n { - | PullRequestPaginationQuerySearchEdgesNode::PullRequest(p) => { - Some(PullRequest { - id: p.id.into(), - title: p.title.into(), - state: p.state, - is_draft: p.is_draft, - repo_slug: format!( - "{}/{}", - p.repository.owner.login, p.repository.name - ) - .into(), - }) - } - | _ => None, + | PullRequestPaginationQuerySearchEdgesNode::PullRequest(p) => { + Some(PullRequest { + id: p.id.into(), + title: p.title.into(), + state: p.state, + is_draft: p.is_draft, + repo_slug: format!( + "{}/{}", + p.repository.owner.login, p.repository.name + ) + .into(), + }) + } + | _ => None, }) }) .collect::>() @@ -399,43 +399,43 @@ impl query::QueryFn for FetchPullRequest { "missing 'node' field on PullRequestQuery response".into(), )) .and_then(|n| match n { - | PullRequestQueryNode::PullRequest(p) => { - let created_at = - chrono::DateTime::parse_from_rfc3339(&p.created_at).map_err(|err| { - api::Error::MalformedResponse(format!( - "invalid pull request createdAt {:?}: {err}", - p.created_at - )) - })?; + | PullRequestQueryNode::PullRequest(p) => { + let created_at = + chrono::DateTime::parse_from_rfc3339(&p.created_at).map_err(|err| { + api::Error::MalformedResponse(format!( + "invalid pull request createdAt {:?}: {err}", + p.created_at + )) + })?; - Ok(DetailedPullRequest { - id: Id(p.id.into()), - title: p.title.into(), - state: p.state, - is_draft: p.is_draft, - body: p.body.into(), - author: p.author.map(|it| api::user::Actor { - login: it.login.into(), - avatar_url: it.avatar_url.into(), - }), - base_repo_slug: p - .base_repository - .map(|it| it.name_with_owner.into()) - .unwrap_or_default(), - base_branch_name: p.base_ref_name.into(), - base_ref: p.base_ref_oid.into(), - head_repo_slug: p - .head_repository - .map(|it| it.name_with_owner.into()) - .unwrap_or_default(), - head_branch_name: p.head_ref_name.into(), - head_ref: p.head_ref_oid.into(), - created_at: Some(created_at), - }) - } - | _ => Err(api::Error::MalformedResponse( - "unexpected node type on PullRequestQuery".into(), - )), + Ok(DetailedPullRequest { + id: Id(p.id.into()), + title: p.title.into(), + state: p.state, + is_draft: p.is_draft, + body: p.body.into(), + author: p.author.map(|it| api::user::Actor { + login: it.login.into(), + avatar_url: it.avatar_url.into(), + }), + base_repo_slug: p + .base_repository + .map(|it| it.name_with_owner.into()) + .unwrap_or_default(), + base_branch_name: p.base_ref_name.into(), + base_ref: p.base_ref_oid.into(), + head_repo_slug: p + .head_repository + .map(|it| it.name_with_owner.into()) + .unwrap_or_default(), + head_branch_name: p.head_ref_name.into(), + head_ref: p.head_ref_oid.into(), + created_at: Some(created_at), + }) + } + | _ => Err(api::Error::MalformedResponse( + "unexpected node type on PullRequestQuery".into(), + )), }) } } @@ -447,7 +447,7 @@ pub(crate) struct FetchPullRequestFileTree { } impl query::QueryFn for FetchPullRequestFileTree { - type Data = Vec; + type Data = util::file::SortedByPath; type Error = api::Error; type Context = api::QueryContext; @@ -494,12 +494,12 @@ impl query::QueryFn for FetchPullRequestFileTree { "missing 'node' field on PullRequestFileTreeQuery response".into(), )) .and_then(|node| match node { - | pull_request_file_tree_query::PullRequestFileTreeQueryNode::PullRequest( - pull_request, - ) => Ok(pull_request), - | _ => Err(api::Error::MalformedResponse( - "unexpected node type on PullRequestFileTreeQuery".into(), - )), + | pull_request_file_tree_query::PullRequestFileTreeQueryNode::PullRequest( + pull_request, + ) => Ok(pull_request), + | _ => Err(api::Error::MalformedResponse( + "unexpected node type on PullRequestFileTreeQuery".into(), + )), })?; Ok(pull_request @@ -513,25 +513,23 @@ impl query::QueryFn for FetchPullRequestFileTree { edge.node.map(|node| ChangedFile { cursor, change_type: match node.change_type { - | pull_request_file_tree_query::PatchStatus::ADDED => { - ChangeType::Added - } - | pull_request_file_tree_query::PatchStatus::MODIFIED => { - ChangeType::Modified - } - | pull_request_file_tree_query::PatchStatus::DELETED => { - ChangeType::Deleted - } - | pull_request_file_tree_query::PatchStatus::RENAMED => { - ChangeType::Renamed - } - | pull_request_file_tree_query::PatchStatus::COPIED => { - ChangeType::Copied - } - | pull_request_file_tree_query::PatchStatus::CHANGED => { - ChangeType::Changed - } - | _ => ChangeType::Changed, + | pull_request_file_tree_query::PatchStatus::ADDED => ChangeType::Added, + | pull_request_file_tree_query::PatchStatus::MODIFIED => { + ChangeType::Modified + } + | pull_request_file_tree_query::PatchStatus::DELETED => { + ChangeType::Deleted + } + | pull_request_file_tree_query::PatchStatus::RENAMED => { + ChangeType::Renamed + } + | pull_request_file_tree_query::PatchStatus::COPIED => { + ChangeType::Copied + } + | pull_request_file_tree_query::PatchStatus::CHANGED => { + ChangeType::Changed + } + | _ => ChangeType::Changed, }, additions: node.additions, deletions: node.deletions, @@ -541,6 +539,7 @@ impl query::QueryFn for FetchPullRequestFileTree { }) .collect::>() }) + .map(|files| util::file::sort_by_path(files, |f| &f.path)) .unwrap_or_default()) } } @@ -577,11 +576,11 @@ impl query::QueryFn for FetchPullRequestTimeline { TimelineActor { kind: match on { - | actorFieldsOn::Bot => "Bot", - | actorFieldsOn::EnterpriseUserAccount => "EnterpriseUserAccount", - | actorFieldsOn::Mannequin => "Mannequin", - | actorFieldsOn::Organization => "Organization", - | actorFieldsOn::User => "User", + | actorFieldsOn::Bot => "Bot", + | actorFieldsOn::EnterpriseUserAccount => "EnterpriseUserAccount", + | actorFieldsOn::Mannequin => "Mannequin", + | actorFieldsOn::Organization => "Organization", + | actorFieldsOn::User => "User", } .into(), name: login, @@ -591,62 +590,62 @@ impl query::QueryFn for FetchPullRequestTimeline { fn normalize_assignee(actor: assigneeFields) -> TimelineActor { match actor { - | assigneeFields::Bot(actor) => TimelineActor { - kind: "Bot".into(), - name: actor.login, - avatar_url: Some(actor.avatar_url), - }, - | assigneeFields::Mannequin(actor) => TimelineActor { - kind: "Mannequin".into(), - name: actor.login, - avatar_url: Some(actor.avatar_url), - }, - | assigneeFields::Organization(actor) => TimelineActor { - kind: "Organization".into(), - name: actor.login, - avatar_url: Some(actor.avatar_url), - }, - | assigneeFields::User(actor) => TimelineActor { - kind: "User".into(), - name: actor.login, - avatar_url: Some(actor.avatar_url), - }, + | assigneeFields::Bot(actor) => TimelineActor { + kind: "Bot".into(), + name: actor.login, + avatar_url: Some(actor.avatar_url), + }, + | assigneeFields::Mannequin(actor) => TimelineActor { + kind: "Mannequin".into(), + name: actor.login, + avatar_url: Some(actor.avatar_url), + }, + | assigneeFields::Organization(actor) => TimelineActor { + kind: "Organization".into(), + name: actor.login, + avatar_url: Some(actor.avatar_url), + }, + | assigneeFields::User(actor) => TimelineActor { + kind: "User".into(), + name: actor.login, + avatar_url: Some(actor.avatar_url), + }, } } fn normalize_requested_reviewer(actor: requestedReviewerFields) -> TimelineActor { match actor { - | requestedReviewerFields::Bot(actor) => TimelineActor { - kind: "Bot".into(), - name: actor.login, - avatar_url: Some(actor.avatar_url), - }, - | requestedReviewerFields::Mannequin(actor) => TimelineActor { - kind: "Mannequin".into(), - name: actor.login, - avatar_url: Some(actor.avatar_url), - }, - | requestedReviewerFields::Team(actor) => TimelineActor { - kind: "Team".into(), - name: actor.name, - avatar_url: None, - }, - | requestedReviewerFields::User(actor) => TimelineActor { - kind: "User".into(), - name: actor.login, - avatar_url: Some(actor.avatar_url), - }, + | requestedReviewerFields::Bot(actor) => TimelineActor { + kind: "Bot".into(), + name: actor.login, + avatar_url: Some(actor.avatar_url), + }, + | requestedReviewerFields::Mannequin(actor) => TimelineActor { + kind: "Mannequin".into(), + name: actor.login, + avatar_url: Some(actor.avatar_url), + }, + | requestedReviewerFields::Team(actor) => TimelineActor { + kind: "Team".into(), + name: actor.name, + avatar_url: None, + }, + | requestedReviewerFields::User(actor) => TimelineActor { + kind: "User".into(), + name: actor.login, + avatar_url: Some(actor.avatar_url), + }, } } fn normalize_review_state(state: PullRequestReviewState) -> String { match state { - | PullRequestReviewState::PENDING => "PENDING", - | PullRequestReviewState::COMMENTED => "COMMENTED", - | PullRequestReviewState::APPROVED => "APPROVED", - | PullRequestReviewState::CHANGES_REQUESTED => "CHANGES_REQUESTED", - | PullRequestReviewState::DISMISSED => "DISMISSED", - | _ => "OTHER", + | PullRequestReviewState::PENDING => "PENDING", + | PullRequestReviewState::COMMENTED => "COMMENTED", + | PullRequestReviewState::APPROVED => "APPROVED", + | PullRequestReviewState::CHANGES_REQUESTED => "CHANGES_REQUESTED", + | PullRequestReviewState::DISMISSED => "DISMISSED", + | _ => "OTHER", } .into() } @@ -866,10 +865,10 @@ impl query::QueryFn for FetchPullRequestTimeline { "missing 'node' field on PullRequestTimelineQuery response".into(), )) .and_then(|node| match node { - | PullRequestTimelineQueryNode::PullRequest(pull_request) => Ok(pull_request), - | _ => Err(api::Error::MalformedResponse( - "unexpected node type on PullRequestTimelineQuery".into(), - )), + | PullRequestTimelineQueryNode::PullRequest(pull_request) => Ok(pull_request), + | _ => Err(api::Error::MalformedResponse( + "unexpected node type on PullRequestTimelineQuery".into(), + )), })?; let timeline = pull_request.timeline_items; diff --git a/src/asset/font_icon/chevron_right.svg b/src/asset/font_icon/chevron_right.svg new file mode 100644 index 0000000..4c0ff5e --- /dev/null +++ b/src/asset/font_icon/chevron_right.svg @@ -0,0 +1 @@ + diff --git a/src/asset/font_icon/folder_closed.svg b/src/asset/font_icon/folder_closed.svg new file mode 100644 index 0000000..38e08b7 --- /dev/null +++ b/src/asset/font_icon/folder_closed.svg @@ -0,0 +1 @@ + diff --git a/src/asset/font_icon/folder_open.svg b/src/asset/font_icon/folder_open.svg new file mode 100644 index 0000000..615b6c0 --- /dev/null +++ b/src/asset/font_icon/folder_open.svg @@ -0,0 +1 @@ + diff --git a/src/component/file_tree.rs b/src/component/file_tree.rs new file mode 100644 index 0000000..f97b951 --- /dev/null +++ b/src/component/file_tree.rs @@ -0,0 +1,176 @@ +use std::{cell::RefCell, collections::HashSet, rc::Rc, sync::Arc}; + +use gpui::{ + InteractiveElement, IntoElement, ParentElement, StatefulInteractiveElement, Styled, div, list, + prelude::FluentBuilder, px, +}; + +use crate::{ + app, + component::{ + font_icon::{FontIcon, font_icon}, + text::text, + }, + util::{ + self, + file::{FileTreeItem, FileTreeItemKind}, + }, +}; + +#[derive(gpui::IntoElement)] +pub(crate) struct FileTree { + state: FileTreeState, + item: Box util::file::FileTreeItem>, + on_item_click: Option>, +} + +#[derive(gpui::IntoElement)] +pub(crate) struct Item { + item: util::file::FileTreeItem, + is_expanded: bool, + on_click: Box, +} + +#[derive(Clone)] +pub(crate) struct FileTreeState(Rc>); + +struct FileTreeStateInner { + list_state: gpui::ListState, + collapsed_dirs: HashSet>, + visible_items: Vec, +} + +pub(crate) fn file_tree( + state: FileTreeState, + item: impl Fn(usize, &gpui::Window, &mut gpui::App) -> util::file::FileTreeItem + 'static, +) -> FileTree { + FileTree { + state, + item: Box::new(item), + on_item_click: None, + } +} + +impl FileTreeState { + pub(crate) fn new() -> Self { + Self(Rc::new(RefCell::new(FileTreeStateInner { + list_state: gpui::ListState::new(0, gpui::ListAlignment::Top, px(50.)), + collapsed_dirs: HashSet::new(), + visible_items: Vec::new(), + }))) + } + + pub(crate) fn reset(&self, items: &[util::file::FileTreeItem]) { + let mut state = self.0.borrow_mut(); + state.list_state.reset(items.len()); + state.visible_items = (0..items.len()).collect::>(); + } + + pub(crate) fn toggle_directory( + &mut self, + dir_path: &Arc, + items: &[util::file::FileTreeItem], + ) { + let mut state = self.0.borrow_mut(); + if !state.collapsed_dirs.remove(dir_path) { + state.collapsed_dirs.insert(Arc::clone(dir_path)); + } + + state.visible_items.clear(); + + let mut hidden_after_level: usize = usize::MAX; + for (i, item) in items.iter().enumerate() { + if item.level > hidden_after_level { + continue; + } + + hidden_after_level = usize::MAX; + + if item.kind == FileTreeItemKind::Directory + && state.collapsed_dirs.contains(&item.full_path) + { + hidden_after_level = item.level; + } + + state.visible_items.push(i); + } + + state.list_state.reset(state.visible_items.len()); + } +} + +impl FileTree { + pub(crate) fn on_item_click( + mut self, + f: impl Fn(&usize, &mut gpui::Window, &mut gpui::App) + 'static, + ) -> Self { + self.on_item_click = Some(Rc::new(f)); + self + } +} + +impl gpui::RenderOnce for FileTree { + fn render( + self, + _window: &mut gpui::Window, + _cx: &mut gpui::App, + ) -> impl gpui::prelude::IntoElement { + let list_state = self.state.0.borrow().list_state.clone(); + + list(list_state, move |i, window, cx| { + let state = self.state.0.borrow(); + let item_index = state.visible_items[i]; + let on_item_click = self.on_item_click.as_ref().map(Rc::clone); + + let item = (self.item)(item_index, window, cx); + let item = Item { + is_expanded: !state.collapsed_dirs.contains(&item.full_path), + item, + on_click: Box::new(move |window, cx| { + if let Some(f) = &on_item_click { + f(&i, window, cx); + } + }), + }; + + item.into_any_element() + }) + .size_full() + } +} + +impl gpui::RenderOnce for Item { + fn render(self, _window: &mut gpui::Window, cx: &mut gpui::App) -> impl IntoElement { + let theme = app::current_theme(cx); + div() + .id(gpui::SharedString::new(self.item.full_path)) + .w_full() + .flex() + .flex_row() + .items_center() + .gap_1p5() + .pr_2() + .py_0p5() + .pl(px(8. + (8 * self.item.level) as f32)) + .rounded_sm() + .hover(|it| it.bg(theme.colors.surface_hover)) + .on_click(move |_, window, cx| (self.on_click)(window, cx)) + .when( + matches!(self.item.kind, FileTreeItemKind::Directory), + |it| { + it.child( + font_icon(if self.is_expanded { + FontIcon::FolderOpen + } else { + FontIcon::FolderClosed + }) + .size_3(), + ) + }, + ) + .when(matches!(self.item.kind, FileTreeItemKind::File), |it| { + it.child(font_icon(FontIcon::FileBracesCorner).size_3()) + }) + .child(text(gpui::SharedString::new(self.item.name)).text_sm()) + } +} diff --git a/src/component/font_icon.rs b/src/component/font_icon.rs index 9132141..6d921d2 100644 --- a/src/component/font_icon.rs +++ b/src/component/font_icon.rs @@ -28,6 +28,7 @@ macro_rules! define_font_icons { define_font_icons!( Check, + ChevronRight, ChevronDown, FolderGit, Github, @@ -45,6 +46,8 @@ define_font_icons!( Star, MessageCircleMore, FileBracesCorner, + FolderClosed, + FolderOpen, ); #[derive(gpui::IntoElement)] diff --git a/src/component/mod.rs b/src/component/mod.rs index b479e65..be4bc36 100644 --- a/src/component/mod.rs +++ b/src/component/mod.rs @@ -1,6 +1,7 @@ pub(crate) mod button; pub(crate) mod code_view; pub(crate) mod diff_view; +pub(crate) mod file_tree; pub(crate) mod font_icon; pub(crate) mod markdown; pub(crate) mod segmented_control; diff --git a/src/screen/dashboard/mod.rs b/src/screen/dashboard/mod.rs index f3966b8..b229758 100644 --- a/src/screen/dashboard/mod.rs +++ b/src/screen/dashboard/mod.rs @@ -1,5 +1,6 @@ mod issue_list; mod pull_request_diff_view; +mod pull_request_file_tree; mod pull_request_view; mod screen; mod sidebar; diff --git a/src/screen/dashboard/pull_request_diff_view.rs b/src/screen/dashboard/pull_request_diff_view.rs index 19225bc..10911b7 100644 --- a/src/screen/dashboard/pull_request_diff_view.rs +++ b/src/screen/dashboard/pull_request_diff_view.rs @@ -7,7 +7,8 @@ use crate::{ text::text, }, query::{self, QueryStatus, read_query, use_query, watch_query}, - util, + screen::dashboard::pull_request_file_tree::{self, PullRequestFileTree}, + util::{self}, }; use gpui::{AppContext, ParentElement, Styled, div}; @@ -20,6 +21,8 @@ pub(crate) struct PullRequestDiffView { diff_view_state: DiffViewState, diff_view_content: Option, + + file_tree: gpui::Entity, } pub(crate) fn new( @@ -31,7 +34,7 @@ pub(crate) fn new( pr_query: use_query(api::issues::FetchPullRequest { id: pr_id.clone() }, cx), file_tree_query: use_query( api::issues::FetchPullRequestFileTree { - id: pr_id, + id: pr_id.clone(), first: 100, }, cx, @@ -39,6 +42,8 @@ pub(crate) fn new( content_diff_query: None, diff_view_state: DiffViewState::new(), diff_view_content: None, + + file_tree: cx.new(|cx| pull_request_file_tree::new(pr_id, cx)), }; view.on_create(cx); view @@ -117,8 +122,8 @@ impl PullRequestDiffView { ) { if let Some(diff) = { match read_query(query, cx) { - | QueryStatus::Loaded(diff) => Some(Arc::clone(diff)), - | _ => None, + | QueryStatus::Loaded(diff) => Some(Arc::clone(diff)), + | _ => None, } } { self.load_diff_view(diff, cx); @@ -153,16 +158,16 @@ impl PullRequestDiffView { _ = cx .spawn(async move |weak, cx| match tokio::join!(t1, t2) { - | (Some(old_side_highlights), Some(new_side_highlights)) => { - _ = weak.update(cx, |this, cx| { - this.diff_view_state - .set_old_side_highlights(old_side_highlights); - this.diff_view_state - .set_new_side_highlights(new_side_highlights); - cx.notify(); - }); - } - | _ => {} + | (Some(old_side_highlights), Some(new_side_highlights)) => { + _ = weak.update(cx, |this, cx| { + this.diff_view_state + .set_old_side_highlights(old_side_highlights); + this.diff_view_state + .set_new_side_highlights(new_side_highlights); + cx.notify(); + }); + } + | _ => {} }) .detach(); } @@ -184,18 +189,30 @@ impl gpui::Render for PullRequestDiffView { .unwrap_or(QueryStatus::Loading); match content_diff { - | QueryStatus::Err(_) | QueryStatus::Loading => div() - .size_full() - .bg(theme.colors.surface) - .p_4() - .child(text("asd")), + | QueryStatus::Err(_) | QueryStatus::Loading => div() + .size_full() + .bg(theme.colors.surface) + .p_4() + .child(text("asd")), - | QueryStatus::Loaded(_) => match &self.diff_view_content { - | Some(content) => div() - .size_full() - .child(diff_view(self.diff_view_state.clone(), content.clone())), - | None => div(), - }, + | QueryStatus::Loaded(_) => match &self.diff_view_content { + | Some(content) => div() + .size_full() + .flex() + .flex_row() + .child( + div() + .flex() + .w_80() + .h_full() + .border_r_1() + .border_color(theme.colors.border_muted) + .p_1() + .child(self.file_tree.clone()), + ) + .child(diff_view(self.diff_view_state.clone(), content.clone())), + | None => div(), + }, } } } diff --git a/src/screen/dashboard/pull_request_file_tree.rs b/src/screen/dashboard/pull_request_file_tree.rs new file mode 100644 index 0000000..5515b6b --- /dev/null +++ b/src/screen/dashboard/pull_request_file_tree.rs @@ -0,0 +1,81 @@ +use crate::{ + api, + component::file_tree::{FileTree, FileTreeState, file_tree}, + query::{self, QueryStatus, read_query, use_query, watch_query}, + util::{ + self, + file::{FileTreeItem, FileTreeItemKind}, + }, +}; + +pub(crate) struct PullRequestFileTree { + file_tree_query: query::Entity, + file_tree_state: FileTreeState, + file_tree_items: Vec, +} + +pub(crate) fn new( + pr_id: api::issues::Id, + cx: &mut gpui::Context, +) -> PullRequestFileTree { + let mut v = PullRequestFileTree { + file_tree_query: use_query( + api::issues::FetchPullRequestFileTree { + id: pr_id, + first: 100, + }, + cx, + ), + file_tree_state: FileTreeState::new(), + file_tree_items: Vec::new(), + }; + v.on_create(cx); + v +} + +impl PullRequestFileTree { + fn on_create(&mut self, cx: &mut gpui::Context) { + watch_query( + &self.file_tree_query, + |this, query, cx| { + if let QueryStatus::Loaded(changed_files) = read_query(query, cx) { + let tree = util::file::build_file_tree(changed_files, |f| &f.path); + this.file_tree_state.reset(&tree); + this.file_tree_items = tree; + cx.notify(); + }; + }, + cx, + ) + .detach(); + } + + fn handle_file_tree_item_click( + &mut self, + i: usize, + cx: &mut gpui::Context, + ) { + let item = &self.file_tree_items[i]; + if item.kind == FileTreeItemKind::Directory { + self.file_tree_state + .toggle_directory(&item.full_path, &self.file_tree_items); + cx.notify(); + } + } +} + +impl gpui::Render for PullRequestFileTree { + fn render( + &mut self, + _window: &mut gpui::Window, + cx: &mut gpui::prelude::Context, + ) -> impl gpui::prelude::IntoElement { + let weak = cx.entity(); + file_tree(self.file_tree_state.clone(), move |i, _, cx| { + weak.read(cx).file_tree_items[i].clone() + }) + .on_item_click(cx.listener(|this, i, _, cx| { + this.handle_file_tree_item_click(*i, cx); + })) + } +} diff --git a/src/util/file.rs b/src/util/file.rs index 405c232..d7667c1 100644 --- a/src/util/file.rs +++ b/src/util/file.rs @@ -17,12 +17,20 @@ pub(crate) enum FileType { pub(crate) struct SortedByPath(Vec); +#[derive(Clone)] pub(crate) struct FileTreeItem { + pub(crate) kind: FileTreeItemKind, pub(crate) full_path: Arc, pub(crate) name: Arc, pub(crate) level: usize, } +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum FileTreeItemKind { + File, + Directory, +} + pub(crate) fn classify_content(content: &[u8]) -> ContentType { if content.is_empty() { ContentType::Text @@ -35,17 +43,17 @@ pub(crate) fn classify_content(content: &[u8]) -> ContentType { ContentType::Text } else { match memchr(0, &content[..content.len().min(8192)]) { - | None => ContentType::Text, - | Some(_) => ContentType::Binary, + | None => ContentType::Text, + | Some(_) => ContentType::Binary, } } } pub(crate) fn file_type_from_path(path: &str) -> FileType { match Path::new(path).extension().map(|it| it.to_str()).flatten() { - | Some("rs") => FileType::Rust, - | Some("js") | Some("jsx") => FileType::JavaScript, - | _ => FileType::Unknown, + | Some("rs") => FileType::Rust, + | Some("js") | Some("jsx") => FileType::JavaScript, + | _ => FileType::Unknown, } } @@ -63,18 +71,18 @@ pub(crate) fn line_ranges(content: &[u8]) -> Vec> { let c = content[i]; match (c, content.get(i + 1)) { - | (b'\r', Some(b'\n')) => { - // if \r found, check if its \r\n or if its a lone \r - // if \r\n, then treat as one line break - ranges.push(line_start..i + 1); - // because we already counted the \n byte, the next iter into it needs to be skipped - skip_next = true; - line_start = i + 2; - } - | _ => { - ranges.push(line_start..i); - line_start = i + 1; - } + | (b'\r', Some(b'\n')) => { + // if \r found, check if its \r\n or if its a lone \r + // if \r\n, then treat as one line break + ranges.push(line_start..i + 1); + // because we already counted the \n byte, the next iter into it needs to be skipped + skip_next = true; + line_start = i + 2; + } + | _ => { + ranges.push(line_start..i); + line_start = i + 1; + } } } @@ -93,33 +101,38 @@ pub(crate) fn sort_by_path(mut items: Vec, key: impl Fn(&T) -> &str) -> So let b_is_root_file = !b_path.contains('/'); match (a_is_root_file, b_is_root_file) { - | (true, false) => return std::cmp::Ordering::Greater, - | (false, true) => return std::cmp::Ordering::Less, - | _ => {} + | (true, false) => return std::cmp::Ordering::Greater, + | (false, true) => return std::cmp::Ordering::Less, + | _ => {} } - let mut a_parts = a_path.split('/'); - let mut b_parts = b_path.split('/'); + let mut a_parts = a_path.split('/').peekable(); + let mut b_parts = b_path.split('/').peekable(); loop { match (a_parts.next(), b_parts.next()) { - | (Some(a), Some(b)) => { - if a != b { - return a.cmp(b); + | (Some(a), Some(b)) => { + if a != b { + match (a_parts.peek().is_some(), b_parts.peek().is_some()) { + | (true, false) => return std::cmp::Ordering::Less, + | (false, true) => return std::cmp::Ordering::Greater, + | _ => {} } + return a.cmp(b); } - | (Some(_), None) => return std::cmp::Ordering::Greater, - | (None, Some(_)) => return std::cmp::Ordering::Less, - | (None, None) => return std::cmp::Ordering::Equal, + } + | (Some(_), None) => return std::cmp::Ordering::Greater, + | (None, Some(_)) => return std::cmp::Ordering::Less, + | (None, None) => return std::cmp::Ordering::Equal, } } }); SortedByPath(items) } -pub(crate) fn build_file_tree_from_sorted_paths(paths: &SortedByPath) -> Vec -where - T: AsRef, -{ +pub(crate) fn build_file_tree( + paths: &SortedByPath, + key: impl Fn(&T) -> &str, +) -> Vec { let mut stack: Vec<&str> = Vec::with_capacity(50); let mut leafs: Vec<&str> = Vec::with_capacity(50); @@ -127,7 +140,7 @@ where fn strip_path_prefix<'a>(path: &'a str, prefix: &str) -> &'a str { path.strip_prefix(prefix) - .and_then(|it| it.strip_prefix('/')) + .map(|it| it.strip_prefix('/').unwrap_or(it)) .unwrap_or(path) } @@ -137,115 +150,139 @@ where items: &mut Vec, emitted_depth: usize, base_depth: usize, - ) { + ) -> bool { + let mut base_dir_created = false; + if leafs.is_empty() && stack.is_empty() { - return; + return false; } let stack_dir_path = Arc::::from(stack.join("/")); let (common_dir_path, stack_dir_name) = if (base_depth == 0 || base_depth == stack.len()) && emitted_depth == 0 { - (None, Arc::clone(&stack_dir_path)) + (None, Some(Arc::clone(&stack_dir_path))) } else { - let common_dir_path = if base_depth == stack.len() { + let common_dir_path = if base_depth == stack.len() || emitted_depth == stack.len() { Arc::::from(stack[..emitted_depth].join("/")) } else { Arc::::from(stack[..base_depth].join("/")) }; let stack_dir_name = Arc::::from(strip_path_prefix(&stack_dir_path, &common_dir_path)); - (Some(common_dir_path), stack_dir_name) + ( + Some(common_dir_path), + if stack_dir_name.len() == 0 { + None + } else { + Some(stack_dir_name) + }, + ) }; let stack_dir_depth = if let Some(common_dir_path) = common_dir_path && emitted_depth == 0 { items.push(FileTreeItem { + kind: FileTreeItemKind::Directory, full_path: Arc::clone(&common_dir_path), name: common_dir_path, level: base_depth.saturating_sub(1), }); + base_dir_created = true; base_depth } else { emitted_depth }; - items.push(FileTreeItem { - full_path: Arc::clone(&stack_dir_path), - name: stack_dir_name, - level: stack_dir_depth, - }); + if let Some(stack_dir_name) = stack_dir_name { + items.push(FileTreeItem { + kind: FileTreeItemKind::Directory, + full_path: Arc::clone(&stack_dir_path), + name: stack_dir_name, + level: stack_dir_depth, + }); + } for leaf in leafs.drain(..) { items.push(FileTreeItem { + kind: FileTreeItemKind::File, full_path: Arc::::from(leaf), name: strip_path_prefix(&leaf, &stack_dir_path).into(), level: stack.len(), }); } + + base_dir_created } let mut base_depth = 0; let mut emitted_depth = 0; for path in paths.0.iter() { - let path = path.as_ref(); + let path = key(path); match path.rsplit_once('/') { - | None => { - flush_leafs(&mut leafs, &stack, &mut items, emitted_depth, base_depth); - stack.clear(); - // top level file - items.push(FileTreeItem { - full_path: path.into(), - name: path.into(), - level: 0, - }); + | None => { + flush_leafs(&mut leafs, &stack, &mut items, emitted_depth, base_depth); + stack.clear(); + // top level file + items.push(FileTreeItem { + kind: FileTreeItemKind::File, + full_path: path.into(), + name: path.into(), + level: 0, + }); + } + + | Some((parent, _)) => { + let mut common_depth = 0; + + for (i, seg) in parent.split('/').enumerate() { + let stack_item = stack.get(i); + if stack_item.is_none() { + // segment is unseen, push to stack + stack.push(seg); + common_depth += 1; + } else if Some(&seg) == stack.get(i) { + // segment matches stack, continue comparison + common_depth += 1; + } else { + // segment differs from stack, stop comparison + break; + } } - | Some((parent, _)) => { - let mut common_depth = 0; - - for (i, seg) in parent.split('/').enumerate() { - let stack_item = stack.get(i); - if stack_item.is_none() { - // segment is unseen, push to stack - stack.push(seg); - common_depth += 1; - } else if Some(&seg) == stack.get(i) { - // segment matches stack, continue comparison - common_depth += 1; - } else { - // segment differs from stack, stop comparison - break; - } - } - - if common_depth == stack.len() { - // current path is in same directory as stack, add to leafs - leafs.push(path); - base_depth = common_depth; - } else { - // e.g. stack = ["a", "b", "c"], path = ["a", "c"] - // common dir path = "a/", stack dir path = "a/b/c", common count = 1 - // push common dir a to items - // also push stack dir a/b/c to items but strip a from name so that it becomes "b/c" with level equal to common_count - // finally push any leaf under a/b/c + if common_depth == stack.len() { + // current path is in same directory as stack, add to leafs + leafs.push(path); + base_depth = common_depth; + } else { + // e.g. stack = ["a", "b", "c"], path = ["a", "c"] + // common dir path = "a/", stack dir path = "a/b/c", common count = 1 + // push common dir a to items + // also push stack dir a/b/c to items but strip a from name so that it becomes "b/c" with level equal to common_count + // finally push any leaf under a/b/c + let base_dir_created = flush_leafs(&mut leafs, &stack, &mut items, emitted_depth, common_depth); - // pop top of stack minus common dir - stack.truncate(common_depth); + // pop top of stack minus common dir + stack.truncate(common_depth); + + if base_dir_created { emitted_depth = common_depth; - - for seg in parent.split('/').skip(common_depth) { - stack.push(seg); - } - - leafs.push(path); + } else { + emitted_depth = 0; } + + for seg in parent.split('/').skip(common_depth) { + stack.push(seg); + } + + leafs.push(path); } } + } } flush_leafs(&mut leafs, &stack, &mut items, emitted_depth, base_depth); @@ -253,6 +290,12 @@ where items } +impl Default for SortedByPath { + fn default() -> Self { + Self(Vec::new()) + } +} + impl Deref for SortedByPath { type Target = [T]; fn deref(&self) -> &[T] { diff --git a/src/util/file_tests.rs b/src/util/file_tests.rs index e4c7b70..4750630 100644 --- a/src/util/file_tests.rs +++ b/src/util/file_tests.rs @@ -1,4 +1,5 @@ use super::*; +use serde_json::Value; fn assert_tree(paths: &[&str], expected: &[(&str, &str, usize)]) { let sorted_paths = sort_by_path(paths.to_vec(), |path| *path); @@ -8,7 +9,7 @@ fn assert_tree(paths: &[&str], expected: &[(&str, &str, usize)]) { "test inputs must already be sorted by sort_by_path", ); - let actual = build_file_tree_from_sorted_paths(&sorted_paths) + let actual = build_file_tree(&sorted_paths, |path| *path) .into_iter() .map(|item| { ( @@ -54,6 +55,19 @@ fn sorts_paths_by_components_with_root_files_at_bottom() { ); } +#[test] +fn sorts_directory_branches_before_sibling_files() { + let sorted_paths = sort_by_path( + vec!["src/query.rs", "src/screen/dashboard/issue_list.rs"], + |path| *path, + ); + + assert_eq!( + sorted_paths.0, + vec!["src/screen/dashboard/issue_list.rs", "src/query.rs",], + ); +} + #[test] fn builds_empty_tree_for_empty_paths() { assert_tree(&[], &[]); @@ -138,3 +152,58 @@ fn keeps_emitted_parent_for_mixed_multi_file_and_singleton_branches() { ], ); } + +#[test] +fn builds_tree_for_pull_request_fixture_with_root_and_nested_file() { + let fixture: Value = serde_json::from_str(include_str!( + "../../fixtures/github/issues.pull_request_file_tree.PR_kwDONovem84.json" + )) + .expect("fixture json should parse"); + + let paths = fixture + .get("node") + .and_then(|node| node.get("files")) + .and_then(|files| files.get("edges")) + .and_then(Value::as_array) + .expect("fixture should contain file edges") + .iter() + .filter_map(|edge| edge.get("node")) + .filter_map(|node| node.get("path")) + .filter_map(Value::as_str) + .collect::>(); + + let sorted_paths = sort_by_path(paths, |path| *path); + assert_eq!( + sorted_paths.0.as_slice(), + &["src/screen/dashboard/issue_list.rs", "src/query.rs"], + ); + + let actual = build_file_tree(&sorted_paths, |path| *path) + .into_iter() + .map(|item| { + ( + item.full_path.to_string(), + item.name.to_string(), + item.level, + ) + }) + .collect::>(); + + assert_eq!( + actual, + vec![ + ("src".to_string(), "src".to_string(), 0), + ( + "src/screen/dashboard".to_string(), + "screen/dashboard".to_string(), + 1, + ), + ( + "src/screen/dashboard/issue_list.rs".to_string(), + "issue_list.rs".to_string(), + 3, + ), + ("src/query.rs".to_string(), "query.rs".to_string(), 1), + ], + ); +}