feat: impl pull request file tree
This commit is contained in:
@@ -11,7 +11,7 @@ use crate::{
|
|||||||
pull_request_query::PullRequestQueryNode,
|
pull_request_query::PullRequestQueryNode,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
query,
|
query, util,
|
||||||
};
|
};
|
||||||
|
|
||||||
type DateTime = String;
|
type DateTime = String;
|
||||||
@@ -318,8 +318,8 @@ impl query::QueryFn for ListPullRequests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let query_string = match self.filter {
|
let query_string = match self.filter {
|
||||||
| Some(filter) => format!("is:pr archived:false sort:updated-desc {}", filter),
|
| Some(filter) => format!("is:pr archived:false sort:updated-desc {}", filter),
|
||||||
| None => "is:pr archived:false sort:updated-desc".into(),
|
| None => "is:pr archived:false sort:updated-desc".into(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let gql =
|
let gql =
|
||||||
@@ -341,20 +341,20 @@ impl query::QueryFn for ListPullRequests {
|
|||||||
.flatten()
|
.flatten()
|
||||||
.filter_map(|edge| {
|
.filter_map(|edge| {
|
||||||
edge.node.and_then(|n| match n {
|
edge.node.and_then(|n| match n {
|
||||||
| PullRequestPaginationQuerySearchEdgesNode::PullRequest(p) => {
|
| PullRequestPaginationQuerySearchEdgesNode::PullRequest(p) => {
|
||||||
Some(PullRequest {
|
Some(PullRequest {
|
||||||
id: p.id.into(),
|
id: p.id.into(),
|
||||||
title: p.title.into(),
|
title: p.title.into(),
|
||||||
state: p.state,
|
state: p.state,
|
||||||
is_draft: p.is_draft,
|
is_draft: p.is_draft,
|
||||||
repo_slug: format!(
|
repo_slug: format!(
|
||||||
"{}/{}",
|
"{}/{}",
|
||||||
p.repository.owner.login, p.repository.name
|
p.repository.owner.login, p.repository.name
|
||||||
)
|
)
|
||||||
.into(),
|
.into(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
| _ => None,
|
| _ => None,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
@@ -399,43 +399,43 @@ impl query::QueryFn for FetchPullRequest {
|
|||||||
"missing 'node' field on PullRequestQuery response".into(),
|
"missing 'node' field on PullRequestQuery response".into(),
|
||||||
))
|
))
|
||||||
.and_then(|n| match n {
|
.and_then(|n| match n {
|
||||||
| PullRequestQueryNode::PullRequest(p) => {
|
| PullRequestQueryNode::PullRequest(p) => {
|
||||||
let created_at =
|
let created_at =
|
||||||
chrono::DateTime::parse_from_rfc3339(&p.created_at).map_err(|err| {
|
chrono::DateTime::parse_from_rfc3339(&p.created_at).map_err(|err| {
|
||||||
api::Error::MalformedResponse(format!(
|
api::Error::MalformedResponse(format!(
|
||||||
"invalid pull request createdAt {:?}: {err}",
|
"invalid pull request createdAt {:?}: {err}",
|
||||||
p.created_at
|
p.created_at
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(DetailedPullRequest {
|
Ok(DetailedPullRequest {
|
||||||
id: Id(p.id.into()),
|
id: Id(p.id.into()),
|
||||||
title: p.title.into(),
|
title: p.title.into(),
|
||||||
state: p.state,
|
state: p.state,
|
||||||
is_draft: p.is_draft,
|
is_draft: p.is_draft,
|
||||||
body: p.body.into(),
|
body: p.body.into(),
|
||||||
author: p.author.map(|it| api::user::Actor {
|
author: p.author.map(|it| api::user::Actor {
|
||||||
login: it.login.into(),
|
login: it.login.into(),
|
||||||
avatar_url: it.avatar_url.into(),
|
avatar_url: it.avatar_url.into(),
|
||||||
}),
|
}),
|
||||||
base_repo_slug: p
|
base_repo_slug: p
|
||||||
.base_repository
|
.base_repository
|
||||||
.map(|it| it.name_with_owner.into())
|
.map(|it| it.name_with_owner.into())
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
base_branch_name: p.base_ref_name.into(),
|
base_branch_name: p.base_ref_name.into(),
|
||||||
base_ref: p.base_ref_oid.into(),
|
base_ref: p.base_ref_oid.into(),
|
||||||
head_repo_slug: p
|
head_repo_slug: p
|
||||||
.head_repository
|
.head_repository
|
||||||
.map(|it| it.name_with_owner.into())
|
.map(|it| it.name_with_owner.into())
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
head_branch_name: p.head_ref_name.into(),
|
head_branch_name: p.head_ref_name.into(),
|
||||||
head_ref: p.head_ref_oid.into(),
|
head_ref: p.head_ref_oid.into(),
|
||||||
created_at: Some(created_at),
|
created_at: Some(created_at),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
| _ => Err(api::Error::MalformedResponse(
|
| _ => Err(api::Error::MalformedResponse(
|
||||||
"unexpected node type on PullRequestQuery".into(),
|
"unexpected node type on PullRequestQuery".into(),
|
||||||
)),
|
)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -447,7 +447,7 @@ pub(crate) struct FetchPullRequestFileTree {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl query::QueryFn for FetchPullRequestFileTree {
|
impl query::QueryFn for FetchPullRequestFileTree {
|
||||||
type Data = Vec<ChangedFile>;
|
type Data = util::file::SortedByPath<ChangedFile>;
|
||||||
type Error = api::Error;
|
type Error = api::Error;
|
||||||
type Context = api::QueryContext;
|
type Context = api::QueryContext;
|
||||||
|
|
||||||
@@ -494,12 +494,12 @@ impl query::QueryFn for FetchPullRequestFileTree {
|
|||||||
"missing 'node' field on PullRequestFileTreeQuery response".into(),
|
"missing 'node' field on PullRequestFileTreeQuery response".into(),
|
||||||
))
|
))
|
||||||
.and_then(|node| match node {
|
.and_then(|node| match node {
|
||||||
| pull_request_file_tree_query::PullRequestFileTreeQueryNode::PullRequest(
|
| pull_request_file_tree_query::PullRequestFileTreeQueryNode::PullRequest(
|
||||||
pull_request,
|
pull_request,
|
||||||
) => Ok(pull_request),
|
) => Ok(pull_request),
|
||||||
| _ => Err(api::Error::MalformedResponse(
|
| _ => Err(api::Error::MalformedResponse(
|
||||||
"unexpected node type on PullRequestFileTreeQuery".into(),
|
"unexpected node type on PullRequestFileTreeQuery".into(),
|
||||||
)),
|
)),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(pull_request
|
Ok(pull_request
|
||||||
@@ -513,25 +513,23 @@ impl query::QueryFn for FetchPullRequestFileTree {
|
|||||||
edge.node.map(|node| ChangedFile {
|
edge.node.map(|node| ChangedFile {
|
||||||
cursor,
|
cursor,
|
||||||
change_type: match node.change_type {
|
change_type: match node.change_type {
|
||||||
| pull_request_file_tree_query::PatchStatus::ADDED => {
|
| pull_request_file_tree_query::PatchStatus::ADDED => ChangeType::Added,
|
||||||
ChangeType::Added
|
| pull_request_file_tree_query::PatchStatus::MODIFIED => {
|
||||||
}
|
ChangeType::Modified
|
||||||
| pull_request_file_tree_query::PatchStatus::MODIFIED => {
|
}
|
||||||
ChangeType::Modified
|
| pull_request_file_tree_query::PatchStatus::DELETED => {
|
||||||
}
|
ChangeType::Deleted
|
||||||
| pull_request_file_tree_query::PatchStatus::DELETED => {
|
}
|
||||||
ChangeType::Deleted
|
| pull_request_file_tree_query::PatchStatus::RENAMED => {
|
||||||
}
|
ChangeType::Renamed
|
||||||
| pull_request_file_tree_query::PatchStatus::RENAMED => {
|
}
|
||||||
ChangeType::Renamed
|
| pull_request_file_tree_query::PatchStatus::COPIED => {
|
||||||
}
|
ChangeType::Copied
|
||||||
| pull_request_file_tree_query::PatchStatus::COPIED => {
|
}
|
||||||
ChangeType::Copied
|
| pull_request_file_tree_query::PatchStatus::CHANGED => {
|
||||||
}
|
ChangeType::Changed
|
||||||
| pull_request_file_tree_query::PatchStatus::CHANGED => {
|
}
|
||||||
ChangeType::Changed
|
| _ => ChangeType::Changed,
|
||||||
}
|
|
||||||
| _ => ChangeType::Changed,
|
|
||||||
},
|
},
|
||||||
additions: node.additions,
|
additions: node.additions,
|
||||||
deletions: node.deletions,
|
deletions: node.deletions,
|
||||||
@@ -541,6 +539,7 @@ impl query::QueryFn for FetchPullRequestFileTree {
|
|||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
})
|
})
|
||||||
|
.map(|files| util::file::sort_by_path(files, |f| &f.path))
|
||||||
.unwrap_or_default())
|
.unwrap_or_default())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -577,11 +576,11 @@ impl query::QueryFn for FetchPullRequestTimeline {
|
|||||||
|
|
||||||
TimelineActor {
|
TimelineActor {
|
||||||
kind: match on {
|
kind: match on {
|
||||||
| actorFieldsOn::Bot => "Bot",
|
| actorFieldsOn::Bot => "Bot",
|
||||||
| actorFieldsOn::EnterpriseUserAccount => "EnterpriseUserAccount",
|
| actorFieldsOn::EnterpriseUserAccount => "EnterpriseUserAccount",
|
||||||
| actorFieldsOn::Mannequin => "Mannequin",
|
| actorFieldsOn::Mannequin => "Mannequin",
|
||||||
| actorFieldsOn::Organization => "Organization",
|
| actorFieldsOn::Organization => "Organization",
|
||||||
| actorFieldsOn::User => "User",
|
| actorFieldsOn::User => "User",
|
||||||
}
|
}
|
||||||
.into(),
|
.into(),
|
||||||
name: login,
|
name: login,
|
||||||
@@ -591,62 +590,62 @@ impl query::QueryFn for FetchPullRequestTimeline {
|
|||||||
|
|
||||||
fn normalize_assignee(actor: assigneeFields) -> TimelineActor {
|
fn normalize_assignee(actor: assigneeFields) -> TimelineActor {
|
||||||
match actor {
|
match actor {
|
||||||
| assigneeFields::Bot(actor) => TimelineActor {
|
| assigneeFields::Bot(actor) => TimelineActor {
|
||||||
kind: "Bot".into(),
|
kind: "Bot".into(),
|
||||||
name: actor.login,
|
name: actor.login,
|
||||||
avatar_url: Some(actor.avatar_url),
|
avatar_url: Some(actor.avatar_url),
|
||||||
},
|
},
|
||||||
| assigneeFields::Mannequin(actor) => TimelineActor {
|
| assigneeFields::Mannequin(actor) => TimelineActor {
|
||||||
kind: "Mannequin".into(),
|
kind: "Mannequin".into(),
|
||||||
name: actor.login,
|
name: actor.login,
|
||||||
avatar_url: Some(actor.avatar_url),
|
avatar_url: Some(actor.avatar_url),
|
||||||
},
|
},
|
||||||
| assigneeFields::Organization(actor) => TimelineActor {
|
| assigneeFields::Organization(actor) => TimelineActor {
|
||||||
kind: "Organization".into(),
|
kind: "Organization".into(),
|
||||||
name: actor.login,
|
name: actor.login,
|
||||||
avatar_url: Some(actor.avatar_url),
|
avatar_url: Some(actor.avatar_url),
|
||||||
},
|
},
|
||||||
| assigneeFields::User(actor) => TimelineActor {
|
| assigneeFields::User(actor) => TimelineActor {
|
||||||
kind: "User".into(),
|
kind: "User".into(),
|
||||||
name: actor.login,
|
name: actor.login,
|
||||||
avatar_url: Some(actor.avatar_url),
|
avatar_url: Some(actor.avatar_url),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_requested_reviewer(actor: requestedReviewerFields) -> TimelineActor {
|
fn normalize_requested_reviewer(actor: requestedReviewerFields) -> TimelineActor {
|
||||||
match actor {
|
match actor {
|
||||||
| requestedReviewerFields::Bot(actor) => TimelineActor {
|
| requestedReviewerFields::Bot(actor) => TimelineActor {
|
||||||
kind: "Bot".into(),
|
kind: "Bot".into(),
|
||||||
name: actor.login,
|
name: actor.login,
|
||||||
avatar_url: Some(actor.avatar_url),
|
avatar_url: Some(actor.avatar_url),
|
||||||
},
|
},
|
||||||
| requestedReviewerFields::Mannequin(actor) => TimelineActor {
|
| requestedReviewerFields::Mannequin(actor) => TimelineActor {
|
||||||
kind: "Mannequin".into(),
|
kind: "Mannequin".into(),
|
||||||
name: actor.login,
|
name: actor.login,
|
||||||
avatar_url: Some(actor.avatar_url),
|
avatar_url: Some(actor.avatar_url),
|
||||||
},
|
},
|
||||||
| requestedReviewerFields::Team(actor) => TimelineActor {
|
| requestedReviewerFields::Team(actor) => TimelineActor {
|
||||||
kind: "Team".into(),
|
kind: "Team".into(),
|
||||||
name: actor.name,
|
name: actor.name,
|
||||||
avatar_url: None,
|
avatar_url: None,
|
||||||
},
|
},
|
||||||
| requestedReviewerFields::User(actor) => TimelineActor {
|
| requestedReviewerFields::User(actor) => TimelineActor {
|
||||||
kind: "User".into(),
|
kind: "User".into(),
|
||||||
name: actor.login,
|
name: actor.login,
|
||||||
avatar_url: Some(actor.avatar_url),
|
avatar_url: Some(actor.avatar_url),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_review_state(state: PullRequestReviewState) -> String {
|
fn normalize_review_state(state: PullRequestReviewState) -> String {
|
||||||
match state {
|
match state {
|
||||||
| PullRequestReviewState::PENDING => "PENDING",
|
| PullRequestReviewState::PENDING => "PENDING",
|
||||||
| PullRequestReviewState::COMMENTED => "COMMENTED",
|
| PullRequestReviewState::COMMENTED => "COMMENTED",
|
||||||
| PullRequestReviewState::APPROVED => "APPROVED",
|
| PullRequestReviewState::APPROVED => "APPROVED",
|
||||||
| PullRequestReviewState::CHANGES_REQUESTED => "CHANGES_REQUESTED",
|
| PullRequestReviewState::CHANGES_REQUESTED => "CHANGES_REQUESTED",
|
||||||
| PullRequestReviewState::DISMISSED => "DISMISSED",
|
| PullRequestReviewState::DISMISSED => "DISMISSED",
|
||||||
| _ => "OTHER",
|
| _ => "OTHER",
|
||||||
}
|
}
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
@@ -866,10 +865,10 @@ impl query::QueryFn for FetchPullRequestTimeline {
|
|||||||
"missing 'node' field on PullRequestTimelineQuery response".into(),
|
"missing 'node' field on PullRequestTimelineQuery response".into(),
|
||||||
))
|
))
|
||||||
.and_then(|node| match node {
|
.and_then(|node| match node {
|
||||||
| PullRequestTimelineQueryNode::PullRequest(pull_request) => Ok(pull_request),
|
| PullRequestTimelineQueryNode::PullRequest(pull_request) => Ok(pull_request),
|
||||||
| _ => Err(api::Error::MalformedResponse(
|
| _ => Err(api::Error::MalformedResponse(
|
||||||
"unexpected node type on PullRequestTimelineQuery".into(),
|
"unexpected node type on PullRequestTimelineQuery".into(),
|
||||||
)),
|
)),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let timeline = pull_request.timeline_items;
|
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!(
|
define_font_icons!(
|
||||||
Check,
|
Check,
|
||||||
|
ChevronRight,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
FolderGit,
|
FolderGit,
|
||||||
Github,
|
Github,
|
||||||
@@ -45,6 +46,8 @@ define_font_icons!(
|
|||||||
Star,
|
Star,
|
||||||
MessageCircleMore,
|
MessageCircleMore,
|
||||||
FileBracesCorner,
|
FileBracesCorner,
|
||||||
|
FolderClosed,
|
||||||
|
FolderOpen,
|
||||||
);
|
);
|
||||||
|
|
||||||
#[derive(gpui::IntoElement)]
|
#[derive(gpui::IntoElement)]
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
pub(crate) mod button;
|
pub(crate) mod button;
|
||||||
pub(crate) mod code_view;
|
pub(crate) mod code_view;
|
||||||
pub(crate) mod diff_view;
|
pub(crate) mod diff_view;
|
||||||
|
pub(crate) mod file_tree;
|
||||||
pub(crate) mod font_icon;
|
pub(crate) mod font_icon;
|
||||||
pub(crate) mod markdown;
|
pub(crate) mod markdown;
|
||||||
pub(crate) mod segmented_control;
|
pub(crate) mod segmented_control;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
mod issue_list;
|
mod issue_list;
|
||||||
mod pull_request_diff_view;
|
mod pull_request_diff_view;
|
||||||
|
mod pull_request_file_tree;
|
||||||
mod pull_request_view;
|
mod pull_request_view;
|
||||||
mod screen;
|
mod screen;
|
||||||
mod sidebar;
|
mod sidebar;
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ use crate::{
|
|||||||
text::text,
|
text::text,
|
||||||
},
|
},
|
||||||
query::{self, QueryStatus, read_query, use_query, watch_query},
|
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};
|
use gpui::{AppContext, ParentElement, Styled, div};
|
||||||
|
|
||||||
@@ -20,6 +21,8 @@ pub(crate) struct PullRequestDiffView {
|
|||||||
|
|
||||||
diff_view_state: DiffViewState,
|
diff_view_state: DiffViewState,
|
||||||
diff_view_content: Option<DiffViewContent>,
|
diff_view_content: Option<DiffViewContent>,
|
||||||
|
|
||||||
|
file_tree: gpui::Entity<PullRequestFileTree>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn new(
|
pub(crate) fn new(
|
||||||
@@ -31,7 +34,7 @@ pub(crate) fn new(
|
|||||||
pr_query: use_query(api::issues::FetchPullRequest { id: pr_id.clone() }, cx),
|
pr_query: use_query(api::issues::FetchPullRequest { id: pr_id.clone() }, cx),
|
||||||
file_tree_query: use_query(
|
file_tree_query: use_query(
|
||||||
api::issues::FetchPullRequestFileTree {
|
api::issues::FetchPullRequestFileTree {
|
||||||
id: pr_id,
|
id: pr_id.clone(),
|
||||||
first: 100,
|
first: 100,
|
||||||
},
|
},
|
||||||
cx,
|
cx,
|
||||||
@@ -39,6 +42,8 @@ pub(crate) fn new(
|
|||||||
content_diff_query: None,
|
content_diff_query: None,
|
||||||
diff_view_state: DiffViewState::new(),
|
diff_view_state: DiffViewState::new(),
|
||||||
diff_view_content: None,
|
diff_view_content: None,
|
||||||
|
|
||||||
|
file_tree: cx.new(|cx| pull_request_file_tree::new(pr_id, cx)),
|
||||||
};
|
};
|
||||||
view.on_create(cx);
|
view.on_create(cx);
|
||||||
view
|
view
|
||||||
@@ -117,8 +122,8 @@ impl PullRequestDiffView {
|
|||||||
) {
|
) {
|
||||||
if let Some(diff) = {
|
if let Some(diff) = {
|
||||||
match read_query(query, cx) {
|
match read_query(query, cx) {
|
||||||
| QueryStatus::Loaded(diff) => Some(Arc::clone(diff)),
|
| QueryStatus::Loaded(diff) => Some(Arc::clone(diff)),
|
||||||
| _ => None,
|
| _ => None,
|
||||||
}
|
}
|
||||||
} {
|
} {
|
||||||
self.load_diff_view(diff, cx);
|
self.load_diff_view(diff, cx);
|
||||||
@@ -153,16 +158,16 @@ impl PullRequestDiffView {
|
|||||||
|
|
||||||
_ = cx
|
_ = cx
|
||||||
.spawn(async move |weak, cx| match tokio::join!(t1, t2) {
|
.spawn(async move |weak, cx| match tokio::join!(t1, t2) {
|
||||||
| (Some(old_side_highlights), Some(new_side_highlights)) => {
|
| (Some(old_side_highlights), Some(new_side_highlights)) => {
|
||||||
_ = weak.update(cx, |this, cx| {
|
_ = weak.update(cx, |this, cx| {
|
||||||
this.diff_view_state
|
this.diff_view_state
|
||||||
.set_old_side_highlights(old_side_highlights);
|
.set_old_side_highlights(old_side_highlights);
|
||||||
this.diff_view_state
|
this.diff_view_state
|
||||||
.set_new_side_highlights(new_side_highlights);
|
.set_new_side_highlights(new_side_highlights);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
| _ => {}
|
| _ => {}
|
||||||
})
|
})
|
||||||
.detach();
|
.detach();
|
||||||
}
|
}
|
||||||
@@ -184,18 +189,30 @@ impl gpui::Render for PullRequestDiffView {
|
|||||||
.unwrap_or(QueryStatus::Loading);
|
.unwrap_or(QueryStatus::Loading);
|
||||||
|
|
||||||
match content_diff {
|
match content_diff {
|
||||||
| QueryStatus::Err(_) | QueryStatus::Loading => div()
|
| QueryStatus::Err(_) | QueryStatus::Loading => div()
|
||||||
.size_full()
|
.size_full()
|
||||||
.bg(theme.colors.surface)
|
.bg(theme.colors.surface)
|
||||||
.p_4()
|
.p_4()
|
||||||
.child(text("asd")),
|
.child(text("asd")),
|
||||||
|
|
||||||
| QueryStatus::Loaded(_) => match &self.diff_view_content {
|
| QueryStatus::Loaded(_) => match &self.diff_view_content {
|
||||||
| Some(content) => div()
|
| Some(content) => div()
|
||||||
.size_full()
|
.size_full()
|
||||||
.child(diff_view(self.diff_view_state.clone(), content.clone())),
|
.flex()
|
||||||
| None => div(),
|
.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>);
|
pub(crate) struct SortedByPath<T>(Vec<T>);
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
pub(crate) struct FileTreeItem {
|
pub(crate) struct FileTreeItem {
|
||||||
|
pub(crate) kind: FileTreeItemKind,
|
||||||
pub(crate) full_path: Arc<str>,
|
pub(crate) full_path: Arc<str>,
|
||||||
pub(crate) name: Arc<str>,
|
pub(crate) name: Arc<str>,
|
||||||
pub(crate) level: usize,
|
pub(crate) level: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum FileTreeItemKind {
|
||||||
|
File,
|
||||||
|
Directory,
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn classify_content(content: &[u8]) -> ContentType {
|
pub(crate) fn classify_content(content: &[u8]) -> ContentType {
|
||||||
if content.is_empty() {
|
if content.is_empty() {
|
||||||
ContentType::Text
|
ContentType::Text
|
||||||
@@ -35,17 +43,17 @@ pub(crate) fn classify_content(content: &[u8]) -> ContentType {
|
|||||||
ContentType::Text
|
ContentType::Text
|
||||||
} else {
|
} else {
|
||||||
match memchr(0, &content[..content.len().min(8192)]) {
|
match memchr(0, &content[..content.len().min(8192)]) {
|
||||||
| None => ContentType::Text,
|
| None => ContentType::Text,
|
||||||
| Some(_) => ContentType::Binary,
|
| Some(_) => ContentType::Binary,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn file_type_from_path(path: &str) -> FileType {
|
pub(crate) fn file_type_from_path(path: &str) -> FileType {
|
||||||
match Path::new(path).extension().map(|it| it.to_str()).flatten() {
|
match Path::new(path).extension().map(|it| it.to_str()).flatten() {
|
||||||
| Some("rs") => FileType::Rust,
|
| Some("rs") => FileType::Rust,
|
||||||
| Some("js") | Some("jsx") => FileType::JavaScript,
|
| Some("js") | Some("jsx") => FileType::JavaScript,
|
||||||
| _ => FileType::Unknown,
|
| _ => FileType::Unknown,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,18 +71,18 @@ pub(crate) fn line_ranges(content: &[u8]) -> Vec<std::ops::Range<usize>> {
|
|||||||
let c = content[i];
|
let c = content[i];
|
||||||
|
|
||||||
match (c, content.get(i + 1)) {
|
match (c, content.get(i + 1)) {
|
||||||
| (b'\r', Some(b'\n')) => {
|
| (b'\r', Some(b'\n')) => {
|
||||||
// if \r found, check if its \r\n or if its a lone \r
|
// if \r found, check if its \r\n or if its a lone \r
|
||||||
// if \r\n, then treat as one line break
|
// if \r\n, then treat as one line break
|
||||||
ranges.push(line_start..i + 1);
|
ranges.push(line_start..i + 1);
|
||||||
// because we already counted the \n byte, the next iter into it needs to be skipped
|
// because we already counted the \n byte, the next iter into it needs to be skipped
|
||||||
skip_next = true;
|
skip_next = true;
|
||||||
line_start = i + 2;
|
line_start = i + 2;
|
||||||
}
|
}
|
||||||
| _ => {
|
| _ => {
|
||||||
ranges.push(line_start..i);
|
ranges.push(line_start..i);
|
||||||
line_start = i + 1;
|
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('/');
|
let b_is_root_file = !b_path.contains('/');
|
||||||
|
|
||||||
match (a_is_root_file, b_is_root_file) {
|
match (a_is_root_file, b_is_root_file) {
|
||||||
| (true, false) => return std::cmp::Ordering::Greater,
|
| (true, false) => return std::cmp::Ordering::Greater,
|
||||||
| (false, true) => return std::cmp::Ordering::Less,
|
| (false, true) => return std::cmp::Ordering::Less,
|
||||||
| _ => {}
|
| _ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut a_parts = a_path.split('/');
|
let mut a_parts = a_path.split('/').peekable();
|
||||||
let mut b_parts = b_path.split('/');
|
let mut b_parts = b_path.split('/').peekable();
|
||||||
loop {
|
loop {
|
||||||
match (a_parts.next(), b_parts.next()) {
|
match (a_parts.next(), b_parts.next()) {
|
||||||
| (Some(a), Some(b)) => {
|
| (Some(a), Some(b)) => {
|
||||||
if a != b {
|
if a != b {
|
||||||
return a.cmp(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,
|
| (Some(_), None) => return std::cmp::Ordering::Greater,
|
||||||
| (None, None) => return std::cmp::Ordering::Equal,
|
| (None, Some(_)) => return std::cmp::Ordering::Less,
|
||||||
|
| (None, None) => return std::cmp::Ordering::Equal,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
SortedByPath(items)
|
SortedByPath(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn build_file_tree_from_sorted_paths<T>(paths: &SortedByPath<T>) -> Vec<FileTreeItem>
|
pub(crate) fn build_file_tree<T>(
|
||||||
where
|
paths: &SortedByPath<T>,
|
||||||
T: AsRef<str>,
|
key: impl Fn(&T) -> &str,
|
||||||
{
|
) -> Vec<FileTreeItem> {
|
||||||
let mut stack: Vec<&str> = Vec::with_capacity(50);
|
let mut stack: Vec<&str> = Vec::with_capacity(50);
|
||||||
let mut leafs: 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 {
|
fn strip_path_prefix<'a>(path: &'a str, prefix: &str) -> &'a str {
|
||||||
path.strip_prefix(prefix)
|
path.strip_prefix(prefix)
|
||||||
.and_then(|it| it.strip_prefix('/'))
|
.map(|it| it.strip_prefix('/').unwrap_or(it))
|
||||||
.unwrap_or(path)
|
.unwrap_or(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,115 +150,139 @@ where
|
|||||||
items: &mut Vec<FileTreeItem>,
|
items: &mut Vec<FileTreeItem>,
|
||||||
emitted_depth: usize,
|
emitted_depth: usize,
|
||||||
base_depth: usize,
|
base_depth: usize,
|
||||||
) {
|
) -> bool {
|
||||||
|
let mut base_dir_created = false;
|
||||||
|
|
||||||
if leafs.is_empty() && stack.is_empty() {
|
if leafs.is_empty() && stack.is_empty() {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let stack_dir_path = Arc::<str>::from(stack.join("/"));
|
let stack_dir_path = Arc::<str>::from(stack.join("/"));
|
||||||
|
|
||||||
let (common_dir_path, stack_dir_name) =
|
let (common_dir_path, stack_dir_name) =
|
||||||
if (base_depth == 0 || base_depth == stack.len()) && emitted_depth == 0 {
|
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 {
|
} 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("/"))
|
Arc::<str>::from(stack[..emitted_depth].join("/"))
|
||||||
} else {
|
} else {
|
||||||
Arc::<str>::from(stack[..base_depth].join("/"))
|
Arc::<str>::from(stack[..base_depth].join("/"))
|
||||||
};
|
};
|
||||||
let stack_dir_name =
|
let stack_dir_name =
|
||||||
Arc::<str>::from(strip_path_prefix(&stack_dir_path, &common_dir_path));
|
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
|
let stack_dir_depth = if let Some(common_dir_path) = common_dir_path
|
||||||
&& emitted_depth == 0
|
&& emitted_depth == 0
|
||||||
{
|
{
|
||||||
items.push(FileTreeItem {
|
items.push(FileTreeItem {
|
||||||
|
kind: FileTreeItemKind::Directory,
|
||||||
full_path: Arc::clone(&common_dir_path),
|
full_path: Arc::clone(&common_dir_path),
|
||||||
name: common_dir_path,
|
name: common_dir_path,
|
||||||
level: base_depth.saturating_sub(1),
|
level: base_depth.saturating_sub(1),
|
||||||
});
|
});
|
||||||
|
base_dir_created = true;
|
||||||
base_depth
|
base_depth
|
||||||
} else {
|
} else {
|
||||||
emitted_depth
|
emitted_depth
|
||||||
};
|
};
|
||||||
|
|
||||||
items.push(FileTreeItem {
|
if let Some(stack_dir_name) = stack_dir_name {
|
||||||
full_path: Arc::clone(&stack_dir_path),
|
items.push(FileTreeItem {
|
||||||
name: stack_dir_name,
|
kind: FileTreeItemKind::Directory,
|
||||||
level: stack_dir_depth,
|
full_path: Arc::clone(&stack_dir_path),
|
||||||
});
|
name: stack_dir_name,
|
||||||
|
level: stack_dir_depth,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
for leaf in leafs.drain(..) {
|
for leaf in leafs.drain(..) {
|
||||||
items.push(FileTreeItem {
|
items.push(FileTreeItem {
|
||||||
|
kind: FileTreeItemKind::File,
|
||||||
full_path: Arc::<str>::from(leaf),
|
full_path: Arc::<str>::from(leaf),
|
||||||
name: strip_path_prefix(&leaf, &stack_dir_path).into(),
|
name: strip_path_prefix(&leaf, &stack_dir_path).into(),
|
||||||
level: stack.len(),
|
level: stack.len(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
base_dir_created
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut base_depth = 0;
|
let mut base_depth = 0;
|
||||||
let mut emitted_depth = 0;
|
let mut emitted_depth = 0;
|
||||||
|
|
||||||
for path in paths.0.iter() {
|
for path in paths.0.iter() {
|
||||||
let path = path.as_ref();
|
let path = key(path);
|
||||||
match path.rsplit_once('/') {
|
match path.rsplit_once('/') {
|
||||||
| None => {
|
| None => {
|
||||||
flush_leafs(&mut leafs, &stack, &mut items, emitted_depth, base_depth);
|
flush_leafs(&mut leafs, &stack, &mut items, emitted_depth, base_depth);
|
||||||
stack.clear();
|
stack.clear();
|
||||||
// top level file
|
// top level file
|
||||||
items.push(FileTreeItem {
|
items.push(FileTreeItem {
|
||||||
full_path: path.into(),
|
kind: FileTreeItemKind::File,
|
||||||
name: path.into(),
|
full_path: path.into(),
|
||||||
level: 0,
|
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, _)) => {
|
if common_depth == stack.len() {
|
||||||
let mut common_depth = 0;
|
// current path is in same directory as stack, add to leafs
|
||||||
|
leafs.push(path);
|
||||||
for (i, seg) in parent.split('/').enumerate() {
|
base_depth = common_depth;
|
||||||
let stack_item = stack.get(i);
|
} else {
|
||||||
if stack_item.is_none() {
|
// e.g. stack = ["a", "b", "c"], path = ["a", "c"]
|
||||||
// segment is unseen, push to stack
|
// common dir path = "a/", stack dir path = "a/b/c", common count = 1
|
||||||
stack.push(seg);
|
// push common dir a to items
|
||||||
common_depth += 1;
|
// 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
|
||||||
} else if Some(&seg) == stack.get(i) {
|
// finally push any leaf under a/b/c
|
||||||
// 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
|
|
||||||
|
|
||||||
|
let base_dir_created =
|
||||||
flush_leafs(&mut leafs, &stack, &mut items, emitted_depth, common_depth);
|
flush_leafs(&mut leafs, &stack, &mut items, emitted_depth, common_depth);
|
||||||
|
|
||||||
// pop top of stack minus common dir
|
// pop top of stack minus common dir
|
||||||
stack.truncate(common_depth);
|
stack.truncate(common_depth);
|
||||||
|
|
||||||
|
if base_dir_created {
|
||||||
emitted_depth = common_depth;
|
emitted_depth = common_depth;
|
||||||
|
} else {
|
||||||
for seg in parent.split('/').skip(common_depth) {
|
emitted_depth = 0;
|
||||||
stack.push(seg);
|
|
||||||
}
|
|
||||||
|
|
||||||
leafs.push(path);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
flush_leafs(&mut leafs, &stack, &mut items, emitted_depth, base_depth);
|
||||||
@@ -253,6 +290,12 @@ where
|
|||||||
items
|
items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<T> Default for SortedByPath<T> {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self(Vec::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl<T> Deref for SortedByPath<T> {
|
impl<T> Deref for SortedByPath<T> {
|
||||||
type Target = [T];
|
type Target = [T];
|
||||||
fn deref(&self) -> &[T] {
|
fn deref(&self) -> &[T] {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
fn assert_tree(paths: &[&str], expected: &[(&str, &str, usize)]) {
|
fn assert_tree(paths: &[&str], expected: &[(&str, &str, usize)]) {
|
||||||
let sorted_paths = sort_by_path(paths.to_vec(), |path| *path);
|
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",
|
"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()
|
.into_iter()
|
||||||
.map(|item| {
|
.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]
|
#[test]
|
||||||
fn builds_empty_tree_for_empty_paths() {
|
fn builds_empty_tree_for_empty_paths() {
|
||||||
assert_tree(&[], &[]);
|
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