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, pull_request_query::PullRequestQueryNode,
}, },
}, },
query, query, util,
}; };
type DateTime = String; type DateTime = String;
@@ -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;
@@ -513,9 +513,7 @@ 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 => { | pull_request_file_tree_query::PatchStatus::MODIFIED => {
ChangeType::Modified ChangeType::Modified
} }
@@ -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())
} }
} }

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!( 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)]

View File

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

View File

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

View File

@@ -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
@@ -193,6 +198,18 @@ impl gpui::Render for PullRequestDiffView {
| QueryStatus::Loaded(_) => match &self.diff_view_content { | QueryStatus::Loaded(_) => match &self.diff_view_content {
| Some(content) => div() | Some(content) => div()
.size_full() .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())), .child(diff_view(self.diff_view_state.clone(), content.clone())),
| None => div(), | 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>); 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
@@ -98,12 +106,17 @@ pub(crate) fn sort_by_path<T>(mut items: Vec<T>, key: impl Fn(&T) -> &str) -> So
| _ => {} | _ => {}
} }
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 {
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); return a.cmp(b);
} }
} }
@@ -116,10 +129,10 @@ pub(crate) fn sort_by_path<T>(mut items: Vec<T>, key: impl Fn(&T) -> &str) -> So
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,66 +150,84 @@ 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
}; };
if let Some(stack_dir_name) = stack_dir_name {
items.push(FileTreeItem { items.push(FileTreeItem {
kind: FileTreeItemKind::Directory,
full_path: Arc::clone(&stack_dir_path), full_path: Arc::clone(&stack_dir_path),
name: stack_dir_name, name: stack_dir_name,
level: stack_dir_depth, 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 {
kind: FileTreeItemKind::File,
full_path: path.into(), full_path: path.into(),
name: path.into(), name: path.into(),
level: 0, level: 0,
@@ -232,11 +263,17 @@ where
// 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 // 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 // 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 {
emitted_depth = 0;
}
for seg in parent.split('/').skip(common_depth) { for seg in parent.split('/').skip(common_depth) {
stack.push(seg); stack.push(seg);
@@ -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] {

View File

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