pub use crate::commit_context_menu::{CopyCommitSha, CopyCommitTag, OpenCommitView};use crate::{ commit_context_menu::{CommitContextMenuData, CommitContextMenuSource, commit_context_menu}, commit_tooltip::CommitAvatar, commit_view::CommitView, git_status_icon,};use collections::{BTreeMap, HashMap, IndexSet};use editor::Editor;use file_icons::FileIcons;use git::{ BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, Oid, ParsedGitRemote, parse_git_remote_url, repository::{ CommitDiff, CommitFile, InitialGraphCommitData, LogOrder, LogSource, RepoPath, SearchCommitArgs, }, status::{FileStatus, StatusCode, TrackedStatus},};use gpui::{ Anchor, AnyElement, App, Bounds, ClickEvent, ClipboardItem, DefiniteLength, DismissEvent, DragMoveEvent, ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, Hsla, MouseButton, MouseDownEvent, PathBuilder, Pixels, Point, ScrollHandle, ScrollStrategy, ScrollWheelEvent, SharedString, Subscription, Task, TextStyleRefinement, UniformListScrollHandle, WeakEntity, Window, actions, anchored, deferred, point, prelude::*, px, uniform_list,};use language::line_diff;use markdown::{Markdown, MarkdownElement};use menu::{Cancel, SelectFirst, SelectLast, SelectNext, SelectPrevious};use picker::{Picker, PickerDelegate};use project::{ ProjectPath, git_store::{ CommitDataState, GitGraphEvent, GitStore, GitStoreEvent, GraphDataResponse, Repository, RepositoryEvent, RepositoryId, },};use search::{ SearchOption, SearchOptions, SearchSource, SelectNextMatch, SelectPreviousMatch, ToggleCaseSensitive, buffer_search,};use smallvec::{SmallVec, smallvec};use std::{ cell::Cell, ops::Range, rc::Rc, sync::{Arc, OnceLock}, time::{Duration, Instant},}; use theme::AccentColors;use time::{OffsetDateTime, UtcOffset, format_description::BorrowedFormatItem};use ui::{ Chip, ColumnWidthConfig, CommonAnimationExt as _, ContextMenu, DiffStat, Divider, HeaderResizeInfo, HighlightedLabel, IndentGuideColors, ListItem, ListItemSpacing, RedistributableColumnsState, ScrollableHandle, Table, TableInteractionState, TableRenderContext, TableResizeBehavior, Tooltip, WithScrollbar, bind_redistributable_columns, prelude::*, redistribute_hidden_fractions, redistribute_hidden_widths, render_redistributable_columns_resize_handles, render_table_header, table_row::TableRow,};use util::{ResultExt, debug_panic};use workspace::{ ModalView, Workspace, item::{Item, ItemEvent, TabTooltipContent},}; const COMMIT_CIRCLE_RADIUS: Pixels = px(3.5);const COMMIT_CIRCLE_STROKE_WIDTH: Pixels = px(1.5);const LANE_WIDTH: Pixels = px(16.0);const LEFT_PADDING: Pixels = px(12.0);const LINE_WIDTH: Pixels = px(1.5);const RESIZE_HANDLE_WIDTH: f32 = 8.0;const COPIED_STATE_DURATION: Duration = Duration::from_secs(2);const COMMIT_TAG_LIST_WIDTH_IN_REMS: Rems = rems(10.);const TREE_INDENT: f32 = 20.0;const TABLE_COLUMN_COUNT: usize = 4;const ROW_VERTICAL_PADDING: Pixels = px(4.0); struct CopiedState { copied_at: Option<Instant>,} impl CopiedState { fn new(_window: &mut Window, _cx: &mut Context<Self>) -> Self { Self { copied_at: None } } fn is_copied(&self) -> bool { self.copied_at .map(|t| t.elapsed() < COPIED_STATE_DURATION) .unwrap_or(false) } fn mark_copied(&mut self) { self.copied_at = Some(Instant::now()); }} struct DraggedSplitHandle; struct CommitTagPicker { picker: Entity<Picker<CommitTagPickerDelegate>>,} impl CommitTagPicker { fn new(tag_names: Vec<SharedString>, window: &mut Window, cx: &mut Context<Self>) -> Self { let delegate = CommitTagPickerDelegate { picker: cx.entity().downgrade(), tag_names, selected_index: 0, }; let picker = cx.new(|cx| { Picker::nonsearchable_uniform_list(delegate, window, cx) .initial_width(COMMIT_TAG_LIST_WIDTH_IN_REMS) }); Self { picker } }} impl EventEmitter<DismissEvent> for CommitTagPicker {}impl ModalView for CommitTagPicker {} impl Focusable for CommitTagPicker { fn focus_handle(&self, cx: &App) -> FocusHandle { self.picker.focus_handle(cx) }} impl Render for CommitTagPicker { fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement { v_flex().child(self.picker.clone()) }} struct CommitTagPickerDelegate { picker: WeakEntity<CommitTagPicker>, tag_names: Vec<SharedString>, selected_index: usize,} impl PickerDelegate for CommitTagPickerDelegate { type ListItem = ListItem; fn name() -> &'static str { "commit-tag" } fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> { "Copy Tag".into() } fn match_count(&self) -> usize { self.tag_names.len() } fn selected_index(&self) -> usize { self.selected_index } fn set_selected_index( &mut self, ix: usize, _window: &mut Window, _cx: &mut Context<Picker<Self>>, ) { self.selected_index = ix; } fn update_matches( &mut self, _query: String, _window: &mut Window, _cx: &mut Context<Picker<Self>>, ) -> Task<()> { Task::ready(()) } fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) { if let Some(tag_name) = self.tag_names.get(self.selected_index) { cx.write_to_clipboard(ClipboardItem::new_string(tag_name.to_string())); } self.dismissed(window, cx); } fn dismissed(&mut self, _window: &mut Window, cx: &mut Context<Picker<Self>>) { self.picker .update(cx, |_this, cx| cx.emit(DismissEvent)) .ok(); } fn render_match( &self, ix: usize, selected: bool, _window: &mut Window, _cx: &mut Context<Picker<Self>>, ) -> Option<Self::ListItem> { Some( ListItem::new(ix) .inset(true) .spacing(ListItemSpacing::Sparse) .toggle_state(selected) .child(Label::new(self.tag_names.get(ix)?.clone())), ) }} #[derive(Clone)]struct ChangedFileEntry { status: FileStatus, file_name: SharedString, dir_path: SharedString, repo_path: RepoPath,} impl ChangedFileEntry { fn from_commit_file(file: &CommitFile, _cx: &App) -> Self { let file_name: SharedString = file .path .file_name() .map(|n| n.to_string()) .unwrap_or_default() .into(); let dir_path: SharedString = file .path .parent() .map(|p| p.as_unix_str().to_string()) .unwrap_or_default() .into(); let status_code = match (&file.old_text, &file.new_text) { (None, Some(_)) => StatusCode::Added, (Some(_), None) => StatusCode::Deleted, _ => StatusCode::Modified, }; let status = FileStatus::Tracked(TrackedStatus { index_status: status_code, worktree_status: StatusCode::Unmodified, }); Self { status, file_name, dir_path, repo_path: file.path.clone(), } } fn open_in_commit_view( &self, commit_sha: &SharedString, repository: &WeakEntity<Repository>, workspace: &WeakEntity<Workspace>, window: &mut Window, cx: &mut App, ) { CommitView::open( commit_sha.to_string(), repository.clone(), workspace.clone(), None, Some(self.repo_path.clone()), window, cx, ); } fn render( &self, ix: usize, depth: usize, directory_label: Option<SharedString>, commit_sha: SharedString, repository: WeakEntity<Repository>, workspace: WeakEntity<Workspace>, _cx: &App, ) -> AnyElement { let file_name = self.file_name.clone(); let dir_path = self.dir_path.clone(); ListItem::new(("changed-file", ix)) .spacing(ListItemSpacing::Sparse) .indent_level(depth) .indent_step_size(px(TREE_INDENT)) .start_slot(git_status_icon(self.status)) .child( Label::new(file_name.clone()) .size(LabelSize::Small) .truncate(), ) .when_some(directory_label, |this, directory_label| { this.child( Label::new(directory_label) .size(LabelSize::Small) .color(Color::Muted) .truncate_start(), ) }) .tooltip({ let meta = if dir_path.is_empty() { file_name } else { format!("{}/{}", dir_path, file_name).into() }; move |_, cx| Tooltip::with_meta("View Changes", None, meta.clone(), cx) }) .on_click({ let entry = self.clone(); move |_, window, cx| { entry.open_in_commit_view(&commit_sha, &repository, &workspace, window, cx); } }) .into_any_element() }} enum ChangedFileTreeEntry { Directory(ChangedFileDirectoryEntry), File(ChangedFileTreeStatusEntry),} struct ChangedFileTreeStatusEntry { entry: ChangedFileEntry, depth: usize,} #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]enum ChangedFilesViewMode { Flat, #[default] Tree,} impl ChangedFilesViewMode { fn toggled(self) -> Self { match self { Self::Flat => Self::Tree, Self::Tree => Self::Flat, } } fn is_tree(self) -> bool { matches!(self, Self::Tree) }} struct ChangedFileDirectoryEntry { path: RepoPath, name: SharedString, depth: usize, expanded: bool,} impl ChangedFileDirectoryEntry { fn render(&self, ix: usize, git_graph: WeakEntity<GitGraph>, cx: &App) -> AnyElement { let path = self.path.clone(); let expanded = self.expanded; let folder_icon = FileIcons::get_folder_icon(expanded, path.as_std_path(), cx) .map(|icon| { Icon::from_path(icon) .size(IconSize::Small) .color(Color::Muted) }) .unwrap_or_else(|| { let icon = if expanded { IconName::FolderOpen } else { IconName::Folder }; Icon::new(icon).size(IconSize::Small).color(Color::Muted) }); ListItem::new(("changed-file-dir", ix)) .spacing(ListItemSpacing::Sparse) .indent_level(self.depth) .indent_step_size(px(TREE_INDENT)) .start_slot(folder_icon) .child( Label::new(self.name.clone()) .size(LabelSize::Small) .color(Color::Muted) .truncate(), ) .tooltip({ let name = self.name.clone(); move |_, cx| Tooltip::with_meta("Toggle Folder", None, name.clone(), cx) }) .on_click(move |_, _, cx| { git_graph .update(cx, |git_graph, cx| { git_graph .changed_files_expanded_dirs .insert(path.clone(), !expanded); cx.notify(); }) .ok(); }) .into_any_element() }} #[derive(Default)]struct ChangedFileTreeNode { name: SharedString, path: Option<RepoPath>, children: BTreeMap<SharedString, ChangedFileTreeNode>, files: Vec<ChangedFileEntry>,} fn build_changed_file_tree_entries( mut files: Vec<ChangedFileEntry>, expanded_dirs: &HashMap<RepoPath, bool>,) -> Vec<ChangedFileTreeEntry> { files.sort_by(|a, b| a.repo_path.cmp(&b.repo_path)); let mut root = ChangedFileTreeNode::default(); for file in files { let components: Vec<&str> = file.repo_path.components().collect(); if components.is_empty() { root.files.push(file); continue; } let mut current = &mut root; let mut current_path = String::new(); for (ix, component) in components.iter().enumerate() { if ix == components.len() - 1 { current.files.push(file.clone()); } else { if !current_path.is_empty() { current_path.push('/'); } current_path.push_str(component); let Ok(dir_path) = RepoPath::new(¤t_path) else { continue; }; let component = SharedString::from(component.to_string()); current = current .children .entry(component.clone()) .or_insert_with(|| ChangedFileTreeNode { name: component, path: Some(dir_path), ..Default::default() }); } } } flatten_changed_file_tree(&root, 0, expanded_dirs)} fn flatten_changed_file_tree( node: &ChangedFileTreeNode, depth: usize, expanded_dirs: &HashMap<RepoPath, bool>,) -> Vec<ChangedFileTreeEntry> { let mut entries = Vec::new(); for child in node.children.values() { let (terminal, name) = compact_changed_file_directory_chain(child); let Some(path) = terminal.path.clone().or_else(|| child.path.clone()) else { continue; }; let expanded = *expanded_dirs.get(&path).unwrap_or(&true); let child_entries = flatten_changed_file_tree(terminal, depth + 1, expanded_dirs); entries.push(ChangedFileTreeEntry::Directory(ChangedFileDirectoryEntry { path, name, depth, expanded, })); if expanded { entries.extend(child_entries); } } entries.extend( node.files .iter() .cloned() .map(|entry| ChangedFileTreeEntry::File(ChangedFileTreeStatusEntry { entry, depth })), ); entries} fn compact_changed_file_directory_chain( mut node: &ChangedFileTreeNode,) -> (&ChangedFileTreeNode, SharedString) { let mut parts = vec![node.name.clone()]; while node.files.is_empty() && node.children.len() == 1 { let Some(child) = node.children.values().next() else { continue;Lines 1–500 of 7555. Load more
pub use crate::commit_context_menu::{CopyCommitSha, CopyCommitTag, OpenCommitView}; use crate::{ commit_context_menu::{CommitContextMenuData, CommitContextMenuSource, commit_context_menu}, commit_tooltip::CommitAvatar, commit_view::CommitView, git_status_icon, }; use collections::{BTreeMap, HashMap, IndexSet}; use editor::Editor; use file_icons::FileIcons; use git::{ BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, Oid, ParsedGitRemote, parse_git_remote_url, repository::{ CommitDiff, CommitFile, InitialGraphCommitData, LogOrder, LogSource, RepoPath, SearchCommitArgs, }, status::{FileStatus, StatusCode, TrackedStatus}, }; use gpui::{ Anchor, AnyElement, App, Bounds, ClickEvent, ClipboardItem, DefiniteLength, DismissEvent, DragMoveEvent, ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, Hsla, MouseButton, MouseDownEvent, PathBuilder, Pixels, Point, ScrollHandle, ScrollStrategy, ScrollWheelEvent, SharedString, Subscription, Task, TextStyleRefinement, UniformListScrollHandle, WeakEntity, Window, actions, anchored, deferred, point, prelude::*, px, uniform_list, }; use language::line_diff; use markdown::{Markdown, MarkdownElement}; use menu::{Cancel, SelectFirst, SelectLast, SelectNext, SelectPrevious}; use picker::{Picker, PickerDelegate}; use project::{ ProjectPath, git_store::{ CommitDataState, GitGraphEvent, GitStore, GitStoreEvent, GraphDataResponse, Repository, RepositoryEvent, RepositoryId, }, }; use search::{ SearchOption, SearchOptions, SearchSource, SelectNextMatch, SelectPreviousMatch, ToggleCaseSensitive, buffer_search, }; use smallvec::{SmallVec, smallvec}; use std::{ cell::Cell, ops::Range, rc::Rc, sync::{Arc, OnceLock}, time::{Duration, Instant}, }; use theme::AccentColors; use time::{OffsetDateTime, UtcOffset, format_description::BorrowedFormatItem}; use ui::{ Chip, ColumnWidthConfig, CommonAnimationExt as _, ContextMenu, DiffStat, Divider, HeaderResizeInfo, HighlightedLabel, IndentGuideColors, ListItem, ListItemSpacing, RedistributableColumnsState, ScrollableHandle, Table, TableInteractionState, TableRenderContext, TableResizeBehavior, Tooltip, WithScrollbar, bind_redistributable_columns, prelude::*, redistribute_hidden_fractions, redistribute_hidden_widths, render_redistributable_columns_resize_handles, render_table_header, table_row::TableRow, }; use util::{ResultExt, debug_panic}; use workspace::{ ModalView, Workspace, item::{Item, ItemEvent, TabTooltipContent}, }; const COMMIT_CIRCLE_RADIUS: Pixels = px(3.5); const COMMIT_CIRCLE_STROKE_WIDTH: Pixels = px(1.5); const LANE_WIDTH: Pixels = px(16.0); const LEFT_PADDING: Pixels = px(12.0); const LINE_WIDTH: Pixels = px(1.5); const RESIZE_HANDLE_WIDTH: f32 = 8.0; const COPIED_STATE_DURATION: Duration = Duration::from_secs(2); const COMMIT_TAG_LIST_WIDTH_IN_REMS: Rems = rems(10.); const TREE_INDENT: f32 = 20.0; const TABLE_COLUMN_COUNT: usize = 4; const ROW_VERTICAL_PADDING: Pixels = px(4.0); struct CopiedState { copied_at: Option<Instant>, } impl CopiedState { fn new(_window: &mut Window, _cx: &mut Context<Self>) -> Self { Self { copied_at: None } } fn is_copied(&self) -> bool { self.copied_at .map(|t| t.elapsed() < COPIED_STATE_DURATION) .unwrap_or(false) } fn mark_copied(&mut self) { self.copied_at = Some(Instant::now()); } } struct DraggedSplitHandle; struct CommitTagPicker { picker: Entity<Picker<CommitTagPickerDelegate>>, } impl CommitTagPicker { fn new(tag_names: Vec<SharedString>, window: &mut Window, cx: &mut Context<Self>) -> Self { let delegate = CommitTagPickerDelegate { picker: cx.entity().downgrade(), tag_names, selected_index: 0, }; let picker = cx.new(|cx| { Picker::nonsearchable_uniform_list(delegate, window, cx) .initial_width(COMMIT_TAG_LIST_WIDTH_IN_REMS) }); Self { picker } } } impl EventEmitter<DismissEvent> for CommitTagPicker {} impl ModalView for CommitTagPicker {} impl Focusable for CommitTagPicker { fn focus_handle(&self, cx: &App) -> FocusHandle { self.picker.focus_handle(cx) } } impl Render for CommitTagPicker { fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement { v_flex().child(self.picker.clone()) } } struct CommitTagPickerDelegate { picker: WeakEntity<CommitTagPicker>, tag_names: Vec<SharedString>, selected_index: usize, } impl PickerDelegate for CommitTagPickerDelegate { type ListItem = ListItem; fn name() -> &'static str { "commit-tag" } fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> { "Copy Tag".into() } fn match_count(&self) -> usize { self.tag_names.len() } fn selected_index(&self) -> usize { self.selected_index } fn set_selected_index( &mut self, ix: usize, _window: &mut Window, _cx: &mut Context<Picker<Self>>, ) { self.selected_index = ix; } fn update_matches( &mut self, _query: String, _window: &mut Window, _cx: &mut Context<Picker<Self>>, ) -> Task<()> { Task::ready(()) } fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) { if let Some(tag_name) = self.tag_names.get(self.selected_index) { cx.write_to_clipboard(ClipboardItem::new_string(tag_name.to_string())); } self.dismissed(window, cx); } fn dismissed(&mut self, _window: &mut Window, cx: &mut Context<Picker<Self>>) { self.picker .update(cx, |_this, cx| cx.emit(DismissEvent)) .ok(); } fn render_match( &self, ix: usize, selected: bool, _window: &mut Window, _cx: &mut Context<Picker<Self>>, ) -> Option<Self::ListItem> { Some( ListItem::new(ix) .inset(true) .spacing(ListItemSpacing::Sparse) .toggle_state(selected) .child(Label::new(self.tag_names.get(ix)?.clone())), ) } } #[derive(Clone)] struct ChangedFileEntry { status: FileStatus, file_name: SharedString, dir_path: SharedString, repo_path: RepoPath, } impl ChangedFileEntry { fn from_commit_file(file: &CommitFile, _cx: &App) -> Self { let file_name: SharedString = file .path .file_name() .map(|n| n.to_string()) .unwrap_or_default() .into(); let dir_path: SharedString = file .path .parent() .map(|p| p.as_unix_str().to_string()) .unwrap_or_default() .into(); let status_code = match (&file.old_text, &file.new_text) { (None, Some(_)) => StatusCode::Added, (Some(_), None) => StatusCode::Deleted, _ => StatusCode::Modified, }; let status = FileStatus::Tracked(TrackedStatus { index_status: status_code, worktree_status: StatusCode::Unmodified, }); Self { status, file_name, dir_path, repo_path: file.path.clone(), } } fn open_in_commit_view( &self, commit_sha: &SharedString, repository: &WeakEntity<Repository>, workspace: &WeakEntity<Workspace>, window: &mut Window, cx: &mut App, ) { CommitView::open( commit_sha.to_string(), repository.clone(), workspace.clone(), None, Some(self.repo_path.clone()), window, cx, ); } fn render( &self, ix: usize, depth: usize, directory_label: Option<SharedString>, commit_sha: SharedString, repository: WeakEntity<Repository>, workspace: WeakEntity<Workspace>, _cx: &App, ) -> AnyElement { let file_name = self.file_name.clone(); let dir_path = self.dir_path.clone(); ListItem::new(("changed-file", ix)) .spacing(ListItemSpacing::Sparse) .indent_level(depth) .indent_step_size(px(TREE_INDENT)) .start_slot(git_status_icon(self.status)) .child( Label::new(file_name.clone()) .size(LabelSize::Small) .truncate(), ) .when_some(directory_label, |this, directory_label| { this.child( Label::new(directory_label) .size(LabelSize::Small) .color(Color::Muted) .truncate_start(), ) }) .tooltip({ let meta = if dir_path.is_empty() { file_name } else { format!("{}/{}", dir_path, file_name).into() }; move |_, cx| Tooltip::with_meta("View Changes", None, meta.clone(), cx) }) .on_click({ let entry = self.clone(); move |_, window, cx| { entry.open_in_commit_view(&commit_sha, &repository, &workspace, window, cx); } }) .into_any_element() } } enum ChangedFileTreeEntry { Directory(ChangedFileDirectoryEntry), File(ChangedFileTreeStatusEntry), } struct ChangedFileTreeStatusEntry { entry: ChangedFileEntry, depth: usize, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] enum ChangedFilesViewMode { Flat, #[default] Tree, } impl ChangedFilesViewMode { fn toggled(self) -> Self { match self { Self::Flat => Self::Tree, Self::Tree => Self::Flat, } } fn is_tree(self) -> bool { matches!(self, Self::Tree) } } struct ChangedFileDirectoryEntry { path: RepoPath, name: SharedString, depth: usize, expanded: bool, } impl ChangedFileDirectoryEntry { fn render(&self, ix: usize, git_graph: WeakEntity<GitGraph>, cx: &App) -> AnyElement { let path = self.path.clone(); let expanded = self.expanded; let folder_icon = FileIcons::get_folder_icon(expanded, path.as_std_path(), cx) .map(|icon| { Icon::from_path(icon) .size(IconSize::Small) .color(Color::Muted) }) .unwrap_or_else(|| { let icon = if expanded { IconName::FolderOpen } else { IconName::Folder }; Icon::new(icon).size(IconSize::Small).color(Color::Muted) }); ListItem::new(("changed-file-dir", ix)) .spacing(ListItemSpacing::Sparse) .indent_level(self.depth) .indent_step_size(px(TREE_INDENT)) .start_slot(folder_icon) .child( Label::new(self.name.clone()) .size(LabelSize::Small) .color(Color::Muted) .truncate(), ) .tooltip({ let name = self.name.clone(); move |_, cx| Tooltip::with_meta("Toggle Folder", None, name.clone(), cx) }) .on_click(move |_, _, cx| { git_graph .update(cx, |git_graph, cx| { git_graph .changed_files_expanded_dirs .insert(path.clone(), !expanded); cx.notify(); }) .ok(); }) .into_any_element() } } #[derive(Default)] struct ChangedFileTreeNode { name: SharedString, path: Option<RepoPath>, children: BTreeMap<SharedString, ChangedFileTreeNode>, files: Vec<ChangedFileEntry>, } fn build_changed_file_tree_entries( mut files: Vec<ChangedFileEntry>, expanded_dirs: &HashMap<RepoPath, bool>, ) -> Vec<ChangedFileTreeEntry> { files.sort_by(|a, b| a.repo_path.cmp(&b.repo_path)); let mut root = ChangedFileTreeNode::default(); for file in files { let components: Vec<&str> = file.repo_path.components().collect(); if components.is_empty() { root.files.push(file); continue; } let mut current = &mut root; let mut current_path = String::new(); for (ix, component) in components.iter().enumerate() { if ix == components.len() - 1 { current.files.push(file.clone()); } else { if !current_path.is_empty() { current_path.push('/'); } current_path.push_str(component); let Ok(dir_path) = RepoPath::new(¤t_path) else { continue; }; let component = SharedString::from(component.to_string()); current = current .children .entry(component.clone()) .or_insert_with(|| ChangedFileTreeNode { name: component, path: Some(dir_path), ..Default::default() }); } } } flatten_changed_file_tree(&root, 0, expanded_dirs) } fn flatten_changed_file_tree( node: &ChangedFileTreeNode, depth: usize, expanded_dirs: &HashMap<RepoPath, bool>, ) -> Vec<ChangedFileTreeEntry> { let mut entries = Vec::new(); for child in node.children.values() { let (terminal, name) = compact_changed_file_directory_chain(child); let Some(path) = terminal.path.clone().or_else(|| child.path.clone()) else { continue; }; let expanded = *expanded_dirs.get(&path).unwrap_or(&true); let child_entries = flatten_changed_file_tree(terminal, depth + 1, expanded_dirs); entries.push(ChangedFileTreeEntry::Directory(ChangedFileDirectoryEntry { path, name, depth, expanded, })); if expanded { entries.extend(child_entries); } } entries.extend( node.files .iter() .cloned() .map(|entry| ChangedFileTreeEntry::File(ChangedFileTreeStatusEntry { entry, depth })), ); entries } fn compact_changed_file_directory_chain( mut node: &ChangedFileTreeNode, ) -> (&ChangedFileTreeNode, SharedString) { let mut parts = vec![node.name.clone()]; while node.files.is_empty() && node.children.len() == 1 { let Some(child) = node.children.values().next() else { continue;