diff --git a/Cargo.lock b/Cargo.lock index 10b23dd..fd6fae3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -190,6 +190,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -647,6 +653,12 @@ version = "3.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec" +[[package]] +name = "bytecount" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e5f035d16fc623ae5f74981db80a439803888314e3a555fd6f04acd51a3205" + [[package]] name = "bytemuck" version = "1.14.0" @@ -751,6 +763,20 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" +[[package]] +name = "chrono" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2c685bad3eb3d45a01354cedb7d5faa66194d1d58ba6e267a8de788f79db38" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-targets 0.48.5", +] + [[package]] name = "clipboard-win" version = "4.5.0" @@ -1001,6 +1027,7 @@ dependencies = [ "libcosmic", "log", "notify", + "patch", "rfd", "rust-embed", "serde", @@ -2539,6 +2566,29 @@ dependencies = [ "syn 2.0.39", ] +[[package]] +name = "iana-time-zone" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8326b86b6cff230b97d0d312a6c40a60726df3332e721f72a1b035f451663b20" +dependencies = [ + "android_system_properties", + "core-foundation-sys 0.8.6", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "iced" version = "0.10.0" @@ -3621,6 +3671,17 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom_locate" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e3c83c053b0713da60c5b8de47fe8e494fe3ece5267b2f23090a07a053ba8f3" +dependencies = [ + "bytecount", + "memchr", + "nom 7.1.3", +] + [[package]] name = "notify" version = "6.1.1" @@ -4064,6 +4125,17 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" +[[package]] +name = "patch" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15c07fdcdd8b05bdcf2a25bc195b6c34cbd52762ada9dba88bf81e7686d14e7a" +dependencies = [ + "chrono", + "nom 7.1.3", + "nom_locate", +] + [[package]] name = "percent-encoding" version = "2.3.1" @@ -6255,6 +6327,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-core" +version = "0.51.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1f8cf84f35d2db49a46868f947758c7a1138116f7fac3bc844f43ade1292e64" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-implement" version = "0.44.0" diff --git a/Cargo.toml b/Cargo.toml index 166c7e1..8e7b444 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,11 +11,12 @@ grep = "0.3.1" ignore = "0.4.21" lazy_static = "1.4.0" log = "0.4.20" +patch = "0.7.0" notify = "6.1.1" #TODO: this is using gtk for file dialogues rfd = { version = "0.12.0", optional = true } serde = { version = "1", features = ["serde_derive"] } -tokio = { version = "1", features = ["time"] } +tokio = { version = "1", features = ["process", "time"] } # Extra syntax highlighting syntect = "5.1.0" two-face = "0.3.0" diff --git a/i18n/en/cosmic_edit.ftl b/i18n/en/cosmic_edit.ftl index 47d1217..04ba1f2 100644 --- a/i18n/en/cosmic_edit.ftl +++ b/i18n/en/cosmic_edit.ftl @@ -11,6 +11,11 @@ character-count = Characters character-count-no-spaces = Characters (without spaces) line-count = Lines +## Git management +git-management = Git management +unstaged-changes = Unstaged changes +staged-changes = Staged changes + ## Project search project-search = Project search @@ -50,6 +55,7 @@ revert-all-changes = Revert all changes menu-document-statistics = Document statistics... document-type = Document type... encoding = Encoding... +menu-git-management = Git management... print = Print quit = Quit diff --git a/src/config.rs b/src/config.rs index 57708de..3df7351 100644 --- a/src/config.rs +++ b/src/config.rs @@ -28,6 +28,7 @@ pub enum Action { Redo, Save, SelectAll, + ToggleGitManagement, ToggleProjectSearch, ToggleSettingsPage, ToggleWordWrap, @@ -50,6 +51,7 @@ impl Action { Self::Redo => Message::Redo, Self::Save => Message::Save, Self::SelectAll => Message::SelectAll, + Self::ToggleGitManagement => Message::ToggleContextPage(ContextPage::GitManagement), Self::ToggleProjectSearch => Message::ToggleContextPage(ContextPage::ProjectSearch), Self::ToggleSettingsPage => Message::ToggleContextPage(ContextPage::Settings), Self::ToggleWordWrap => Message::ToggleWordWrap, @@ -118,6 +120,7 @@ impl KeyBind { bind!([Ctrl, Shift], Z, Redo); bind!([Ctrl], S, Save); bind!([Ctrl], A, SelectAll); + bind!([Ctrl, Shift], G, ToggleGitManagement); bind!([Ctrl, Shift], F, ToggleProjectSearch); bind!([Ctrl], Comma, ToggleSettingsPage); bind!([Alt], Z, ToggleWordWrap); diff --git a/src/git.rs b/src/git.rs new file mode 100644 index 0000000..1afd2bb --- /dev/null +++ b/src/git.rs @@ -0,0 +1,265 @@ +//TODO: try to use gitoxide + +use std::{ + fs, io, + path::{Path, PathBuf}, +}; +use tokio::process::Command; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GitDiff { + pub path: PathBuf, + pub staged: bool, + pub hunks: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GitDiffHunk { + pub old_range: patch::Range, + pub new_range: patch::Range, + pub lines: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum GitDiffLine { + Context { + old_line: u64, + new_line: u64, + text: String, + }, + Added { + new_line: u64, + text: String, + }, + Deleted { + old_line: u64, + text: String, + }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GitStatus { + pub path: PathBuf, + pub old_path: Option, + pub staged: GitStatusKind, + pub unstaged: GitStatusKind, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum GitStatusKind { + Unmodified, + Modified, + FileTypeChanged, + Added, + Deleted, + Renamed, + Copied, + Updated, + Untracked, + SubmoduleModified, +} + +impl TryFrom for GitStatusKind { + type Error = char; + + fn try_from(c: char) -> Result { + // https://git-scm.com/docs/git-status#_short_format + match c { + ' ' => Ok(Self::Unmodified), + 'M' => Ok(Self::Modified), + 'T' => Ok(Self::FileTypeChanged), + 'A' => Ok(Self::Added), + 'D' => Ok(Self::Deleted), + 'R' => Ok(Self::Renamed), + 'C' => Ok(Self::Copied), + 'U' => Ok(Self::Updated), + '?' => Ok(Self::Untracked), + 'm' => Ok(Self::SubmoduleModified), + _ => Err(c), + } + } +} + +pub struct GitRepository { + path: PathBuf, +} + +impl GitRepository { + pub fn new>(path: P) -> io::Result { + let path = path.as_ref(); + if path.join(".git").exists() { + let path = fs::canonicalize(path)?; + Ok(Self { path }) + } else { + Err(io::Error::new( + io::ErrorKind::NotFound, + format!("{:?} is not a git repository", path), + )) + } + } + + fn command(&self) -> Command { + let mut command = Command::new("git"); + command.arg("-C").arg(&self.path); + command + } + + async fn command_stdout(mut command: Command) -> io::Result { + log::info!("{:?}", command); + let output = command.output().await?; + if output.status.success() { + String::from_utf8(output.stdout).map_err(|err| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("failed to parse git stdout: {}", err), + ) + }) + } else { + let mut msg = format!("git exited with {}", output.status); + for line in String::from_utf8_lossy(&output.stdout).lines() { + msg.push_str("\nstdout> "); + msg.push_str(line); + } + for line in String::from_utf8_lossy(&output.stderr).lines() { + msg.push_str("\nstderr> "); + msg.push_str(line); + } + Err(io::Error::new(io::ErrorKind::Other, msg)) + } + } + + pub async fn diff>(&self, path: P, staged: bool) -> io::Result { + let path = path.as_ref(); + let mut command = self.command(); + command.arg("diff"); + if staged { + command.arg("--staged"); + } + command.arg("--").arg(path); + let diff = Self::command_stdout(command).await?; + let patch = patch::Patch::from_single(&diff).map_err(|err| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("failed to parse diff: {}", err), + ) + })?; + + let mut hunks = Vec::with_capacity(patch.hunks.len()); + for hunk in patch.hunks.iter() { + //TODO: validate range counts + let mut old_line = hunk.old_range.start; + let mut new_line = hunk.new_range.start; + + let mut lines = Vec::with_capacity(hunk.lines.len()); + for line in hunk.lines.iter() { + match line { + patch::Line::Context(text) => { + lines.push(GitDiffLine::Context { + old_line, + new_line, + text: text.to_string(), + }); + old_line += 1; + new_line += 1; + } + patch::Line::Add(text) => { + lines.push(GitDiffLine::Added { + new_line, + text: text.to_string(), + }); + new_line += 1; + } + patch::Line::Remove(text) => { + lines.push(GitDiffLine::Deleted { + old_line, + text: text.to_string(), + }); + old_line += 1; + } + } + } + + hunks.push(GitDiffHunk { + old_range: hunk.old_range.clone(), + new_range: hunk.new_range.clone(), + lines, + }); + } + + Ok(GitDiff { + path: path.to_path_buf(), + staged, + hunks, + }) + } + + pub async fn status(&self) -> io::Result> { + let mut command = self.command(); + command.arg("status").arg("-z"); + let stdout = Self::command_stdout(command).await?; + + let mut status = Vec::new(); + let mut lines = stdout.split('\0'); + while let Some(line) = lines.next() { + macro_rules! invalid_line { + () => {{ + log::warn!("invalid git status line {:?}", line); + continue; + }}; + } + + if line.is_empty() { + // Ignore empty lines + continue; + } + + let mut chars = line.chars(); + + // Get staged status + let staged = match chars.next() { + Some(some) => match GitStatusKind::try_from(some) { + Ok(ok) => ok, + Err(_) => invalid_line!(), + }, + None => invalid_line!(), + }; + + // Get unstaged status + let unstaged = match chars.next() { + Some(some) => match GitStatusKind::try_from(some) { + Ok(ok) => ok, + Err(_) => invalid_line!(), + }, + None => invalid_line!(), + }; + + // Skip space + match chars.next() { + Some(' ') => {} + _ => invalid_line!(), + } + + // The rest of the chars are in the path + let relative_path: String = chars.collect(); + + let old_path = if staged == GitStatusKind::Renamed || unstaged == GitStatusKind::Renamed + { + match lines.next() { + Some(old_relative_path) => Some(self.path.join(old_relative_path)), + None => invalid_line!(), + } + } else { + None + }; + + status.push(GitStatus { + path: self.path.join(relative_path), + old_path, + staged, + unstaged, + }) + } + + Ok(status) + } +} diff --git a/src/main.rs b/src/main.rs index 7196793..409510b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,7 +10,7 @@ use cosmic::{ futures::{self, SinkExt}, keyboard, subscription, widget::{row, text}, - window, Alignment, Length, Point, + window, Alignment, Background, Color, Length, Point, }, style, theme, widget::{self, button, icon, nav_bar, segmented_button, view_switcher}, @@ -29,6 +29,9 @@ use tokio::time; use config::{Action, AppTheme, Config, CONFIG_VERSION}; mod config; +use git::{GitDiff, GitDiffLine, GitRepository, GitStatus, GitStatusKind}; +mod git; + use icon_cache::IconCache; mod icon_cache; @@ -49,7 +52,7 @@ mod project; use self::search::ProjectSearchResult; mod search; -use self::tab::Tab; +use self::tab::{EditorTab, GitDiffTab, Tab}; mod tab; use self::text_box::text_box; @@ -163,6 +166,7 @@ pub enum Message { Cut, DefaultFont(usize), DefaultFontSize(usize), + GitProjectStatus(Vec<(String, PathBuf, Vec)>), Key(keyboard::Modifiers, keyboard::KeyCode), NewFile, NewWindow, @@ -170,11 +174,13 @@ pub enum Message { NotifyWatcher(WatcherWrapper), OpenFileDialog, OpenFile(PathBuf), + OpenGitDiff(PathBuf, GitDiff), OpenProjectDialog, OpenProject(PathBuf), OpenSearchResult(usize, usize), Paste, PasteValue(String), + PrepareGitDiff(PathBuf, PathBuf, bool), ProjectSearchResult(ProjectSearchResult), ProjectSearchSubmit, ProjectSearchValue(String), @@ -203,6 +209,7 @@ pub enum Message { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ContextPage { DocumentStatistics, + GitManagement, //TODO: Move search to pop-up ProjectSearch, Settings, @@ -212,6 +219,7 @@ impl ContextPage { fn title(&self) -> String { match self { Self::DocumentStatistics => fl!("document-statistics"), + Self::GitManagement => fl!("git-management"), Self::ProjectSearch => fl!("project-search"), Self::Settings => fl!("settings"), } @@ -230,6 +238,8 @@ pub struct App { font_sizes: Vec, theme_names: Vec, context_page: ContextPage, + git_project_status: Option)>>, + projects: Vec<(String, PathBuf)>, project_search_id: widget::Id, project_search_value: String, project_search_result: Option, @@ -300,25 +310,31 @@ impl App { } pub fn open_project>(&mut self, path: P) { - let node = match ProjectNode::new(&path) { + let path = path.as_ref(); + let node = match ProjectNode::new(path) { Ok(mut node) => { match &mut node { - ProjectNode::Folder { open, root, .. } => { + ProjectNode::Folder { + name, + path, + open, + root, + } => { *open = true; *root = true; + + // Save the absolute path + self.projects.push((name.to_string(), path.to_path_buf())); } _ => { - log::error!( - "failed to open project {:?}: not a directory", - path.as_ref() - ); + log::error!("failed to open project {:?}: not a directory", path); return; } } node } Err(err) => { - log::error!("failed to open project {:?}: {}", path.as_ref(), err); + log::error!("failed to open project {:?}: {}", path, err); return; } }; @@ -351,13 +367,13 @@ impl App { let mut activate_opt = None; for entity in self.tab_model.iter() { match self.tab_model.data::(entity) { - Some(tab) => { + Some(Tab::Editor(tab)) => { if tab.path_opt.as_ref() == Some(&canonical) { activate_opt = Some(entity); break; } } - None => {} + _ => {} } } if let Some(entity) = activate_opt { @@ -365,12 +381,12 @@ impl App { return Some(entity); } - let mut tab = Tab::new(&self.config); + let mut tab = EditorTab::new(&self.config); tab.open(canonical); tab.watch(&mut self.watcher_opt); tab } - None => Tab::new(&self.config), + None => EditorTab::new(&self.config), }; Some( @@ -378,7 +394,7 @@ impl App { .insert() .text(tab.title()) .icon(tab.icon(16)) - .data::(tab) + .data::(Tab::Editor(tab)) .closable() .activate() .id(), @@ -389,7 +405,7 @@ impl App { //TODO: provide iterator over data let entities: Vec<_> = self.tab_model.iter().collect(); for entity in entities { - if let Some(tab) = self.tab_model.data_mut::(entity) { + if let Some(Tab::Editor(tab)) = self.tab_model.data_mut::(entity) { tab.set_config(&self.config); } } @@ -411,7 +427,8 @@ impl App { fn update_nav_bar_active(&mut self) { let tab_path_opt = match self.active_tab() { - Some(tab) => tab.path_opt.clone(), + Some(Tab::Editor(tab)) => tab.path_opt.clone(), + Some(Tab::GitDiff(tab)) => Some(tab.diff.path.clone()), None => None, }; @@ -464,8 +481,10 @@ impl App { let title = match self.active_tab() { Some(tab) => { - // Force redraw on tab switches - tab.editor.lock().unwrap().buffer_mut().set_redraw(true); + if let Tab::Editor(inner) = tab { + // Force redraw on tab switches + inner.editor.lock().unwrap().buffer_mut().set_redraw(true); + } tab.title() } None => format!("No Open File"), @@ -554,6 +573,8 @@ impl Application for App { font_sizes, theme_names, context_page: ContextPage::Settings, + git_project_status: None, + projects: Vec::new(), project_search_id: widget::Id::unique(), project_search_value: String::new(), project_search_result: None, @@ -709,17 +730,17 @@ impl Application for App { log::info!("TODO"); } Message::Copy => match self.active_tab() { - Some(tab) => { + Some(Tab::Editor(tab)) => { let editor = tab.editor.lock().unwrap(); let selection_opt = editor.copy_selection(); if let Some(selection) = selection_opt { return clipboard::write(selection); } } - None => {} + _ => {} }, Message::Cut => match self.active_tab() { - Some(tab) => { + Some(Tab::Editor(tab)) => { let mut editor = tab.editor.lock().unwrap(); let selection_opt = editor.copy_selection(); editor.delete_selection(); @@ -727,7 +748,7 @@ impl Application for App { return clipboard::write(selection); } } - None => {} + _ => {} }, Message::DefaultFont(index) => { match self.font_names.get(index) { @@ -748,7 +769,9 @@ impl Application for App { // This does a complete reset of shaping data! let entities: Vec<_> = self.tab_model.iter().collect(); for entity in entities { - if let Some(tab) = self.tab_model.data_mut::(entity) { + if let Some(Tab::Editor(tab)) = + self.tab_model.data_mut::(entity) + { let mut editor = tab.editor.lock().unwrap(); for line in editor.buffer_mut().lines.iter_mut() { line.reset(); @@ -774,6 +797,9 @@ impl Application for App { log::warn!("failed to find font with index {}", index); } }, + Message::GitProjectStatus(project_status) => { + self.git_project_status = Some(project_status); + } Message::Key(modifiers, key_code) => { for (key_bind, action) in self.config.keybinds.iter() { if key_bind.matches(modifiers, key_code) { @@ -803,7 +829,7 @@ impl Application for App { let mut needs_reload = Vec::new(); for entity in self.tab_model.iter() { match self.tab_model.data::(entity) { - Some(tab) => { + Some(Tab::Editor(tab)) => { if let Some(path) = &tab.path_opt { if event.paths.contains(&path) { if tab.changed() { @@ -817,16 +843,16 @@ impl Application for App { } } } - None => {} + _ => {} } } for entity in needs_reload { match self.tab_model.data_mut::(entity) { - Some(tab) => { + Some(Tab::Editor(tab)) => { tab.reload(); } - None => { + _ => { log::warn!("failed to find tab {:?} that needs reload", entity); } } @@ -839,10 +865,10 @@ impl Application for App { for entity in self.tab_model.iter() { match self.tab_model.data::(entity) { - Some(tab) => { + Some(Tab::Editor(tab)) => { tab.watch(&mut self.watcher_opt); } - None => {} + _ => {} } } } @@ -867,6 +893,39 @@ impl Application for App { self.open_tab(Some(path)); return self.update_tab(); } + Message::OpenGitDiff(project_path, diff) => { + let relative_path = match diff.path.strip_prefix(project_path.clone()) { + Ok(ok) => ok, + Err(err) => { + log::warn!( + "failed to find relative path of {:?} in project {:?}: {}", + diff.path, + project_path, + err + ); + &diff.path + } + }; + let title = format!( + "{}: {}", + if diff.staged { + fl!("staged-changes") + } else { + fl!("unstaged-changes") + }, + relative_path.display() + ); + let icon = mime_icon(&diff.path, 16); + let tab = Tab::GitDiff(GitDiffTab { title, diff }); + self.tab_model + .insert() + .text(tab.title()) + .icon(icon) + .data::(tab) + .closable() + .activate(); + return self.update_tab(); + } Message::OpenProjectDialog => { #[cfg(feature = "rfd")] return Command::perform( @@ -927,12 +986,43 @@ impl Application for App { }); } Message::PasteValue(value) => match self.active_tab() { - Some(tab) => { + Some(Tab::Editor(tab)) => { let mut editor = tab.editor.lock().unwrap(); editor.insert_string(&value, None); } - None => {} + _ => {} }, + Message::PrepareGitDiff(project_path, path, staged) => { + return Command::perform( + async move { + //TODO: send errors to UI + match GitRepository::new(&project_path) { + Ok(repo) => match repo.diff(&path, staged).await { + Ok(diff) => { + return message::app(Message::OpenGitDiff(project_path, diff)); + } + Err(err) => { + log::error!( + "failed to get diff of {:?} in {:?}: {}", + path, + project_path, + err + ); + } + }, + Err(err) => { + log::error!( + "failed to open repository {:?}: {}", + project_path, + err + ); + } + } + message::none() + }, + |x| x, + ); + } Message::ProjectSearchResult(project_search_result) => { self.project_search_result = Some(project_search_result); @@ -942,19 +1032,7 @@ impl Application for App { Message::ProjectSearchSubmit => { //TODO: Figure out length requirements? if !self.project_search_value.is_empty() { - //TODO: cache projects outside of nav model? - let mut project_paths = Vec::new(); - for id in self.nav_model.iter() { - match self.nav_model.data(id) { - Some(ProjectNode::Folder { path, root, .. }) => { - if *root { - project_paths.push(path.clone()) - } - } - _ => {} - } - } - + let projects = self.projects.clone(); let project_search_value = self.project_search_value.clone(); let mut project_search_result = ProjectSearchResult { value: project_search_value.clone(), @@ -965,7 +1043,7 @@ impl Application for App { return Command::perform( async move { let task_res = tokio::task::spawn_blocking(move || { - project_search_result.search_projects(project_paths); + project_search_result.search_projects(projects); message::app(Message::ProjectSearchResult(project_search_result)) }) .await; @@ -989,17 +1067,17 @@ impl Application for App { return window::close(); } Message::Redo => match self.active_tab() { - Some(tab) => { + Some(Tab::Editor(tab)) => { let mut editor = tab.editor.lock().unwrap(); editor.redo(); } - None => {} + _ => {} }, Message::Save => { let mut title_opt = None; match self.active_tab_mut() { - Some(tab) => { + Some(Tab::Editor(tab)) => { #[cfg(feature = "rfd")] if tab.path_opt.is_none() { //TODO: use async file dialog @@ -1008,10 +1086,7 @@ impl Application for App { title_opt = Some(tab.title()); tab.save(); } - None => { - //TODO: disable save button? - log::warn!("TODO: NO TAB OPEN"); - } + _ => {} } if let Some(title) = title_opt { @@ -1020,7 +1095,7 @@ impl Application for App { } Message::SelectAll => { match self.active_tab_mut() { - Some(tab) => { + Some(Tab::Editor(tab)) => { let mut editor = tab.editor.lock().unwrap(); // Set cursor to lowest possible value @@ -1032,7 +1107,7 @@ impl Application for App { let last_index = buffer.lines[last_line].text().len(); editor.set_selection(Selection::Normal(Cursor::new(last_line, last_index))); } - None => {} + _ => {} } } Message::SystemThemeModeChange(_theme_mode) => { @@ -1056,13 +1131,13 @@ impl Application for App { return self.update_tab(); } Message::TabChanged(entity) => match self.tab_model.data::(entity) { - Some(tab) => { + Some(Tab::Editor(tab)) => { let mut title = tab.title(); //TODO: better way of adding change indicator title.push_str(" \u{2022}"); self.tab_model.text_set(entity, title); } - None => {} + _ => {} }, Message::TabClose(entity) => { // Activate closest item @@ -1086,30 +1161,30 @@ impl Application for App { } Message::TabContextAction(entity, action) => { match self.tab_model.data_mut::(entity) { - Some(tab) => { + Some(Tab::Editor(tab)) => { // Close context menu tab.context_menu = None; // Run action's message return self.update(action.message()); } - None => {} + _ => {} } } Message::TabContextMenu(entity, position_opt) => { match self.tab_model.data_mut::(entity) { - Some(tab) => { + Some(Tab::Editor(tab)) => { // Update context menu tab.context_menu = position_opt; } - None => {} + _ => {} } } Message::TabSetCursor(entity, cursor) => match self.tab_model.data::(entity) { - Some(tab) => { + Some(Tab::Editor(tab)) => { let mut editor = tab.editor.lock().unwrap(); editor.set_cursor(cursor); } - None => {} + _ => {} }, Message::TabWidth(tab_width) => { self.config.tab_width = tab_width; @@ -1131,10 +1206,52 @@ impl Application for App { } self.set_context_title(context_page.title()); - // Ensure focus of correct input + // Execute commands for specific pages if self.core.window.show_context { match self.context_page { + ContextPage::GitManagement => { + self.git_project_status = None; + let projects = self.projects.clone(); + return Command::perform( + async move { + let mut project_status = Vec::new(); + for (project_name, project_path) in projects.iter() { + //TODO: send errors to UI + match GitRepository::new(&project_path) { + Ok(repo) => match repo.status().await { + Ok(status) => { + if !status.is_empty() { + project_status.push(( + project_name.clone(), + project_path.clone(), + status, + )); + } + } + Err(err) => { + log::error!( + "failed to get status of {:?}: {}", + project_path, + err + ); + } + }, + Err(err) => { + log::error!( + "failed to open repository {:?}: {}", + project_path, + err + ); + } + } + } + message::app(Message::GitProjectStatus(project_status)) + }, + |x| x, + ); + } ContextPage::ProjectSearch => { + // Ensure focus of correct input return widget::text_input::focus(self.project_search_id.clone()); } _ => {} @@ -1147,7 +1264,7 @@ impl Application for App { // This forces a redraw of all buffers let entities: Vec<_> = self.tab_model.iter().collect(); for entity in entities { - if let Some(tab) = self.tab_model.data_mut::(entity) { + if let Some(Tab::Editor(tab)) = self.tab_model.data_mut::(entity) { let mut editor = tab.editor.lock().unwrap(); editor.buffer_mut().set_redraw(true); } @@ -1160,11 +1277,11 @@ impl Application for App { return self.save_config(); } Message::Undo => match self.active_tab() { - Some(tab) => { + Some(Tab::Editor(tab)) => { let mut editor = tab.editor.lock().unwrap(); editor.undo(); } - None => {} + _ => {} }, Message::VimBindings(vim_bindings) => { self.config.vim_bindings = vim_bindings; @@ -1195,7 +1312,7 @@ impl Application for App { let mut character_count_no_spaces = 0; let line_count; match self.active_tab() { - Some(tab) => { + Some(Tab::Editor(tab)) => { let editor = tab.editor.lock().unwrap(); let buffer = editor.buffer(); @@ -1210,7 +1327,7 @@ impl Application for App { } } } - None => { + _ => { return None; } } @@ -1232,6 +1349,160 @@ impl Application for App { .into()]) .into() } + ContextPage::GitManagement => { + if let Some(project_status) = &self.git_project_status { + let (success_color, destructive_color, warning_color) = { + let cosmic_theme = self.core().system_theme().cosmic(); + ( + cosmic_theme.success_color(), + cosmic_theme.destructive_color(), + cosmic_theme.warning_color(), + ) + }; + let added = + || widget::text("[+]").style(theme::Text::Color(success_color.into())); + let deleted = + || widget::text("[-]").style(theme::Text::Color(destructive_color.into())); + let modified = + || widget::text("[*]").style(theme::Text::Color(warning_color.into())); + + let mut items = Vec::with_capacity(project_status.len().saturating_mul(3)); + for (project_name, project_path, status) in project_status.iter() { + let mut unstaged_items = Vec::with_capacity(status.len()); + let mut staged_items = Vec::with_capacity(status.len()); + for item in status.iter() { + let relative_path = match item.path.strip_prefix(project_path) { + Ok(ok) => ok, + Err(err) => { + log::warn!( + "failed to find relative path of {:?} in project {:?}: {}", + item.path, + project_path, + err + ); + &item.path + } + }; + + let text = match &item.old_path { + Some(old_path) => { + let old_relative_path = match old_path + .strip_prefix(project_path) + { + Ok(ok) => ok, + Err(err) => { + log::warn!( + "failed to find relative path of {:?} in project {:?}: {}", + old_path, + project_path, + err + ); + &old_path + } + }; + format!( + "{} -> {}", + old_relative_path.display(), + relative_path.display() + ) + } + None => format!("{}", relative_path.display()), + }; + + let unstaged_opt = match item.unstaged { + GitStatusKind::Unmodified => None, + GitStatusKind::Modified => Some(modified()), + GitStatusKind::FileTypeChanged => Some(modified()), + GitStatusKind::Added => Some(added()), + GitStatusKind::Deleted => Some(deleted()), + GitStatusKind::Renamed => Some(modified()), //TODO + GitStatusKind::Copied => Some(modified()), // TODO + GitStatusKind::Updated => Some(modified()), + GitStatusKind::Untracked => Some(added()), + GitStatusKind::SubmoduleModified => Some(modified()), + }; + + if let Some(icon) = unstaged_opt { + unstaged_items.push( + widget::button( + widget::row::with_children(vec![ + icon.into(), + widget::text(text.clone()).into(), + ]) + .spacing(space_xs), + ) + .on_press(Message::PrepareGitDiff( + project_path.clone(), + item.path.clone(), + false, + )) + .style(theme::Button::AppletMenu) + .width(Length::Fill) + .into(), + ); + } + + let staged_opt = match item.staged { + GitStatusKind::Unmodified => None, + GitStatusKind::Modified => Some(modified()), + GitStatusKind::FileTypeChanged => Some(modified()), + GitStatusKind::Added => Some(added()), + GitStatusKind::Deleted => Some(deleted()), + GitStatusKind::Renamed => Some(modified()), //TODO + GitStatusKind::Copied => Some(modified()), // TODO + GitStatusKind::Updated => Some(modified()), + GitStatusKind::Untracked => None, + GitStatusKind::SubmoduleModified => Some(modified()), + }; + + if let Some(icon) = staged_opt { + staged_items.push( + widget::button( + widget::row::with_children(vec![ + icon.into(), + widget::text(text.clone()).into(), + ]) + .spacing(space_xs), + ) + .on_press(Message::PrepareGitDiff( + project_path.clone(), + item.path.clone(), + true, + )) + .style(theme::Button::AppletMenu) + .width(Length::Fill) + .into(), + ); + } + } + + items.push(widget::text::heading(project_name.clone()).into()); + + if !unstaged_items.is_empty() { + items.push( + widget::settings::view_section(fl!("unstaged-changes")) + .add(widget::column::with_children(unstaged_items)) + .into(), + ); + } + + if !staged_items.is_empty() { + items.push( + widget::settings::view_section(fl!("staged-changes")) + .add(widget::column::with_children(staged_items)) + .into(), + ); + } + } + + widget::column::with_children(items) + .spacing(space_s) + .padding([space_xxs, space_none]) + .into() + } else { + widget::text("TODO (TRANSLATE): Loading git status...").into() + } + } ContextPage::ProjectSearch => { let search_input = widget::text_input::search_input( &fl!("project-search"), @@ -1431,7 +1702,7 @@ impl Application for App { let tab_id = self.tab_model.active(); match self.tab_model.data::(tab_id) { - Some(tab) => { + Some(Tab::Editor(tab)) => { let status = { let editor = tab.editor.lock().unwrap(); let parser = editor.parser(); @@ -1486,10 +1757,62 @@ impl Application for App { tab_column = tab_column.push(tab_element); tab_column = tab_column.push(text(status).font(Font::MONOSPACE)); } - None => { - log::warn!("TODO: No tab open"); + Some(Tab::GitDiff(tab)) => { + let mut diff_widget = widget::column::with_capacity(tab.diff.hunks.len()); + for hunk in tab.diff.hunks.iter() { + let mut hunk_widget = widget::column::with_capacity(hunk.lines.len()); + for line in hunk.lines.iter() { + let line_widget = match line { + GitDiffLine::Context { + old_line, + new_line, + text, + } => widget::container(widget::text::monotext(format!( + "{:4} {:4} {}", + old_line, new_line, text + ))), + GitDiffLine::Added { new_line, text } => widget::container( + widget::text::monotext(format!( + "{:4} {:4} + {}", + "", new_line, text + )), + ) + .style(theme::Container::Custom(Box::new(|_theme| { + //TODO: theme this color + widget::container::Appearance { + background: Some(Background::Color(Color::from_rgb8( + 0x00, 0x40, 0x00, + ))), + ..Default::default() + } + }))), + GitDiffLine::Deleted { old_line, text } => widget::container( + widget::text::monotext(format!( + "{:4} {:4} - {}", + old_line, "", text + )), + ) + .style(theme::Container::Custom(Box::new(|_theme| { + //TODO: theme this color + widget::container::Appearance { + background: Some(Background::Color(Color::from_rgb8( + 0x40, 0x00, 0x00, + ))), + ..Default::default() + } + }))), + }; + hunk_widget = hunk_widget.push(line_widget.width(Length::Fill)); + } + diff_widget = diff_widget.push(hunk_widget); + } + tab_column = tab_column.push(widget::scrollable( + widget::cosmic_container::container(diff_widget) + .layer(cosmic_theme::Layer::Primary), + )); } - }; + None => {} + } let content: Element<_> = tab_column.into(); @@ -1517,6 +1840,7 @@ impl Application for App { |mut output| async move { let watcher_res = { let mut output = output.clone(); + //TODO: debounce notify::recommended_watcher( move |event_res: Result| match event_res { Ok(event) => { diff --git a/src/menu.rs b/src/menu.rs index 30ab75a..da74779 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -177,6 +177,10 @@ pub fn menu_bar<'a>(config: &Config) -> Element<'a, Message> { ), menu_item(fl!("document-type"), Message::Todo), menu_item(fl!("encoding"), Message::Todo), + menu_item( + fl!("menu-git-management"), + Message::ToggleContextPage(ContextPage::GitManagement), + ), menu_item(fl!("print"), Message::Todo), MenuTree::new(horizontal_rule(1)), menu_item(fl!("quit"), Message::Quit), diff --git a/src/search.rs b/src/search.rs index 9d9a790..d8d5ca2 100644 --- a/src/search.rs +++ b/src/search.rs @@ -27,14 +27,14 @@ pub struct ProjectSearchResult { } impl ProjectSearchResult { - pub fn search_projects(&mut self, project_paths: Vec) { + pub fn search_projects(&mut self, projects: Vec<(String, PathBuf)>) { //TODO: support literal search //TODO: use ignore::WalkParallel? match RegexMatcher::new(&self.value) { Ok(matcher) => { let mut searcher = Searcher::new(); let mut walk_builder_opt: Option = None; - for project_path in project_paths.iter() { + for (_, project_path) in projects.iter() { walk_builder_opt = match walk_builder_opt.take() { Some(mut walk_builder) => { walk_builder.add(project_path); @@ -49,7 +49,7 @@ impl ProjectSearchResult { let entry = match entry_res { Ok(ok) => ok, Err(err) => { - log::error!("failed to walk projects {:?}: {}", project_paths, err); + log::error!("failed to walk projects {:?}: {}", projects, err); continue; } }; diff --git a/src/tab.rs b/src/tab.rs index a5ecf25..3caf3a4 100644 --- a/src/tab.rs +++ b/src/tab.rs @@ -8,16 +8,35 @@ use cosmic_text::{Attrs, Buffer, Edit, Shaping, SyntaxEditor, ViEditor, Wrap}; use notify::Watcher; use std::{fs, path::PathBuf, sync::Mutex}; -use crate::{fl, mime_icon, Config, FALLBACK_MIME_ICON, FONT_SYSTEM, SYNTAX_SYSTEM}; +use crate::{fl, git::GitDiff, mime_icon, Config, FALLBACK_MIME_ICON, FONT_SYSTEM, SYNTAX_SYSTEM}; -pub struct Tab { +pub enum Tab { + Editor(EditorTab), + GitDiff(GitDiffTab), +} + +impl Tab { + pub fn title(&self) -> String { + match self { + Self::Editor(tab) => tab.title(), + Self::GitDiff(tab) => tab.title.clone(), + } + } +} + +pub struct GitDiffTab { + pub title: String, + pub diff: GitDiff, +} + +pub struct EditorTab { pub path_opt: Option, attrs: Attrs<'static>, pub editor: Mutex>, pub context_menu: Option, } -impl Tab { +impl EditorTab { pub fn new(config: &Config) -> Self { //TODO: do not repeat, used in App::init let attrs = cosmic_text::Attrs::new().family(cosmic_text::Family::Monospace);