use std::path::PathBuf;
use super::{App, BulkAction, Mode, shell_quote};
use crate::event::AsyncEvent;
use crate::sync;
use crate::ui::input::LineEditor;
impl App {
pub(crate) fn do_request_edit(&mut self) {
let Some(rel) = self.selected_file() else {
return;
};
let path = self.abs_path(&rel);
if !path.is_file() {
self.status_message = format!("Not a file: {}", path.display());
return;
}
self.pending_editor = Some(path);
}
pub(crate) fn confirm_trash_local(&mut self) {
let Some(rel) = self.selected_file() else {
return;
};
let Some(root) = self.active_root_path() else {
return;
};
let has_gist = self.store.get(&root, &rel).is_some();
let suffix = if has_gist {
" The remote gist is kept; the mapping is dropped."
} else {
""
};
self.mode = Mode::Confirm {
message: format!("Move {rel} to the system trash?{suffix}"),
action: super::ConfirmAction::TrashLocal {
rel_path: rel,
root,
},
};
}
pub(crate) fn do_trash_local(&mut self, rel_path: String, root: PathBuf) {
let abs = root.join(&rel_path);
match trash::delete(&abs) {
Ok(()) => {
self.store.remove(&root, &rel_path);
if let Err(e) = self.store.save() {
self.status_message = format!("Trashed but store save failed: {e}");
return;
}
if let Err(e) = self.refresh_files() {
self.status_message = format!("Trashed but refresh failed: {e}");
return;
}
self.status_message = format!("Moved {rel_path} to trash.");
}
Err(e) => {
self.status_message = format!("Trash failed: {e}");
}
}
}
pub(crate) fn start_rename(&mut self) {
let Some(rel) = self.selected_file() else {
self.status_message = "No file selected.".into();
return;
};
self.input_editor = LineEditor::new();
self.input_editor.content = rel.clone();
self.input_editor.cursor = self.input_editor.content.len();
self.mode = Mode::Rename { old_rel: rel };
}
pub(crate) fn do_rename(&mut self, old_rel: String, new_rel: String) {
if new_rel.is_empty() {
self.status_message = "Empty filename - rename cancelled.".into();
return;
}
if new_rel == old_rel {
self.status_message = "No change - rename cancelled.".into();
return;
}
if new_rel.starts_with('/') || new_rel.split('/').any(|c| c == "..") {
self.status_message =
"New name must be a relative path under the root (no .. or leading /).".into();
return;
}
let Some(root) = self.active_root_path() else {
self.status_message = "No root.".into();
return;
};
let old_abs = root.join(&old_rel);
let new_abs = root.join(&new_rel);
if new_abs.exists() {
self.status_message = format!("Target exists: {new_rel}");
return;
}
if let Some(parent) = new_abs.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
self.status_message = format!("mkdir failed: {e}");
return;
}
if let Err(e) = std::fs::rename(&old_abs, &new_abs) {
self.status_message = format!("Rename failed: {e}");
return;
}
let entry = self.store.get(&root, &old_rel).cloned();
if self
.store
.files_for_root(&root)
.is_some_and(|m| m.contains_key(&old_rel))
{
self.store.move_entry(&root, &old_rel, new_rel.clone());
if let Err(e) = self.store.save() {
self.status_message = format!("Renamed locally; store save failed: {e}");
return;
}
}
if let Err(e) = self.refresh_files() {
self.status_message = format!("Renamed; refresh failed: {e}");
return;
}
self.jump_to(&new_rel);
if let Some(entry) = entry {
let old_base = std::path::Path::new(&old_rel)
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
let new_base = std::path::Path::new(&new_rel)
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
if old_base != new_base {
let Some(token) = self.token.clone() else {
self.status_message = "Renamed locally; no token to update remote gist.".into();
return;
};
let tx = self.async_tx.clone();
let new_rel_clone = new_rel.clone();
self.status_message = "Renamed locally; updating remote gist...".to_string();
let remote_id = entry.remote_id.clone();
self.spawn_tracked(async move {
let client = penknife_gist::GistClient::new(token);
let result = client
.rename_file(&remote_id, &old_base, &new_base)
.await
.map(|_| ())
.map_err(|e| e.to_string());
let _ = tx.send(AsyncEvent::RenameRemoteDone {
rel_path: new_rel_clone,
result,
});
});
} else {
self.status_message = format!("Renamed: {old_rel} → {new_rel}");
}
} else {
self.status_message = format!("Renamed: {old_rel} → {new_rel}");
}
}
pub fn replace_scope(&self) -> Option<PathBuf> {
let root = self.active_root_path()?;
if let Some(rel) = self.selected_file() {
let p = std::path::Path::new(&rel);
return Some(p.parent().map(|p| root.join(p)).unwrap_or(root));
}
let selected = self.tree_state.selected();
if let Some(id) = selected.last() {
if !self.tree_file_ids.contains(id) {
return Some(root.join(id));
}
}
Some(root)
}
pub fn replace_scope_label(&self) -> String {
let Some(root) = self.active_root_path() else {
return "(no root)".into();
};
let Some(scope) = self.replace_scope() else {
return "(no root)".into();
};
let rel = scope.strip_prefix(&root).unwrap_or(&scope);
let s = rel.to_string_lossy();
if s.is_empty() {
"(root)".into()
} else {
s.into_owned()
}
}
pub(crate) fn start_replace(&mut self) {
if self.active_root_path().is_none() {
self.status_message = "No root directory configured.".into();
return;
}
self.replace_query.clear();
self.replace_target.clear();
self.replace_matches.clear();
self.replace_checked.clear();
self.input_editor = LineEditor::new();
self.mode = Mode::ReplaceQuery;
}
pub(crate) fn start_search(&mut self) {
if self.active_root_path().is_none() {
self.status_message = "No root directory configured.".into();
return;
}
self.search_query.clear();
self.search_matches.clear();
self.input_editor = LineEditor::new();
self.mode = Mode::SearchQuery;
}
pub(crate) fn run_search_scan(&mut self) {
let Some(scope) = self.replace_scope() else {
self.status_message = "No scope available.".into();
self.mode = Mode::Normal;
return;
};
let Some(root) = self.active_root_path() else {
self.status_message = "No root.".into();
self.mode = Mode::Normal;
return;
};
self.search_matches = crate::replace::scan(&scope, &root, &self.search_query);
if self.search_matches.is_empty() {
self.status_message = format!(
"No matches for '{}' in {}",
self.search_query,
self.replace_scope_label()
);
self.mode = Mode::Normal;
return;
}
self.mode = Mode::SearchResults { selected: 0 };
}
pub(crate) fn run_replace_scan(&mut self) {
let Some(scope) = self.replace_scope() else {
self.status_message = "No scope available.".into();
self.mode = Mode::Normal;
return;
};
let Some(root) = self.active_root_path() else {
self.status_message = "No root.".into();
self.mode = Mode::Normal;
return;
};
let matches = crate::replace::scan(&scope, &root, &self.replace_query);
if matches.is_empty() {
self.status_message = format!(
"No matches for '{}' in {}",
self.replace_query,
self.replace_scope_label()
);
self.mode = Mode::Normal;
return;
}
let n = matches.len();
self.replace_checked = vec![true; n];
self.replace_matches = matches;
self.status_message = format!("Found {n} matches - review and apply.");
self.mode = Mode::ReplaceReview { selected: 0 };
}
pub(crate) fn apply_replace(&mut self) {
let to_apply: Vec<crate::replace::ReplaceMatch> = self
.replace_matches
.iter()
.zip(self.replace_checked.iter())
.filter_map(|(m, c)| if *c { Some(m.clone()) } else { None })
.collect();
if to_apply.is_empty() {
self.status_message = "Nothing checked - no changes applied.".into();
self.mode = Mode::Normal;
return;
}
let result = crate::replace::apply(&to_apply, &self.replace_query, &self.replace_target);
let n_files = result.files_changed.len();
let mut msg = format!(
"Replaced {} of {} ({} file{})",
result.applied,
to_apply.len(),
n_files,
if n_files == 1 { "" } else { "s" }
);
if result.drifted > 0 {
msg.push_str(&format!(" - {} skipped (file changed)", result.drifted));
}
if !result.errors.is_empty() {
msg.push_str(&format!(" - {} write error(s)", result.errors.len()));
}
if let Err(e) = self.refresh_files() {
msg.push_str(&format!(" - refresh failed: {e}"));
}
self.update_preview();
self.update_status();
self.status_message = msg;
self.replace_matches.clear();
self.replace_checked.clear();
self.mode = Mode::Normal;
}
pub(crate) fn do_copy_file_contents(&mut self) {
let Some(rel) = self.selected_file() else {
self.status_message = "No file selected.".into();
return;
};
let abs = self.abs_path(&rel);
let content = match std::fs::read_to_string(&abs) {
Ok(c) => c,
Err(e) => {
self.status_message = format!("Read error: {e}");
return;
}
};
let bytes = content.len();
match arboard::Clipboard::new().and_then(|mut c| c.set_text(content)) {
Ok(()) => {
self.status_message = format!("Copied {bytes} bytes from {rel}");
}
Err(e) => {
self.status_message = format!("Clipboard error: {e}");
}
}
}
pub(crate) fn do_copy_rich(&mut self) {
let Some(rel) = self.selected_file() else {
self.status_message = "No file selected.".into();
return;
};
let abs = self.abs_path(&rel);
let markdown = match std::fs::read_to_string(&abs) {
Ok(c) => c,
Err(e) => {
self.status_message = format!("Read error: {e}");
return;
}
};
let html = render_markdown_to_html(&markdown);
match arboard::Clipboard::new().and_then(|mut c| c.set().html(&html, Some(&markdown))) {
Ok(()) => {
self.status_message = format!("Copied {rel} as rich text (paste anywhere)");
}
Err(e) => {
self.status_message = format!("Clipboard error: {e}");
}
}
}
pub(crate) fn do_paste_rich(&mut self) {
let mut clip = match arboard::Clipboard::new() {
Ok(c) => c,
Err(e) => {
self.status_message = format!("Clipboard error: {e}");
return;
}
};
let content = match clip.get().html() {
Ok(html) => match htmd::convert(&html) {
Ok(md) => md,
Err(e) => {
self.status_message = format!("HTML→Markdown failed: {e}");
return;
}
},
Err(_) => match clip.get_text() {
Ok(text) if !text.is_empty() => text,
Ok(_) => {
self.status_message = "Clipboard is empty.".into();
return;
}
Err(e) => {
self.status_message = format!("Clipboard read failed: {e}");
return;
}
},
};
self.gdoc_content = Some(content);
self.input_editor = LineEditor::new();
self.mode = Mode::GdocFilename;
}
pub(crate) fn start_gdoc_fetch(&mut self, url: &str) {
let Some(doc_id) = crate::gdoc::extract_doc_id(url) else {
self.mode = Mode::Message("Invalid Google Doc URL.".into());
return;
};
let tx = self.async_tx.clone();
self.status_message = "Fetching Google Doc...".into();
self.mode = Mode::Normal;
self.spawn_tracked(async move {
let result = crate::gdoc::fetch_doc_markdown(&doc_id).await;
let _ = tx.send(AsyncEvent::GdocFetched(result.map_err(|e| e.to_string())));
});
}
pub(crate) fn save_gdoc_import(&mut self) {
let Some(content) = self.gdoc_content.take() else {
self.mode = Mode::Message("No content to save.".into());
return;
};
let name = self.input_editor.content.trim().to_string();
if name.is_empty() {
self.mode = Mode::Message("Filename cannot be empty.".into());
return;
}
let Some(root) = self.active_root_path() else {
self.mode = Mode::Message("No root directory configured.".into());
return;
};
let dir = if let Some(selected) = self.selected_file() {
let path = std::path::Path::new(&selected);
path.parent().map(|p| root.join(p)).unwrap_or(root.clone())
} else {
let selected = self.tree_state.selected();
if let Some(id) = selected.last() {
root.join(id)
} else {
root.clone()
}
};
let filename = format!("{name}.md");
let path = dir.join(&filename);
if let Err(e) = std::fs::create_dir_all(&dir) {
self.mode = Mode::Message(format!("Failed to create directory: {e}"));
return;
}
if let Err(e) = std::fs::write(&path, &content) {
self.mode = Mode::Message(format!("Failed to write file: {e}"));
return;
}
self.mode = Mode::Normal;
self.status_message = format!("Saved: {}", path.display());
if let Some(entry) = self.pending_import_entry.take()
&& let Ok(rel) = path.strip_prefix(&root)
{
let rel_path = rel.to_string_lossy().to_string();
let gist_url = entry.url.clone();
self.store.insert(&root, rel_path, entry);
if let Err(e) = self.store.save() {
self.status_message = format!("Saved; store save failed: {e}");
} else {
self.status_message = format!("Saved and linked to {gist_url}");
}
}
if let Err(e) = self.refresh_files() {
self.status_message = format!("Saved but refresh failed: {e}");
}
}
fn git_root_or_warn(&mut self) -> Option<PathBuf> {
match self.git_repo_root.clone() {
Some(p) => Some(p),
None => {
self.status_message = "Not in a git repo (root has no .git ancestor).".into();
None
}
}
}
pub(crate) fn open_git_menu(&mut self) {
if self.git_repo_root.is_none() {
self.status_message = "Active root is not inside a git repository.".into();
return;
}
self.mode = Mode::GitMenu { selected: 0 };
}
pub(crate) fn do_git_status(&mut self) {
let Some(repo) = self.git_root_or_warn() else {
return;
};
self.pending_alias = Some(format!("git -C {} status", shell_quote(&repo)));
}
pub(crate) fn do_git_log(&mut self) {
let Some(repo) = self.git_root_or_warn() else {
return;
};
let Some(rel) = self.selected_file() else {
self.pending_alias = Some(format!("git -C {} log", shell_quote(&repo)));
return;
};
let abs = self.abs_path(&rel);
let repo_rel = abs
.strip_prefix(&repo)
.map(|p| p.to_path_buf())
.unwrap_or(abs);
self.pending_alias = Some(format!(
"git -C {} log -p -- {}",
shell_quote(&repo),
shell_quote(&repo_rel),
));
}
pub(crate) fn confirm_git_pull(&mut self) {
let Some(repo) = self.git_root_or_warn() else {
return;
};
let cmd = format!("git -C {} pull --rebase", shell_quote(&repo));
self.mode = Mode::Confirm {
message: format!("Run `git pull --rebase` in {}?", repo.display()),
action: super::ConfirmAction::RunShell { cmd },
};
}
pub(crate) fn confirm_git_push(&mut self) {
let Some(repo) = self.git_root_or_warn() else {
return;
};
let cmd = format!("git -C {} push", shell_quote(&repo));
self.mode = Mode::Confirm {
message: format!("Run `git push` in {}?", repo.display()),
action: super::ConfirmAction::RunShell { cmd },
};
}
pub fn bulk_options(&self) -> Vec<BulkAction> {
let mut push_dirty: Vec<String> = Vec::new();
let mut pull_newer: Vec<String> = Vec::new();
let mut format_json: Vec<String> = Vec::new();
if self.active_root_path().is_some() {
for f in &self.files {
match self.cached_status(&f.rel_path) {
sync::SyncStatus::LocalNewer
| sync::SyncStatus::Conflict
| sync::SyncStatus::NotGisted => push_dirty.push(f.rel_path.clone()),
sync::SyncStatus::RemoteNewer => pull_newer.push(f.rel_path.clone()),
sync::SyncStatus::Synced => {}
}
if f.rel_path.ends_with(".json")
&& let Ok(content) = std::fs::read_to_string(&f.abs_path)
&& let Ok(value) = serde_json::from_str::<serde_json::Value>(&content)
{
let compact = serde_json::to_string(&value).unwrap_or_default();
if content != compact && content != format!("{compact}\n") {
let pretty = serde_json::to_string_pretty(&value).unwrap_or_default();
if content != pretty && content != format!("{pretty}\n") {
format_json.push(f.rel_path.clone());
}
}
}
}
}
let mut orphans: Vec<String> = Vec::new();
if let Some(root) = self.active_root_path()
&& let Some(files_for_root) = self.store.files_for_root(&root)
{
let live: std::collections::HashSet<&str> =
self.files.iter().map(|f| f.rel_path.as_str()).collect();
for rel in files_for_root.keys() {
if !live.contains(rel.as_str()) {
orphans.push(rel.clone());
}
}
}
vec![
BulkAction::PushAllDirty { rels: push_dirty },
BulkAction::PullAllRemoteNewer { rels: pull_newer },
BulkAction::FormatAllJson { rels: format_json },
BulkAction::PruneOrphans { rels: orphans },
]
}
pub(crate) fn run_bulk(&mut self, action: BulkAction) {
match action {
BulkAction::PushAllDirty { rels } => {
let n = rels.len();
self.status_message = format!("Pushing {n} files...");
for rel in rels {
self.do_sync_up_for(rel, false);
}
}
BulkAction::PullAllRemoteNewer { rels } => {
let n = rels.len();
self.status_message = format!("Pulling {n} files...");
for rel in rels {
self.do_sync_down_for(rel);
}
}
BulkAction::FormatAllJson { rels } => {
let mut ok = 0usize;
let mut errs = 0usize;
for rel in &rels {
let abs = self.abs_path(rel);
let Ok(content) = std::fs::read_to_string(&abs) else {
errs += 1;
continue;
};
let Ok(value) = serde_json::from_str::<serde_json::Value>(&content) else {
errs += 1;
continue;
};
let pretty = serde_json::to_string_pretty(&value)
.map(|s| format!("{s}\n"))
.unwrap_or_default();
if pretty != content {
match std::fs::write(&abs, &pretty) {
Ok(()) => ok += 1,
Err(_) => errs += 1,
}
}
}
if let Err(e) = self.refresh_files() {
self.status_message = format!("Formatted {ok}; refresh failed: {e}");
return;
}
self.update_preview();
self.update_status();
self.status_message = if errs == 0 {
format!("Formatted {ok} JSON file(s).")
} else {
format!("Formatted {ok}; {errs} error(s).")
};
}
BulkAction::PruneOrphans { rels } => {
let Some(root) = self.active_root_path() else {
self.status_message = "No root.".into();
return;
};
let n = rels.len();
for rel in &rels {
self.store.remove(&root, rel);
}
if let Err(e) = self.store.save() {
self.status_message = format!("Pruned {n}; store save failed: {e}");
return;
}
self.update_status();
self.status_message = format!("Pruned {n} orphan(s).");
}
}
}
}
fn render_markdown_to_html(markdown: &str) -> String {
use pulldown_cmark::{Options, Parser, html};
let mut options = Options::empty();
options.insert(Options::ENABLE_TABLES);
options.insert(Options::ENABLE_STRIKETHROUGH);
options.insert(Options::ENABLE_TASKLISTS);
options.insert(Options::ENABLE_FOOTNOTES);
let parser = Parser::new_ext(markdown, options);
let mut out = String::new();
html::push_html(&mut out, parser);
out
}
#[cfg(test)]
mod tests {
use super::render_markdown_to_html;
#[test]
fn renders_headings_and_emphasis() {
let html = render_markdown_to_html("# Title\n\nSome **bold** and *italic*.");
assert!(html.contains("<h1>Title</h1>"));
assert!(html.contains("<strong>bold</strong>"));
assert!(html.contains("<em>italic</em>"));
}
#[test]
fn renders_lists_and_links() {
let html = render_markdown_to_html("- a\n- b\n\n[x](https://e.com)");
assert!(html.contains("<ul>"));
assert!(html.contains("<li>a</li>"));
assert!(html.contains("href=\"https://e.com\""));
}
#[test]
fn renders_tables_via_extension() {
let html = render_markdown_to_html("| a | b |\n|---|---|\n| 1 | 2 |");
assert!(html.contains("<table>"));
}
}