feat: impl pull request file tree

This commit is contained in:
2026-05-28 00:37:35 +01:00
parent c4ebe91669
commit f8a1c3f42b
12 changed files with 642 additions and 249 deletions

View File

@@ -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;

View 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

View 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

View 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
View 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())
}
}

View File

@@ -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)]

View File

@@ -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;

View File

@@ -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;

View File

@@ -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(),
},
}
}
}

View 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);
}))
}
}

View File

@@ -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] {

View File

@@ -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),
],
);
}