Add read-only git management

This commit is contained in:
Jeremy Soller 2023-12-01 13:19:56 -07:00
parent b9b46e015c
commit e2e92d5dd5
No known key found for this signature in database
GPG key ID: DCFCA852D3906975
9 changed files with 782 additions and 79 deletions

81
Cargo.lock generated
View file

@ -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"

View file

@ -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"

View file

@ -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

View file

@ -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);

265
src/git.rs Normal file
View file

@ -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<GitDiffHunk>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitDiffHunk {
pub old_range: patch::Range,
pub new_range: patch::Range,
pub lines: Vec<GitDiffLine>,
}
#[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<PathBuf>,
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<char> for GitStatusKind {
type Error = char;
fn try_from(c: char) -> Result<Self, Self::Error> {
// 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<P: AsRef<Path>>(path: P) -> io::Result<Self> {
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<String> {
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<P: AsRef<Path>>(&self, path: P, staged: bool) -> io::Result<GitDiff> {
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<Vec<GitStatus>> {
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)
}
}

View file

@ -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<GitStatus>)>),
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<u16>,
theme_names: Vec<String>,
context_page: ContextPage,
git_project_status: Option<Vec<(String, PathBuf, Vec<GitStatus>)>>,
projects: Vec<(String, PathBuf)>,
project_search_id: widget::Id,
project_search_value: String,
project_search_result: Option<ProjectSearchResult>,
@ -300,25 +310,31 @@ impl App {
}
pub fn open_project<P: AsRef<Path>>(&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::<Tab>(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>(tab)
.data::<Tab>(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::<Tab>(entity) {
if let Some(Tab::Editor(tab)) = self.tab_model.data_mut::<Tab>(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::<Tab>(entity) {
if let Some(Tab::Editor(tab)) =
self.tab_model.data_mut::<Tab>(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::<Tab>(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::<Tab>(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::<Tab>(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>(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::<Tab>(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::<Tab>(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::<Tab>(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::<Tab>(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::<Tab>(entity) {
if let Some(Tab::Editor(tab)) = self.tab_model.data_mut::<Tab>(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>(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<notify::Event, notify::Error>| match event_res {
Ok(event) => {

View file

@ -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),

View file

@ -27,14 +27,14 @@ pub struct ProjectSearchResult {
}
impl ProjectSearchResult {
pub fn search_projects(&mut self, project_paths: Vec<PathBuf>) {
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<ignore::WalkBuilder> = 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;
}
};

View file

@ -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<PathBuf>,
attrs: Attrs<'static>,
pub editor: Mutex<ViEditor<'static>>,
pub context_menu: Option<Point>,
}
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);