use editor::Editor;use git::repository::GitFileStatus;use gpui::*;use settings::Settings;use std::collections::{BTreeMap, HashMap};use std::ops::Range;use theme::ThemeSettings;use ui::{prelude::*, Checkbox, DividerColor, ElevationIndex, IconButtonShape};use ui::{Disclosure, Divider};use workspace::dock::{DockPosition, Panel, PanelEvent};use workspace::item::TabContentParams;use workspace::Workspace; pub fn init(cx: &mut AppContext) { cx.observe_new_views(|workspace: &mut Workspace, _| { workspace.register_action(|workspace, _: &ToggleFocus, cx| { workspace.toggle_panel_focus::<GitPanel>(cx); }); }) .detach();} actions!( git_panel, [ Deploy, DiscardAll, StageAll, DiscardSelected, StageSelected, UnstageSelected, UnstageAll, FilesChanged, ToggleFocus ]); const ADDED_COLOR: Hsla = Hsla { h: 142. / 360., s: 0.68, l: 0.45, a: 1.0,};const MODIFIED_COLOR: Hsla = Hsla { h: 48. / 360., s: 0.76, l: 0.47, a: 1.0,};const REMOVED_COLOR: Hsla = Hsla { h: 355. / 360., s: 0.65, l: 0.65, a: 1.0,}; #[derive(Debug, Clone, PartialEq, Eq, Hash)]pub struct ChangedFileId(SharedString); #[derive(Debug, Clone)]pub struct PanelChangedFile { pub staged: bool, pub file_path: SharedString, pub lines_added: usize, pub lines_removed: usize, pub status: GitFileStatus,} #[derive(Clone)]pub struct GitLines { pub added: usize, pub removed: usize,} struct FileTree { root: BTreeMap<String, FileTreeNode>,} enum FileTreeNode { File(ChangedFileId), Directory(BTreeMap<String, FileTreeNode>),} enum GitListItem { Header(bool), Divider, Directory { path: String, indent_level: usize, is_expanded: bool, }, File { id: ChangedFileId, indent_level: usize, },} pub struct GitPanelState { files: HashMap<ChangedFileId, PanelChangedFile>, file_order: Vec<ChangedFileId>, file_tree: FileTree, lines_changed: GitLines, show_list: bool, selected_index: usize, needs_update: bool, commit_message: Option<SharedString>,} impl GitPanelState { fn new(changed_files: Vec<PanelChangedFile>) -> Self { let mut files = HashMap::new(); let mut file_order = Vec::new(); let mut lines_changed = GitLines { added: 0, removed: 0, }; for file in changed_files { let id = ChangedFileId(file.file_path.clone()); file_order.push(id.clone()); lines_changed.added += file.lines_added; lines_changed.removed += file.lines_removed; files.insert(id, file); } let file_tree = FileTree::new(&files); Self { files, file_order, file_tree, lines_changed, commit_message: None, show_list: true, selected_index: 0, needs_update: true, } } fn generate_git_list_items(&self) -> Vec<GitListItem> { let mut items = Vec::new(); // Unstaged header items.push(GitListItem::Header(false)); // Unstaged files self.add_files_to_list(&mut items, false); // Staged header items.push(GitListItem::Header(true)); // Staged files self.add_files_to_list(&mut items, true); items } fn add_files_to_list(&self, items: &mut Vec<GitListItem>, is_staged: bool) { let mut files: Vec<_> = self .files .iter() .filter(|(_, file)| file.staged == is_staged) .collect(); files.sort_by(|(_, a), (_, b)| a.file_path.cmp(&b.file_path)); for (id, file) in files { items.push(GitListItem::File { id: id.clone(), indent_level: 0, }); } } fn count_files_in_directory(&self, path: &str) -> usize { self.files .values() .filter(|file| file.file_path.starts_with(path)) .count() } fn directory_selection(&self, path: &str) -> Selection { let mut all_staged = true; let mut all_unstaged = true; for file in self.files.values() { if file.file_path.starts_with(path) { if file.staged { all_unstaged = false; } else { all_staged = false; } if !all_staged && !all_unstaged { break; } } } if all_staged { Selection::Selected } else if all_unstaged { Selection::Unselected } else { Selection::Indeterminate } } fn changed_file_count(&self) -> usize { self.files.len() } fn unstaged_count(&self) -> usize { self.files.values().filter(|f| !f.staged).count() } fn staged_count(&self) -> usize { self.files.values().filter(|f| f.staged).count() } fn total_item_count(&self) -> usize { if self.show_list { self.changed_file_count() } else { self.changed_file_count() + 2 // +2 for the two headers } } fn no_unstaged(&self) -> bool { self.unstaged_count() == 0 } fn all_unstaged(&self) -> bool { self.staged_count() == 0 } fn no_staged(&self) -> bool { self.staged_count() == 0 } fn no_changes(&self) -> bool { self.changed_file_count() == 0 } fn all_staged(&self) -> bool { self.unstaged_count() == 0 } fn update_lines_changed(&mut self) { self.lines_changed = GitLines { added: self.files.values().map(|f| f.lines_added).sum(), removed: self.files.values().map(|f| f.lines_removed).sum(), }; } fn discard_all(&mut self) { self.files.clear(); self.file_order.clear(); self.update_lines_changed(); self.file_tree = FileTree::new(&self.files); } fn stage_all(&mut self) { for file in self.files.values_mut() { file.staged = true; } self.file_tree = FileTree::new(&self.files); } fn unstage_all(&mut self) { for file in self.files.values_mut() { file.staged = false; } self.file_tree = FileTree::new(&self.files); } fn discard_selected(&mut self) { if self.selected_index > 0 && self.selected_index <= self.file_order.len() { let id = &self.file_order[self.selected_index - 1]; self.files.remove(id); self.file_order.remove(self.selected_index - 1); self.update_lines_changed(); self.file_tree = FileTree::new(&self.files); } } fn stage_selected(&mut self) { if self.selected_index > 0 && self.selected_index <= self.file_order.len() { let id = &self.file_order[self.selected_index - 1]; if let Some(file) = self.files.get_mut(id) { file.staged = true; } self.file_tree = FileTree::new(&self.files); } } fn unstage_selected(&mut self) { if self.selected_index > 0 && self.selected_index <= self.file_order.len() { let id = &self.file_order[self.selected_index - 1]; if let Some(file) = self.files.get_mut(id) { file.staged = false; } self.file_tree = FileTree::new(&self.files); } }} impl FileTree { fn new(files: &HashMap<ChangedFileId, PanelChangedFile>) -> Self { let mut root = BTreeMap::new(); for (id, file) in files { let path_parts: Vec<&str> = file.file_path.split('/').collect(); Self::insert_into_tree(&mut root, &path_parts, id); } Self { root } } fn insert_into_tree( node: &mut BTreeMap<String, FileTreeNode>, path_parts: &[&str], file_id: &ChangedFileId, ) { if path_parts.is_empty() { return; } let current = path_parts[0]; if path_parts.len() == 1 { node.insert(current.to_string(), FileTreeNode::File(file_id.clone())); } else { let entry = node .entry(current.to_string()) .or_insert_with(|| FileTreeNode::Directory(BTreeMap::new())); if let FileTreeNode::Directory(ref mut child_map) = entry { Self::insert_into_tree(child_map, &path_parts[1..], file_id); } } }} #[derive(IntoElement)]pub struct ChangedFileItem { id: ElementId, file: PanelChangedFile, is_selected: bool, indent_level: usize, model: Model<GitPanelState>,} impl ChangedFileItem { fn new( id: impl Into<ElementId>, file: PanelChangedFile, is_selected: bool, indent_level: usize, model: Model<GitPanelState>, ) -> Self { Self { id: id.into(), file, is_selected, indent_level, model, } }} impl RenderOnce for ChangedFileItem { fn render(self, cx: &mut WindowContext) -> impl IntoElement { let file_path = self.file.file_path.clone(); let (icon_name, color) = match self.file.status { GitFileStatus::Added => (IconName::SquarePlus, Color::Custom(ADDED_COLOR)), GitFileStatus::Modified => (IconName::SquareDot, Color::Custom(MODIFIED_COLOR)), GitFileStatus::Conflict => (IconName::SquareMinus, Color::Custom(REMOVED_COLOR)), }; let is_deleted = self.file.status == GitFileStatus::Conflict; let selected_color = cx.theme().status().info.opacity(0.1); h_flex() .id(self.id.clone()) .items_center() .justify_between() .w_full() .map(|this| { if self.is_selected { this.bg(selected_color) } else { this.bg(cx.theme().colors().elevated_surface_background) } }) .hover(|this| this.bg(cx.theme().colors().ghost_element_hover)) .cursor(CursorStyle::PointingHand) .group("") .rounded_sm() .pl(px(12. + (self.indent_level as f32 * 12.))) .pr(px(12.)) .h(px(28.)) .child( h_flex() .gap(px(8.)) .child( Checkbox::new(self.id.clone(), self.file.staged.into()).on_click({ let model = self.model.clone(); let file_path = file_path.clone(); move |_, cx| { let file_path = file_path.clone(); model.update(cx, |state, cx| { let file = state.files.get_mut(&ChangedFileId(file_path.clone())); if let Some(file) = file { file.staged = !file.staged; } state.update_lines_changed(); state.file_tree = FileTree::new(&state.files); state.needs_update = true; cx.notify(); }); } }), ) .child(Icon::new(icon_name).size(IconSize::Small).color(color)) .child( Label::new(file_path.clone()) .strikethrough(is_deleted) .size(LabelSize::Small) .color(if is_deleted { Color::Placeholder } else { Color::Default }), ) .child(render_lines_changed( self.file.lines_added, self.file.lines_removed, cx, )), ) .child( h_flex() .gap_2() .invisible() .group_hover("", |this| this.visible()) .child( IconButton::new("more-menu", IconName::EllipsisVertical) .shape(IconButtonShape::Square) .size(ButtonSize::Compact) .icon_size(IconSize::XSmall) .icon_color(Color::Muted), ), ) }} fn render_lines_changed(added: usize, removed: usize, cx: &WindowContext) -> impl IntoElement { h_flex() .gap_1() .text_size(px(10.)) .font_buffer(cx) .when(added > 0, |this| { this.child(div().text_color(ADDED_COLOR).child(format!("+{}", added))) }) .when(removed > 0, |this| { this.child( div() .text_color(REMOVED_COLOR) .child(format!("-{}", removed)), ) })} #[derive(IntoElement)]pub struct DirectoryItem { id: ElementId, path: String, file_count: usize, is_selected: bool, is_expanded: bool, indent_level: usize, selection: Selection,} impl DirectoryItem { fn new( id: impl Into<ElementId>, path: String, file_count: usize, is_selected: bool, is_expanded: bool, indent_level: usize, selection: Selection, ) -> Self { Self { id: id.into(), path, file_count, is_selected,Lines 1–500 of 1512. Load more
use editor::Editor; use git::repository::GitFileStatus; use gpui::*; use settings::Settings; use std::collections::{BTreeMap, HashMap}; use std::ops::Range; use theme::ThemeSettings; use ui::{prelude::*, Checkbox, DividerColor, ElevationIndex, IconButtonShape}; use ui::{Disclosure, Divider}; use workspace::dock::{DockPosition, Panel, PanelEvent}; use workspace::item::TabContentParams; use workspace::Workspace; pub fn init(cx: &mut AppContext) { cx.observe_new_views(|workspace: &mut Workspace, _| { workspace.register_action(|workspace, _: &ToggleFocus, cx| { workspace.toggle_panel_focus::<GitPanel>(cx); }); }) .detach(); } actions!( git_panel, [ Deploy, DiscardAll, StageAll, DiscardSelected, StageSelected, UnstageSelected, UnstageAll, FilesChanged, ToggleFocus ] ); const ADDED_COLOR: Hsla = Hsla { h: 142. / 360., s: 0.68, l: 0.45, a: 1.0, }; const MODIFIED_COLOR: Hsla = Hsla { h: 48. / 360., s: 0.76, l: 0.47, a: 1.0, }; const REMOVED_COLOR: Hsla = Hsla { h: 355. / 360., s: 0.65, l: 0.65, a: 1.0, }; #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct ChangedFileId(SharedString); #[derive(Debug, Clone)] pub struct PanelChangedFile { pub staged: bool, pub file_path: SharedString, pub lines_added: usize, pub lines_removed: usize, pub status: GitFileStatus, } #[derive(Clone)] pub struct GitLines { pub added: usize, pub removed: usize, } struct FileTree { root: BTreeMap<String, FileTreeNode>, } enum FileTreeNode { File(ChangedFileId), Directory(BTreeMap<String, FileTreeNode>), } enum GitListItem { Header(bool), Divider, Directory { path: String, indent_level: usize, is_expanded: bool, }, File { id: ChangedFileId, indent_level: usize, }, } pub struct GitPanelState { files: HashMap<ChangedFileId, PanelChangedFile>, file_order: Vec<ChangedFileId>, file_tree: FileTree, lines_changed: GitLines, show_list: bool, selected_index: usize, needs_update: bool, commit_message: Option<SharedString>, } impl GitPanelState { fn new(changed_files: Vec<PanelChangedFile>) -> Self { let mut files = HashMap::new(); let mut file_order = Vec::new(); let mut lines_changed = GitLines { added: 0, removed: 0, }; for file in changed_files { let id = ChangedFileId(file.file_path.clone()); file_order.push(id.clone()); lines_changed.added += file.lines_added; lines_changed.removed += file.lines_removed; files.insert(id, file); } let file_tree = FileTree::new(&files); Self { files, file_order, file_tree, lines_changed, commit_message: None, show_list: true, selected_index: 0, needs_update: true, } } fn generate_git_list_items(&self) -> Vec<GitListItem> { let mut items = Vec::new(); // Unstaged header items.push(GitListItem::Header(false)); // Unstaged files self.add_files_to_list(&mut items, false); // Staged header items.push(GitListItem::Header(true)); // Staged files self.add_files_to_list(&mut items, true); items } fn add_files_to_list(&self, items: &mut Vec<GitListItem>, is_staged: bool) { let mut files: Vec<_> = self .files .iter() .filter(|(_, file)| file.staged == is_staged) .collect(); files.sort_by(|(_, a), (_, b)| a.file_path.cmp(&b.file_path)); for (id, file) in files { items.push(GitListItem::File { id: id.clone(), indent_level: 0, }); } } fn count_files_in_directory(&self, path: &str) -> usize { self.files .values() .filter(|file| file.file_path.starts_with(path)) .count() } fn directory_selection(&self, path: &str) -> Selection { let mut all_staged = true; let mut all_unstaged = true; for file in self.files.values() { if file.file_path.starts_with(path) { if file.staged { all_unstaged = false; } else { all_staged = false; } if !all_staged && !all_unstaged { break; } } } if all_staged { Selection::Selected } else if all_unstaged { Selection::Unselected } else { Selection::Indeterminate } } fn changed_file_count(&self) -> usize { self.files.len() } fn unstaged_count(&self) -> usize { self.files.values().filter(|f| !f.staged).count() } fn staged_count(&self) -> usize { self.files.values().filter(|f| f.staged).count() } fn total_item_count(&self) -> usize { if self.show_list { self.changed_file_count() } else { self.changed_file_count() + 2 // +2 for the two headers } } fn no_unstaged(&self) -> bool { self.unstaged_count() == 0 } fn all_unstaged(&self) -> bool { self.staged_count() == 0 } fn no_staged(&self) -> bool { self.staged_count() == 0 } fn no_changes(&self) -> bool { self.changed_file_count() == 0 } fn all_staged(&self) -> bool { self.unstaged_count() == 0 } fn update_lines_changed(&mut self) { self.lines_changed = GitLines { added: self.files.values().map(|f| f.lines_added).sum(), removed: self.files.values().map(|f| f.lines_removed).sum(), }; } fn discard_all(&mut self) { self.files.clear(); self.file_order.clear(); self.update_lines_changed(); self.file_tree = FileTree::new(&self.files); } fn stage_all(&mut self) { for file in self.files.values_mut() { file.staged = true; } self.file_tree = FileTree::new(&self.files); } fn unstage_all(&mut self) { for file in self.files.values_mut() { file.staged = false; } self.file_tree = FileTree::new(&self.files); } fn discard_selected(&mut self) { if self.selected_index > 0 && self.selected_index <= self.file_order.len() { let id = &self.file_order[self.selected_index - 1]; self.files.remove(id); self.file_order.remove(self.selected_index - 1); self.update_lines_changed(); self.file_tree = FileTree::new(&self.files); } } fn stage_selected(&mut self) { if self.selected_index > 0 && self.selected_index <= self.file_order.len() { let id = &self.file_order[self.selected_index - 1]; if let Some(file) = self.files.get_mut(id) { file.staged = true; } self.file_tree = FileTree::new(&self.files); } } fn unstage_selected(&mut self) { if self.selected_index > 0 && self.selected_index <= self.file_order.len() { let id = &self.file_order[self.selected_index - 1]; if let Some(file) = self.files.get_mut(id) { file.staged = false; } self.file_tree = FileTree::new(&self.files); } } } impl FileTree { fn new(files: &HashMap<ChangedFileId, PanelChangedFile>) -> Self { let mut root = BTreeMap::new(); for (id, file) in files { let path_parts: Vec<&str> = file.file_path.split('/').collect(); Self::insert_into_tree(&mut root, &path_parts, id); } Self { root } } fn insert_into_tree( node: &mut BTreeMap<String, FileTreeNode>, path_parts: &[&str], file_id: &ChangedFileId, ) { if path_parts.is_empty() { return; } let current = path_parts[0]; if path_parts.len() == 1 { node.insert(current.to_string(), FileTreeNode::File(file_id.clone())); } else { let entry = node .entry(current.to_string()) .or_insert_with(|| FileTreeNode::Directory(BTreeMap::new())); if let FileTreeNode::Directory(ref mut child_map) = entry { Self::insert_into_tree(child_map, &path_parts[1..], file_id); } } } } #[derive(IntoElement)] pub struct ChangedFileItem { id: ElementId, file: PanelChangedFile, is_selected: bool, indent_level: usize, model: Model<GitPanelState>, } impl ChangedFileItem { fn new( id: impl Into<ElementId>, file: PanelChangedFile, is_selected: bool, indent_level: usize, model: Model<GitPanelState>, ) -> Self { Self { id: id.into(), file, is_selected, indent_level, model, } } } impl RenderOnce for ChangedFileItem { fn render(self, cx: &mut WindowContext) -> impl IntoElement { let file_path = self.file.file_path.clone(); let (icon_name, color) = match self.file.status { GitFileStatus::Added => (IconName::SquarePlus, Color::Custom(ADDED_COLOR)), GitFileStatus::Modified => (IconName::SquareDot, Color::Custom(MODIFIED_COLOR)), GitFileStatus::Conflict => (IconName::SquareMinus, Color::Custom(REMOVED_COLOR)), }; let is_deleted = self.file.status == GitFileStatus::Conflict; let selected_color = cx.theme().status().info.opacity(0.1); h_flex() .id(self.id.clone()) .items_center() .justify_between() .w_full() .map(|this| { if self.is_selected { this.bg(selected_color) } else { this.bg(cx.theme().colors().elevated_surface_background) } }) .hover(|this| this.bg(cx.theme().colors().ghost_element_hover)) .cursor(CursorStyle::PointingHand) .group("") .rounded_sm() .pl(px(12. + (self.indent_level as f32 * 12.))) .pr(px(12.)) .h(px(28.)) .child( h_flex() .gap(px(8.)) .child( Checkbox::new(self.id.clone(), self.file.staged.into()).on_click({ let model = self.model.clone(); let file_path = file_path.clone(); move |_, cx| { let file_path = file_path.clone(); model.update(cx, |state, cx| { let file = state.files.get_mut(&ChangedFileId(file_path.clone())); if let Some(file) = file { file.staged = !file.staged; } state.update_lines_changed(); state.file_tree = FileTree::new(&state.files); state.needs_update = true; cx.notify(); }); } }), ) .child(Icon::new(icon_name).size(IconSize::Small).color(color)) .child( Label::new(file_path.clone()) .strikethrough(is_deleted) .size(LabelSize::Small) .color(if is_deleted { Color::Placeholder } else { Color::Default }), ) .child(render_lines_changed( self.file.lines_added, self.file.lines_removed, cx, )), ) .child( h_flex() .gap_2() .invisible() .group_hover("", |this| this.visible()) .child( IconButton::new("more-menu", IconName::EllipsisVertical) .shape(IconButtonShape::Square) .size(ButtonSize::Compact) .icon_size(IconSize::XSmall) .icon_color(Color::Muted), ), ) } } fn render_lines_changed(added: usize, removed: usize, cx: &WindowContext) -> impl IntoElement { h_flex() .gap_1() .text_size(px(10.)) .font_buffer(cx) .when(added > 0, |this| { this.child(div().text_color(ADDED_COLOR).child(format!("+{}", added))) }) .when(removed > 0, |this| { this.child( div() .text_color(REMOVED_COLOR) .child(format!("-{}", removed)), ) }) } #[derive(IntoElement)] pub struct DirectoryItem { id: ElementId, path: String, file_count: usize, is_selected: bool, is_expanded: bool, indent_level: usize, selection: Selection, } impl DirectoryItem { fn new( id: impl Into<ElementId>, path: String, file_count: usize, is_selected: bool, is_expanded: bool, indent_level: usize, selection: Selection, ) -> Self { Self { id: id.into(), path, file_count, is_selected,