diff --git a/cli/src/commands/config/subcommands/edit.rs b/cli/src/commands/config/subcommands/edit.rs index d8f00da..cacbbae 100644 --- a/cli/src/commands/config/subcommands/edit.rs +++ b/cli/src/commands/config/subcommands/edit.rs @@ -11,7 +11,7 @@ use crate::extensions::{LocationSelection, WithLocations}; /// Implements the `edit` subcommand for Meva configuration management. /// /// Opens the chosen configuration file in the user's preferred editor, -/// respecting any `core.editor` override in config or falling back to OS defaults. +/// respecting any `editor.default` override in config or falling back to OS defaults. #[derive(Default)] pub struct ConfigEditCommand; @@ -45,7 +45,7 @@ impl MevaCommand for ConfigEditCommand { _container: &Self::Container, ) -> miette::Result<()> { let loader = MevaConfigLoader::default(); - let override_cmd = loader.get("core.editor", None).ok(); + let override_cmd = loader.get("editor.default", None).ok(); let location = matches .get_config_location() .get_default_path() diff --git a/cli/src/commands/ignore/subcommands/edit.rs b/cli/src/commands/ignore/subcommands/edit.rs index 350c896..579bbaa 100644 --- a/cli/src/commands/ignore/subcommands/edit.rs +++ b/cli/src/commands/ignore/subcommands/edit.rs @@ -14,7 +14,7 @@ use crate::{commands::MevaCommand, extensions::WithFile}; /// Implements the `edit` subcommand for Meva ignored files management. /// /// Opens the chosen configuration file in the user's preferred editor, -/// respecting any `core.editor` override in config or falling back to OS defaults. +/// respecting any `editor.default` override in config or falling back to OS defaults. #[derive(Default)] pub struct IgnoreEditCommand; @@ -50,7 +50,7 @@ impl MevaCommand for IgnoreEditCommand { let ignore_service = IgnoreService::new(layout.ignore_file_name()); let loader = MevaConfigLoader::default(); - let override_cmd = loader.get("core.editor", None).ok(); + let override_cmd = loader.get("editor.default", None).ok(); let ignore_file = match file { Some(p) => p.to_path_buf(), diff --git a/cli/src/commands/plugins/subcommands/edit.rs b/cli/src/commands/plugins/subcommands/edit.rs index 7818f3e..c99fcfd 100644 --- a/cli/src/commands/plugins/subcommands/edit.rs +++ b/cli/src/commands/plugins/subcommands/edit.rs @@ -94,7 +94,7 @@ impl MevaCommand for PluginsEditCommand { if enabled.is_none() { let loader = MevaConfigLoader::default(); - let override_cmd = loader.get("core.editor", None).ok(); + let override_cmd = loader.get("editor.default", None).ok(); response .source_file .open_in_editor(override_cmd) diff --git a/engine/src/handlers/status/handlers.rs b/engine/src/handlers/status/handlers.rs index 814505d..cec2e20 100644 --- a/engine/src/handlers/status/handlers.rs +++ b/engine/src/handlers/status/handlers.rs @@ -160,6 +160,7 @@ impl StatusHandler { ahead: 0, behind: 0, }, + // TODO: Fetch real upstream/ahead/behind info HeadMode::Symbolic => BranchInfo { head: head.extract_branch_name(), is_detached: false, diff --git a/gui/src/main.rs b/gui/src/main.rs index d70c927..b336f42 100644 --- a/gui/src/main.rs +++ b/gui/src/main.rs @@ -19,7 +19,7 @@ fn configure_frame_options() -> eframe::NativeOptions { eframe::NativeOptions { viewport: egui::ViewportBuilder::default() - .with_inner_size([800.0, 600.0]) + .with_inner_size([1200.0, 800.0]) .with_icon(icon), centered: true, ..Default::default() diff --git a/gui/src/meva_gui.rs b/gui/src/meva_gui.rs index 2818172..5fd92ca 100644 --- a/gui/src/meva_gui.rs +++ b/gui/src/meva_gui.rs @@ -221,8 +221,18 @@ impl MevaGui { if let Some(status) = &self.repository_status { ui.horizontal(|ui| { - RefreshStatusButton::new(&mut self.async_worker, &self.container) - .show(ui, ctx); + let has_unmerged = status + .unmerged + .as_ref() + .map(|v| !v.is_empty()) + .unwrap_or(false); + + MoreActionsButton::new( + &mut self.async_worker, + &self.container, + has_unmerged, + ) + .show(ui, ctx); ui.with_layout( egui::Layout::top_down_justified(egui::Align::Center), |ui| { diff --git a/gui/src/ui/components/buttons.rs b/gui/src/ui/components/buttons.rs index ea83471..684afbb 100644 --- a/gui/src/ui/components/buttons.rs +++ b/gui/src/ui/components/buttons.rs @@ -1,5 +1,6 @@ mod commit_button; mod icon_button; +mod more_actions_button; mod navigation_button; mod open_repository_button; mod refresh_status_button; @@ -8,6 +9,7 @@ mod theme_button; pub use commit_button::CommitButton; pub use icon_button::IconButton; +pub use more_actions_button::MoreActionsButton; pub use navigation_button::NavigationButton; pub use open_repository_button::OpenRepositoryButton; pub use refresh_status_button::RefreshStatusButton; diff --git a/gui/src/ui/components/buttons/more_actions_button.rs b/gui/src/ui/components/buttons/more_actions_button.rs new file mode 100644 index 0000000..a66b2c4 --- /dev/null +++ b/gui/src/ui/components/buttons/more_actions_button.rs @@ -0,0 +1,170 @@ +use std::{sync::Arc, thread}; + +use egui::{RichText, Ui}; +use egui_phosphor::regular as icons; + +use engine::{ + EngineContainer, + engine_container::MevaContainer, + handlers::{ + // merge::Request as MergeRequest, + branch::{BranchOperations, ListRequest, Request as BranchRequest}, + status::Request as StatusRequest, + }, +}; + +use crate::events::{AsyncWorker, EventError, WorkerEvent, WorkerResult}; + +/// A UI component providing a dropdown menu for secondary repository actions. +/// +/// Contains operations like: +/// - **Abort Merge**: Available only during a merge conflict. +/// - **Refresh Status**: Forces a re-scan of the working directory. +pub struct MoreActionsButton<'a> { + worker: &'a mut AsyncWorker, + container: &'a Arc, + has_unmerged_changes: bool, +} + +impl<'a> MoreActionsButton<'a> { + /// Creates a new [`MoreActionsButton`]. + pub fn new( + worker: &'a mut AsyncWorker, + container: &'a Arc, + has_unmerged_changes: bool, + ) -> Self { + Self { + worker, + container, + has_unmerged_changes, + } + } + + /// Renders the button (three vertical dots) and the dropdown menu. + pub fn show(&mut self, ui: &mut Ui, ctx: &egui::Context) { + ui.menu_button(icons::DOTS_THREE_OUTLINE_VERTICAL, |ui| { + ui.style_mut().visuals.button_frame = true; + ui.set_min_width(150.0); + + if ui + .button(format!("{} Refresh Status", icons::ARROWS_CLOCKWISE)) + .on_hover_text("Force reload of file status and branches") + .clicked() + { + ui.close_kind(egui::UiKind::Menu); + self.handle_refresh(ctx); + } + + // TODO: remove the "true" condition when the merge detection is implemented + // if self.has_unmerged_changes || true { + if self.has_unmerged_changes { + ui.separator(); + if ui + .button( + RichText::new(format!("{} Abort Merge", icons::PROHIBIT)) + .color(ui.visuals().error_fg_color), + ) + .on_hover_text("Abort the current merge and restore HEAD") + .clicked() + { + ui.close_kind(egui::UiKind::Menu); + self.handle_abort_merge(ctx); + } + } + }); + } + + /// Initiates background status refresh. + fn handle_refresh(&mut self, ctx: &egui::Context) { + let container = self.container.clone(); + let ctx_clone = ctx.clone(); + + self.worker.spawn(ctx.clone(), move |tx| { + let mut report = |msg: String| { + let _ = tx.send(WorkerEvent::Progress(msg)); + ctx_clone.request_repaint(); + }; + + match Self::execute_refresh_logic(container, &mut report) { + Ok(result) => { + let _ = tx.send(WorkerEvent::Success(result)); + } + Err(err) => { + let _ = tx.send(WorkerEvent::Error(EventError::new( + "Refresh Failed".to_string(), + "Could not refresh status".to_string(), + err, + ))); + } + } + ctx_clone.request_repaint(); + }); + } + + /// Initiates the background task to abort the merge. + fn handle_abort_merge(&mut self, ctx: &egui::Context) { + let container = self.container.clone(); + let ctx_clone = ctx.clone(); + + self.worker.spawn(ctx.clone(), move |tx| { + let mut report = |msg: String| { + let _ = tx.send(WorkerEvent::Progress(msg)); + ctx_clone.request_repaint(); + }; + + match Self::execute_abort_merge(container, &mut report) { + Ok(result) => { + let _ = tx.send(WorkerEvent::Success(result)); + } + Err(err_msg) => { + let _ = tx.send(WorkerEvent::Error(EventError::new( + "Abort Failed".to_string(), + "Could not abort merge".to_string(), + err_msg, + ))); + } + } + ctx_clone.request_repaint(); + }); + } + + /// Logic for Abort Merge. + fn execute_abort_merge( + container: Arc, + report: &mut dyn FnMut(String), + ) -> Result { + report("Aborting merge...".to_string()); + thread::sleep(std::time::Duration::from_millis(500)); + + // TODO: Implement actual merge abort logic + // let request = MergeRequest {}; + // let merge_handler = container.merge_handler().map_err(|e| e.to_string())?; + // merge_handler.handle_merge(request).map_err(|e| e.to_string())?; + + Self::execute_refresh_logic(container, report) + } + + /// Shared logic for reloading status and branches. + fn execute_refresh_logic( + container: Arc, + report: &mut dyn FnMut(String), + ) -> Result { + report("Refreshing status...".to_string()); + thread::sleep(std::time::Duration::from_millis(500)); + + let status_handler = container.status_handler().map_err(|e| e.to_string())?; + let status = status_handler + .handle_status(StatusRequest::with_branch()) + .map_err(|e| e.to_string())?; + + report("Loading branches...".into()); + thread::sleep(std::time::Duration::from_millis(500)); + + let branch_handler = container.branch_handler().map_err(|e| e.to_string())?; + let branch = branch_handler + .branch(BranchRequest::List(ListRequest::local_only(false))) + .map_err(|e| e.to_string())?; + + Ok(WorkerResult::RepositoryOpened { branch, status }) + } +} diff --git a/gui/src/ui/components/buttons/refresh_status_button.rs b/gui/src/ui/components/buttons/refresh_status_button.rs index e192fde..118aca7 100644 --- a/gui/src/ui/components/buttons/refresh_status_button.rs +++ b/gui/src/ui/components/buttons/refresh_status_button.rs @@ -104,6 +104,7 @@ impl<'a> RefreshStatusButton<'a> { .map_err(|e| e.to_string())?; report("Loading branches...".into()); + thread::sleep(std::time::Duration::from_millis(500)); let branch_handler = container.branch_handler().map_err(|e| e.to_string())?; let branch = branch_handler diff --git a/gui/src/ui/components/file_changes.rs b/gui/src/ui/components/file_changes.rs index 172347f..3fbbe3f 100644 --- a/gui/src/ui/components/file_changes.rs +++ b/gui/src/ui/components/file_changes.rs @@ -9,7 +9,7 @@ use egui::{Color32, RichText, Ui, collapsing_header::CollapsingState}; use egui_phosphor::regular as icons; use engine::{ - EngineContainer, + ConfigLoader, EngineContainer, MevaConfigLoader, diff_builder::{ChangeKind, DiffMode}, engine_container::MevaContainer, handlers::{ @@ -20,7 +20,7 @@ use engine::{ }, revision_parsing::Revision, }; -use shared::PathToString; +use shared::{OpenInEditor, PathToString}; use crate::events::{AsyncWorker, EventError, WorkerEvent, WorkerResult}; @@ -114,9 +114,24 @@ impl<'a> FileChangesComponent<'a> { &mut state.untracked_open, false, ); + + // TODO: Remove mock conflict entry when real unmerged data is available + // let mock_conflict_entry = StatusEntry { + // path: PathBuf::from( + // "C:\\Users\\AG\\Desktop\\test-repo\\data_processor\\src\\sorting.rs", + // ), + // kind: StatusKind::Unmerged { + // ours: ChangeKind::Modified, + // theirs: ChangeKind::Modified, + // }, + // }; + + let unmerged_files = status.unmerged.clone().unwrap_or_default(); + // unmerged_files.push(mock_conflict_entry); + self.render_section( ui, - &status.unmerged, + &Some(unmerged_files), "Unmerged", &mut state.unmerged_open, false, @@ -220,17 +235,22 @@ impl<'a> FileChangesComponent<'a> { }); } - /// Renders the primary action button for a file (Stage `+` or Unstage `-`). + /// Renders the primary action button for a file (Stage `+`, Unstage `-` or Mark as Resolved). fn show_primary_action_button( &mut self, ui: &mut Ui, entry: &StatusEntry, is_section_staged: bool, ) { - let (icon, tooltip) = if is_section_staged { - (icons::MINUS_CIRCLE, "Unstage changes") - } else { - (icons::PLUS_CIRCLE, "Stage changes") + let (icon, tooltip, is_resolve_action) = match entry.kind { + StatusKind::Unmerged { .. } => (icons::CHECK_CIRCLE, "Mark as Resolved", true), + _ => { + if is_section_staged { + (icons::MINUS_CIRCLE, "Unstage changes", false) + } else { + (icons::PLUS_CIRCLE, "Stage changes", false) + } + } }; if ui @@ -238,7 +258,9 @@ impl<'a> FileChangesComponent<'a> { .on_hover_text(tooltip) .clicked() { - if is_section_staged { + if is_resolve_action { + self.handle_stage(&entry.path); + } else if is_section_staged { self.handle_unstage(&entry.path); } else { self.handle_stage(&entry.path); @@ -257,14 +279,28 @@ impl<'a> FileChangesComponent<'a> { RichText::new(icons::DOTS_THREE_OUTLINE_VERTICAL).size(14.0), |ui| { ui.style_mut().visuals.button_frame = true; - ui.set_min_width(120.0); + ui.set_min_width(140.0); self.show_diff_menu_item(ui, entry, is_section_staged); - // Discard is only available for unstaged changes - if matches!(entry.kind, StatusKind::Unstaged(_)) { - ui.separator(); - self.show_discard_menu_item(ui, entry); + match entry.kind { + StatusKind::Unmerged { .. } => { + ui.separator(); + + if ui + .button(format!("{} Resolve conflicts", icons::WRENCH)) + .on_hover_text("Open file in external editor to fix conflicts") + .clicked() + { + ui.close_kind(egui::UiKind::Menu); + self.handle_open_in_editor(&entry.path); + } + } + StatusKind::Unstaged(_) => { + ui.separator(); + self.show_discard_menu_item(ui, entry); + } + _ => { /* No additional actions for other kinds */ } } }, ); @@ -406,6 +442,40 @@ impl<'a> FileChangesComponent<'a> { }); } + /// Opens the file in the system's default editor or the one configured in `editor.default`. + /// + /// Used for resolving conflicts manually. + fn handle_open_in_editor(&mut self, path: &Path) { + let path_buf = path.to_path_buf(); + let ctx = self.ctx.clone(); + self.worker.spawn(ctx.clone(), move |tx| { + let _ = tx.send(WorkerEvent::Progress("Resolving conflicts...".to_string())); + + let task = || -> Result { + let loader = MevaConfigLoader::default(); + let override_cmd = loader.get("editor.default", None).ok(); + + path_buf + .open_in_editor(override_cmd) + .map_err(|e| e.to_string())?; + + Ok(WorkerResult::None) + }; + + if let Err(err_msg) = task() { + let _ = tx.send(WorkerEvent::Error(EventError::new( + "Editor Error".to_string(), + "Could not open file".to_string(), + err_msg, + ))); + } else { + let _ = tx.send(WorkerEvent::Success(WorkerResult::None)); + } + + ctx.request_repaint(); + }); + } + /// Generic helper to spawn a background thread for a single file operation. /// /// This method: diff --git a/gui/src/ui/views/plugins.rs b/gui/src/ui/views/plugins.rs index aa4cde1..9383639 100644 --- a/gui/src/ui/views/plugins.rs +++ b/gui/src/ui/views/plugins.rs @@ -227,7 +227,7 @@ impl PluginsView { }) } else { let loader = MevaConfigLoader::default(); - let override_cmd = loader.get("core.editor", None).ok(); + let override_cmd = loader.get("editor.default", None).ok(); params .path .open_in_editor(override_cmd)