use editor::Editor;use git::repository::GitFileStatus;use gpui::*;use ui::{prelude::*, ElevationIndex, IconButtonShape};use ui::{Disclosure, Divider};use workspace::item::TabContentParams;use workspace::{item::ItemHandle, StatusItemView, ToolbarItemEvent, Workspace}; pub mod git_panel; actions!( vcs_status, [ Deploy, DiscardAll, StageAll, DiscardSelected, StageSelected, UnstageSelected, UnstageAll, FilesChanged ]); #[derive(Debug, Clone)]pub struct ChangedFile { pub staged: bool, pub file_path: SharedString, pub lines_added: usize, pub lines_removed: usize, pub status: GitFileStatus,} pub struct GitLines { pub added: usize, pub removed: usize,} #[derive(IntoElement)]pub struct ChangedFileHeader { id: ElementId, file: ChangedFile, is_selected: bool,} impl ChangedFileHeader { fn new(id: impl Into<ElementId>, file: ChangedFile, is_selected: bool) -> Self { Self { id: id.into(), file, is_selected, } } fn icon_for_status(&self) -> impl IntoElement { let (icon_name, color) = match self.file.status { GitFileStatus::Added => (IconName::SquarePlus, Color::Created), GitFileStatus::Modified => (IconName::SquareDot, Color::Modified), GitFileStatus::Conflict => (IconName::SquareMinus, Color::Conflict), }; Icon::new(icon_name).size(IconSize::Small).color(color) }} impl RenderOnce for ChangedFileHeader { fn render(self, cx: &mut WindowContext) -> impl IntoElement { let disclosure_id = ElementId::Name(format!("{}-file-disclosure", self.id.clone()).into()); let file_path = self.file.file_path.clone(); h_flex() .id(self.id.clone()) .justify_between() .w_full() .when(!self.is_selected, |this| { this.hover(|this| this.bg(cx.theme().colors().ghost_element_hover)) }) .cursor(CursorStyle::PointingHand) .when(self.is_selected, |this| { this.bg(cx.theme().colors().ghost_element_active) }) .group("") .rounded_sm() .px_2() .py_1p5() .child( h_flex() .gap_2() .child(Disclosure::new(disclosure_id, false)) .child(self.icon_for_status()) .child(Label::new(file_path).size(LabelSize::Small)) .child( h_flex() .gap_1() .when(self.file.lines_added > 0, |this| { this.child( Label::new(format!("+{}", self.file.lines_added)) .color(Color::Created) .size(LabelSize::Small), ) }) .when(self.file.lines_removed > 0, |this| { this.child( Label::new(format!("-{}", self.file.lines_removed)) .color(Color::Deleted) .size(LabelSize::Small), ) }), ), ) .child( h_flex() .gap_2() .child( IconButton::new("more-menu", IconName::EllipsisVertical) .shape(IconButtonShape::Square) .size(ButtonSize::Compact) .icon_size(IconSize::XSmall) .icon_color(Color::Muted), ) .child( IconButton::new("remove-file", IconName::X) .shape(IconButtonShape::Square) .size(ButtonSize::Compact) .icon_size(IconSize::XSmall) .icon_color(Color::Error) .style(ButtonStyle::Filled) .layer(ElevationIndex::Background) .on_click(move |_, cx| cx.dispatch_action(Box::new(DiscardSelected))), ) .child( IconButton::new("check-file", IconName::Check) .shape(IconButtonShape::Square) .size(ButtonSize::Compact) .icon_size(IconSize::XSmall) .icon_color(Color::Accent) .style(ButtonStyle::Filled) .layer(ElevationIndex::Background) .on_click(move |_, cx| { if self.file.staged { cx.dispatch_action(Box::new(UnstageSelected)) } else { cx.dispatch_action(Box::new(StageSelected)) } }), ), ) }} #[derive(IntoElement)]pub struct GitProjectOverview { id: ElementId, project_status: Model<GitProjectStatus>,} impl GitProjectOverview { pub fn new(id: impl Into<ElementId>, project_status: Model<GitProjectStatus>) -> Self { Self { id: id.into(), project_status, } } pub fn toggle_file_list(&self, cx: &mut WindowContext) { self.project_status.update(cx, |status, cx| { status.show_list = !status.show_list; cx.notify(); }); }} impl RenderOnce for GitProjectOverview { fn render(self, cx: &mut WindowContext) -> impl IntoElement { let status = self.project_status.read(cx); let changed_files: SharedString = format!("{} Changed files", status.changed_file_count()).into(); let added_label: Option<SharedString> = (status.lines_changed.added > 0) .then(|| format!("+{}", status.lines_changed.added).into()); let removed_label: Option<SharedString> = (status.lines_changed.removed > 0) .then(|| format!("-{}", status.lines_changed.removed).into()); let total_label: SharedString = "total lines changed".into(); h_flex() .id(self.id.clone()) .w_full() .bg(cx.theme().colors().elevated_surface_background) .px_2() .py_2p5() .gap_2() .child( IconButton::new("open-sidebar", IconName::PanelLeft) .selected(self.project_status.read(cx).show_list) .icon_color(Color::Muted) .on_click(move |_, cx| self.toggle_file_list(cx)), ) .child( h_flex() .gap_4() .child(Label::new(changed_files).size(LabelSize::Small)) .child( h_flex() .gap_1() .when(added_label.is_some(), |this| { this.child( Label::new(added_label.unwrap()) .color(Color::Created) .size(LabelSize::Small), ) }) .when(removed_label.is_some(), |this| { this.child( Label::new(removed_label.unwrap()) .color(Color::Deleted) .size(LabelSize::Small), ) }) .child( Label::new(total_label) .color(Color::Muted) .size(LabelSize::Small), ), ), ) }} #[derive(IntoElement)]pub struct GitStagingControls { id: ElementId, project_status: Model<GitProjectStatus>, is_staged: bool, is_selected: bool,} impl GitStagingControls { pub fn new( id: impl Into<ElementId>, project_status: Model<GitProjectStatus>, is_staged: bool, is_selected: bool, ) -> Self { Self { id: id.into(), project_status, is_staged, is_selected, } }} impl RenderOnce for GitStagingControls { fn render(self, cx: &mut WindowContext) -> impl IntoElement { let status = self.project_status.read(cx); let (staging_type, count) = if self.is_staged { ("Staged", status.staged_count()) } else { ("Unstaged", status.unstaged_count()) }; let is_expanded = if self.is_staged { status.staged_expanded } else { status.unstaged_expanded }; let label: SharedString = format!("{} Changes: {}", staging_type, count).into(); h_flex() .id(self.id.clone()) .hover(|this| this.bg(cx.theme().colors().ghost_element_hover)) .on_click(move |_, cx| { self.project_status.update(cx, |status, cx| { if self.is_staged { status.staged_expanded = !status.staged_expanded; } else { status.unstaged_expanded = !status.unstaged_expanded; } cx.notify(); }) }) .justify_between() .w_full() .map(|this| { if self.is_selected { this.bg(cx.theme().colors().ghost_element_active) } else { this.bg(cx.theme().colors().elevated_surface_background) } }) .px_3() .py_2() .child( h_flex() .gap_2() .child(Disclosure::new(self.id.clone(), is_expanded)) .child(Label::new(label).size(LabelSize::Small)), ) .child(h_flex().gap_2().map(|this| { if !self.is_staged { this.child( Button::new( ElementId::Name(format!("{}-discard", self.id.clone()).into()), "Discard All", ) .style(ButtonStyle::Filled) .layer(ui::ElevationIndex::ModalSurface) .size(ButtonSize::Compact) .label_size(LabelSize::Small) .icon(IconName::X) .icon_position(IconPosition::Start) .icon_color(Color::Muted) .disabled(status.changed_file_count() == 0) .on_click(move |_, cx| cx.dispatch_action(Box::new(DiscardAll))), ) .child( Button::new( ElementId::Name(format!("{}-stage", self.id.clone()).into()), "Stage All", ) .style(ButtonStyle::Filled) .size(ButtonSize::Compact) .label_size(LabelSize::Small) .layer(ui::ElevationIndex::ModalSurface) .icon(IconName::Check) .icon_position(IconPosition::Start) .icon_color(Color::Muted) .disabled(status.no_unstaged()) .on_click(move |_, cx| cx.dispatch_action(Box::new(StageAll))), ) } else { this.child( Button::new( ElementId::Name(format!("{}-unstage", self.id.clone()).into()), "Unstage All", ) .layer(ui::ElevationIndex::ModalSurface) .icon(IconName::Check) .icon_position(IconPosition::Start) .icon_color(Color::Muted) .disabled(status.no_staged()) .on_click(move |_, cx| cx.dispatch_action(Box::new(UnstageAll))), ) } })) }} pub struct GitProjectStatus { unstaged_files: Vec<ChangedFile>, staged_files: Vec<ChangedFile>, lines_changed: GitLines, staged_expanded: bool, unstaged_expanded: bool, show_list: bool, selected_index: usize,} impl GitProjectStatus { fn new(changed_files: Vec<ChangedFile>) -> Self { let (unstaged_files, staged_files): (Vec<_>, Vec<_>) = changed_files.into_iter().partition(|f| !f.staged); let lines_changed = GitLines { added: unstaged_files .iter() .chain(staged_files.iter()) .map(|f| f.lines_added) .sum(), removed: unstaged_files .iter() .chain(staged_files.iter()) .map(|f| f.lines_removed) .sum(), }; Self { unstaged_files, staged_files, lines_changed, staged_expanded: true, unstaged_expanded: true, show_list: false, selected_index: 0, } } fn changed_file_count(&self) -> usize { self.unstaged_files.len() + self.staged_files.len() } fn unstaged_count(&self) -> usize { self.unstaged_files.len() } fn staged_count(&self) -> usize { self.staged_files.len() } fn total_item_count(&self) -> usize { self.changed_file_count() + 2 // +2 for the two controls } fn no_unstaged(&self) -> bool { self.unstaged_files.is_empty() } fn all_unstaged(&self) -> bool { self.staged_files.is_empty() } fn no_staged(&self) -> bool { self.staged_files.is_empty() } fn all_staged(&self) -> bool { self.unstaged_files.is_empty() } fn update_lines_changed(&mut self) { self.lines_changed = GitLines { added: self .unstaged_files .iter() .chain(self.staged_files.iter()) .map(|f| f.lines_added) .sum(), removed: self .unstaged_files .iter() .chain(self.staged_files.iter()) .map(|f| f.lines_removed) .sum(), }; } fn discard_all(&mut self) { self.unstaged_files.clear(); self.staged_files.clear(); self.update_lines_changed(); } fn stage_all(&mut self) { self.staged_files.extend(self.unstaged_files.drain(..)); self.update_lines_changed(); } fn unstage_all(&mut self) { self.unstaged_files.extend(self.staged_files.drain(..)); self.update_lines_changed(); } fn discard_selected(&mut self) { let total_len = self.unstaged_files.len() + self.staged_files.len(); if self.selected_index > 0 && self.selected_index <= total_len { if self.selected_index <= self.unstaged_files.len() { self.unstaged_files.remove(self.selected_index - 1); } else { self.staged_files .remove(self.selected_index - 1 - self.unstaged_files.len()); } self.update_lines_changed(); } } fn stage_selected(&mut self) { if self.selected_index > 0 && self.selected_index <= self.unstaged_files.len() { let file = self.unstaged_files.remove(self.selected_index - 1); self.staged_files.push(file); self.update_lines_changed(); } } fn unstage_selected(&mut self) { let unstaged_len = self.unstaged_files.len(); if self.selected_index > unstaged_len && self.selected_index <= self.total_item_count() - 2 { let file = self .staged_files .remove(self.selected_index - 1 - unstaged_len); self.unstaged_files.push(file); self.update_lines_changed(); } }} #[derive(Clone)]pub struct ProjectStatusTab { id: ElementId, focus_handle: FocusHandle, status: Model<GitProjectStatus>, list_state: ListState,} impl ProjectStatusTab { pub fn new(id: impl Into<ElementId>, cx: &mut ViewContext<Self>) -> Self { let changed_files = static_changed_files();Lines 1–500 of 949. Load more
use editor::Editor; use git::repository::GitFileStatus; use gpui::*; use ui::{prelude::*, ElevationIndex, IconButtonShape}; use ui::{Disclosure, Divider}; use workspace::item::TabContentParams; use workspace::{item::ItemHandle, StatusItemView, ToolbarItemEvent, Workspace}; pub mod git_panel; actions!( vcs_status, [ Deploy, DiscardAll, StageAll, DiscardSelected, StageSelected, UnstageSelected, UnstageAll, FilesChanged ] ); #[derive(Debug, Clone)] pub struct ChangedFile { pub staged: bool, pub file_path: SharedString, pub lines_added: usize, pub lines_removed: usize, pub status: GitFileStatus, } pub struct GitLines { pub added: usize, pub removed: usize, } #[derive(IntoElement)] pub struct ChangedFileHeader { id: ElementId, file: ChangedFile, is_selected: bool, } impl ChangedFileHeader { fn new(id: impl Into<ElementId>, file: ChangedFile, is_selected: bool) -> Self { Self { id: id.into(), file, is_selected, } } fn icon_for_status(&self) -> impl IntoElement { let (icon_name, color) = match self.file.status { GitFileStatus::Added => (IconName::SquarePlus, Color::Created), GitFileStatus::Modified => (IconName::SquareDot, Color::Modified), GitFileStatus::Conflict => (IconName::SquareMinus, Color::Conflict), }; Icon::new(icon_name).size(IconSize::Small).color(color) } } impl RenderOnce for ChangedFileHeader { fn render(self, cx: &mut WindowContext) -> impl IntoElement { let disclosure_id = ElementId::Name(format!("{}-file-disclosure", self.id.clone()).into()); let file_path = self.file.file_path.clone(); h_flex() .id(self.id.clone()) .justify_between() .w_full() .when(!self.is_selected, |this| { this.hover(|this| this.bg(cx.theme().colors().ghost_element_hover)) }) .cursor(CursorStyle::PointingHand) .when(self.is_selected, |this| { this.bg(cx.theme().colors().ghost_element_active) }) .group("") .rounded_sm() .px_2() .py_1p5() .child( h_flex() .gap_2() .child(Disclosure::new(disclosure_id, false)) .child(self.icon_for_status()) .child(Label::new(file_path).size(LabelSize::Small)) .child( h_flex() .gap_1() .when(self.file.lines_added > 0, |this| { this.child( Label::new(format!("+{}", self.file.lines_added)) .color(Color::Created) .size(LabelSize::Small), ) }) .when(self.file.lines_removed > 0, |this| { this.child( Label::new(format!("-{}", self.file.lines_removed)) .color(Color::Deleted) .size(LabelSize::Small), ) }), ), ) .child( h_flex() .gap_2() .child( IconButton::new("more-menu", IconName::EllipsisVertical) .shape(IconButtonShape::Square) .size(ButtonSize::Compact) .icon_size(IconSize::XSmall) .icon_color(Color::Muted), ) .child( IconButton::new("remove-file", IconName::X) .shape(IconButtonShape::Square) .size(ButtonSize::Compact) .icon_size(IconSize::XSmall) .icon_color(Color::Error) .style(ButtonStyle::Filled) .layer(ElevationIndex::Background) .on_click(move |_, cx| cx.dispatch_action(Box::new(DiscardSelected))), ) .child( IconButton::new("check-file", IconName::Check) .shape(IconButtonShape::Square) .size(ButtonSize::Compact) .icon_size(IconSize::XSmall) .icon_color(Color::Accent) .style(ButtonStyle::Filled) .layer(ElevationIndex::Background) .on_click(move |_, cx| { if self.file.staged { cx.dispatch_action(Box::new(UnstageSelected)) } else { cx.dispatch_action(Box::new(StageSelected)) } }), ), ) } } #[derive(IntoElement)] pub struct GitProjectOverview { id: ElementId, project_status: Model<GitProjectStatus>, } impl GitProjectOverview { pub fn new(id: impl Into<ElementId>, project_status: Model<GitProjectStatus>) -> Self { Self { id: id.into(), project_status, } } pub fn toggle_file_list(&self, cx: &mut WindowContext) { self.project_status.update(cx, |status, cx| { status.show_list = !status.show_list; cx.notify(); }); } } impl RenderOnce for GitProjectOverview { fn render(self, cx: &mut WindowContext) -> impl IntoElement { let status = self.project_status.read(cx); let changed_files: SharedString = format!("{} Changed files", status.changed_file_count()).into(); let added_label: Option<SharedString> = (status.lines_changed.added > 0) .then(|| format!("+{}", status.lines_changed.added).into()); let removed_label: Option<SharedString> = (status.lines_changed.removed > 0) .then(|| format!("-{}", status.lines_changed.removed).into()); let total_label: SharedString = "total lines changed".into(); h_flex() .id(self.id.clone()) .w_full() .bg(cx.theme().colors().elevated_surface_background) .px_2() .py_2p5() .gap_2() .child( IconButton::new("open-sidebar", IconName::PanelLeft) .selected(self.project_status.read(cx).show_list) .icon_color(Color::Muted) .on_click(move |_, cx| self.toggle_file_list(cx)), ) .child( h_flex() .gap_4() .child(Label::new(changed_files).size(LabelSize::Small)) .child( h_flex() .gap_1() .when(added_label.is_some(), |this| { this.child( Label::new(added_label.unwrap()) .color(Color::Created) .size(LabelSize::Small), ) }) .when(removed_label.is_some(), |this| { this.child( Label::new(removed_label.unwrap()) .color(Color::Deleted) .size(LabelSize::Small), ) }) .child( Label::new(total_label) .color(Color::Muted) .size(LabelSize::Small), ), ), ) } } #[derive(IntoElement)] pub struct GitStagingControls { id: ElementId, project_status: Model<GitProjectStatus>, is_staged: bool, is_selected: bool, } impl GitStagingControls { pub fn new( id: impl Into<ElementId>, project_status: Model<GitProjectStatus>, is_staged: bool, is_selected: bool, ) -> Self { Self { id: id.into(), project_status, is_staged, is_selected, } } } impl RenderOnce for GitStagingControls { fn render(self, cx: &mut WindowContext) -> impl IntoElement { let status = self.project_status.read(cx); let (staging_type, count) = if self.is_staged { ("Staged", status.staged_count()) } else { ("Unstaged", status.unstaged_count()) }; let is_expanded = if self.is_staged { status.staged_expanded } else { status.unstaged_expanded }; let label: SharedString = format!("{} Changes: {}", staging_type, count).into(); h_flex() .id(self.id.clone()) .hover(|this| this.bg(cx.theme().colors().ghost_element_hover)) .on_click(move |_, cx| { self.project_status.update(cx, |status, cx| { if self.is_staged { status.staged_expanded = !status.staged_expanded; } else { status.unstaged_expanded = !status.unstaged_expanded; } cx.notify(); }) }) .justify_between() .w_full() .map(|this| { if self.is_selected { this.bg(cx.theme().colors().ghost_element_active) } else { this.bg(cx.theme().colors().elevated_surface_background) } }) .px_3() .py_2() .child( h_flex() .gap_2() .child(Disclosure::new(self.id.clone(), is_expanded)) .child(Label::new(label).size(LabelSize::Small)), ) .child(h_flex().gap_2().map(|this| { if !self.is_staged { this.child( Button::new( ElementId::Name(format!("{}-discard", self.id.clone()).into()), "Discard All", ) .style(ButtonStyle::Filled) .layer(ui::ElevationIndex::ModalSurface) .size(ButtonSize::Compact) .label_size(LabelSize::Small) .icon(IconName::X) .icon_position(IconPosition::Start) .icon_color(Color::Muted) .disabled(status.changed_file_count() == 0) .on_click(move |_, cx| cx.dispatch_action(Box::new(DiscardAll))), ) .child( Button::new( ElementId::Name(format!("{}-stage", self.id.clone()).into()), "Stage All", ) .style(ButtonStyle::Filled) .size(ButtonSize::Compact) .label_size(LabelSize::Small) .layer(ui::ElevationIndex::ModalSurface) .icon(IconName::Check) .icon_position(IconPosition::Start) .icon_color(Color::Muted) .disabled(status.no_unstaged()) .on_click(move |_, cx| cx.dispatch_action(Box::new(StageAll))), ) } else { this.child( Button::new( ElementId::Name(format!("{}-unstage", self.id.clone()).into()), "Unstage All", ) .layer(ui::ElevationIndex::ModalSurface) .icon(IconName::Check) .icon_position(IconPosition::Start) .icon_color(Color::Muted) .disabled(status.no_staged()) .on_click(move |_, cx| cx.dispatch_action(Box::new(UnstageAll))), ) } })) } } pub struct GitProjectStatus { unstaged_files: Vec<ChangedFile>, staged_files: Vec<ChangedFile>, lines_changed: GitLines, staged_expanded: bool, unstaged_expanded: bool, show_list: bool, selected_index: usize, } impl GitProjectStatus { fn new(changed_files: Vec<ChangedFile>) -> Self { let (unstaged_files, staged_files): (Vec<_>, Vec<_>) = changed_files.into_iter().partition(|f| !f.staged); let lines_changed = GitLines { added: unstaged_files .iter() .chain(staged_files.iter()) .map(|f| f.lines_added) .sum(), removed: unstaged_files .iter() .chain(staged_files.iter()) .map(|f| f.lines_removed) .sum(), }; Self { unstaged_files, staged_files, lines_changed, staged_expanded: true, unstaged_expanded: true, show_list: false, selected_index: 0, } } fn changed_file_count(&self) -> usize { self.unstaged_files.len() + self.staged_files.len() } fn unstaged_count(&self) -> usize { self.unstaged_files.len() } fn staged_count(&self) -> usize { self.staged_files.len() } fn total_item_count(&self) -> usize { self.changed_file_count() + 2 // +2 for the two controls } fn no_unstaged(&self) -> bool { self.unstaged_files.is_empty() } fn all_unstaged(&self) -> bool { self.staged_files.is_empty() } fn no_staged(&self) -> bool { self.staged_files.is_empty() } fn all_staged(&self) -> bool { self.unstaged_files.is_empty() } fn update_lines_changed(&mut self) { self.lines_changed = GitLines { added: self .unstaged_files .iter() .chain(self.staged_files.iter()) .map(|f| f.lines_added) .sum(), removed: self .unstaged_files .iter() .chain(self.staged_files.iter()) .map(|f| f.lines_removed) .sum(), }; } fn discard_all(&mut self) { self.unstaged_files.clear(); self.staged_files.clear(); self.update_lines_changed(); } fn stage_all(&mut self) { self.staged_files.extend(self.unstaged_files.drain(..)); self.update_lines_changed(); } fn unstage_all(&mut self) { self.unstaged_files.extend(self.staged_files.drain(..)); self.update_lines_changed(); } fn discard_selected(&mut self) { let total_len = self.unstaged_files.len() + self.staged_files.len(); if self.selected_index > 0 && self.selected_index <= total_len { if self.selected_index <= self.unstaged_files.len() { self.unstaged_files.remove(self.selected_index - 1); } else { self.staged_files .remove(self.selected_index - 1 - self.unstaged_files.len()); } self.update_lines_changed(); } } fn stage_selected(&mut self) { if self.selected_index > 0 && self.selected_index <= self.unstaged_files.len() { let file = self.unstaged_files.remove(self.selected_index - 1); self.staged_files.push(file); self.update_lines_changed(); } } fn unstage_selected(&mut self) { let unstaged_len = self.unstaged_files.len(); if self.selected_index > unstaged_len && self.selected_index <= self.total_item_count() - 2 { let file = self .staged_files .remove(self.selected_index - 1 - unstaged_len); self.unstaged_files.push(file); self.update_lines_changed(); } } } #[derive(Clone)] pub struct ProjectStatusTab { id: ElementId, focus_handle: FocusHandle, status: Model<GitProjectStatus>, list_state: ListState, } impl ProjectStatusTab { pub fn new(id: impl Into<ElementId>, cx: &mut ViewContext<Self>) -> Self { let changed_files = static_changed_files();