feat: impl pull request file tree
This commit is contained in:
@@ -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::<Vec<_>>()
|
||||
@@ -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<ChangedFile>;
|
||||
type Data = util::file::SortedByPath<ChangedFile>;
|
||||
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::<Vec<_>>()
|
||||
})
|
||||
.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;
|
||||
|
||||
1
src/asset/font_icon/chevron_right.svg
Normal file
1
src/asset/font_icon/chevron_right.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-right-icon lucide-chevron-right"><path d="m9 18 6-6-6-6"/></svg>
|
||||
|
After Width: | Height: | Size: 275 B |
1
src/asset/font_icon/folder_closed.svg
Normal file
1
src/asset/font_icon/folder_closed.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-folder-closed-icon lucide-folder-closed"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/><path d="M2 10h20"/></svg>
|
||||
|
After Width: | Height: | Size: 400 B |
1
src/asset/font_icon/folder_open.svg
Normal file
1
src/asset/font_icon/folder_open.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-folder-open-icon lucide-folder-open"><path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"/></svg>
|
||||
|
After Width: | Height: | Size: 435 B |
176
src/component/file_tree.rs
Normal file
176
src/component/file_tree.rs
Normal file
@@ -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<dyn Fn(usize, &gpui::Window, &mut gpui::App) -> util::file::FileTreeItem>,
|
||||
on_item_click: Option<Rc<dyn Fn(&usize, &mut gpui::Window, &mut gpui::App)>>,
|
||||
}
|
||||
|
||||
#[derive(gpui::IntoElement)]
|
||||
pub(crate) struct Item {
|
||||
item: util::file::FileTreeItem,
|
||||
is_expanded: bool,
|
||||
on_click: Box<dyn Fn(&mut gpui::Window, &mut gpui::App) + 'static>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct FileTreeState(Rc<RefCell<FileTreeStateInner>>);
|
||||
|
||||
struct FileTreeStateInner {
|
||||
list_state: gpui::ListState,
|
||||
collapsed_dirs: HashSet<Arc<str>>,
|
||||
visible_items: Vec<usize>,
|
||||
}
|
||||
|
||||
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::<Vec<_>>();
|
||||
}
|
||||
|
||||
pub(crate) fn toggle_directory(
|
||||
&mut self,
|
||||
dir_path: &Arc<str>,
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<DiffViewContent>,
|
||||
|
||||
file_tree: gpui::Entity<PullRequestFileTree>,
|
||||
}
|
||||
|
||||
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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
81
src/screen/dashboard/pull_request_file_tree.rs
Normal file
81
src/screen/dashboard/pull_request_file_tree.rs
Normal file
@@ -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<api::issues::FetchPullRequestFileTree>,
|
||||
file_tree_state: FileTreeState,
|
||||
file_tree_items: Vec<FileTreeItem>,
|
||||
}
|
||||
|
||||
pub(crate) fn new(
|
||||
pr_id: api::issues::Id,
|
||||
cx: &mut gpui::Context<PullRequestFileTree>,
|
||||
) -> 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<PullRequestFileTree>) {
|
||||
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<PullRequestFileTree>,
|
||||
) {
|
||||
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<Self>,
|
||||
) -> 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);
|
||||
}))
|
||||
}
|
||||
}
|
||||
221
src/util/file.rs
221
src/util/file.rs
@@ -17,12 +17,20 @@ pub(crate) enum FileType {
|
||||
|
||||
pub(crate) struct SortedByPath<T>(Vec<T>);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct FileTreeItem {
|
||||
pub(crate) kind: FileTreeItemKind,
|
||||
pub(crate) full_path: Arc<str>,
|
||||
pub(crate) name: Arc<str>,
|
||||
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<std::ops::Range<usize>> {
|
||||
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<T>(mut items: Vec<T>, 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<T>(paths: &SortedByPath<T>) -> Vec<FileTreeItem>
|
||||
where
|
||||
T: AsRef<str>,
|
||||
{
|
||||
pub(crate) fn build_file_tree<T>(
|
||||
paths: &SortedByPath<T>,
|
||||
key: impl Fn(&T) -> &str,
|
||||
) -> Vec<FileTreeItem> {
|
||||
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<FileTreeItem>,
|
||||
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::<str>::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::<str>::from(stack[..emitted_depth].join("/"))
|
||||
} else {
|
||||
Arc::<str>::from(stack[..base_depth].join("/"))
|
||||
};
|
||||
let stack_dir_name =
|
||||
Arc::<str>::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::<str>::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<T> Default for SortedByPath<T> {
|
||||
fn default() -> Self {
|
||||
Self(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Deref for SortedByPath<T> {
|
||||
type Target = [T];
|
||||
fn deref(&self) -> &[T] {
|
||||
|
||||
@@ -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::<Vec<_>>();
|
||||
|
||||
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::<Vec<_>>();
|
||||
|
||||
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),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user