diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs deleted file mode 100644 index 68cbbcbd..00000000 --- a/src-tauri/src/commands.rs +++ /dev/null @@ -1,937 +0,0 @@ -use std::borrow::Cow; - -use crate::ai_chat::{AiChatRequest, AiChatResponse}; -#[cfg(desktop)] -use crate::claude_cli::ClaudeStreamEvent; -use crate::claude_cli::{AgentStreamRequest, ChatStreamRequest, ClaudeCliStatus}; -use crate::frontmatter::FrontmatterValue; -use crate::git::{ - GitCommit, GitPullResult, GitPushResult, GitRemoteStatus, LastCommitInfo, ModifiedFile, - PulseCommit, -}; -use crate::github::{DeviceFlowPollResult, DeviceFlowStart, GitHubUser, GithubRepo}; -#[cfg(desktop)] -use crate::menu; -use crate::search::SearchResponse; -use crate::settings::Settings; -use crate::vault::{RenameResult, VaultEntry}; -use crate::vault_list::VaultList; -use crate::{frontmatter, git, search, vault, vault_list}; - -/// Expand a leading `~` or `~/` in a path string to the user's home directory. -/// Returns the original string unchanged if it doesn't start with `~` or if the -/// home directory cannot be determined. -pub fn expand_tilde(path: &str) -> Cow<'_, str> { - if path == "~" { - if let Some(home) = dirs::home_dir() { - return Cow::Owned(home.to_string_lossy().into_owned()); - } - } else if let Some(rest) = path.strip_prefix("~/") { - if let Some(home) = dirs::home_dir() { - return Cow::Owned(format!("{}/{}", home.to_string_lossy(), rest)); - } - } - Cow::Borrowed(path) -} - -pub fn parse_build_label(version: &str) -> String { - let parts: Vec<&str> = version.split('.').collect(); - match parts.as_slice() { - [_, minor, patch] if minor.len() >= 6 => format!("b{}", patch), - [_, _, _] => "dev".to_string(), - _ => "b?".to_string(), - } -} - -// ── Vault commands ────────────────────────────────────────────────────────── - -#[tauri::command] -pub fn list_vault(path: String) -> Result, String> { - let path = expand_tilde(&path); - vault::scan_vault_cached(std::path::Path::new(path.as_ref())) -} - -#[tauri::command] -pub fn get_note_content(path: String) -> Result { - let path = expand_tilde(&path); - vault::get_note_content(std::path::Path::new(path.as_ref())) -} - -#[tauri::command] -pub fn save_note_content(path: String, content: String) -> Result<(), String> { - let path = expand_tilde(&path); - vault::save_note_content(&path, &content) -} - -#[tauri::command] -pub fn rename_note( - vault_path: String, - old_path: String, - new_title: String, - old_title: Option, -) -> Result { - let vault_path = expand_tilde(&vault_path); - let old_path = expand_tilde(&old_path); - vault::rename_note(&vault_path, &old_path, &new_title, old_title.as_deref()) -} - -#[tauri::command] -pub fn purge_trash(vault_path: String) -> Result, String> { - let vault_path = expand_tilde(&vault_path); - vault::purge_trash(&vault_path) -} - -#[tauri::command] -pub fn delete_note(path: String) -> Result { - let path = expand_tilde(&path); - vault::delete_note(&path) -} - -#[tauri::command] -pub fn batch_delete_notes(paths: Vec) -> Result, String> { - let expanded: Vec = paths.iter().map(|p| expand_tilde(p).into_owned()).collect(); - vault::batch_delete_notes(&expanded) -} - -#[tauri::command] -pub fn empty_trash(vault_path: String) -> Result, String> { - let vault_path = expand_tilde(&vault_path); - vault::empty_trash(&vault_path) -} - -#[tauri::command] -pub fn migrate_is_a_to_type(vault_path: String) -> Result { - let vault_path = expand_tilde(&vault_path); - vault::migrate_is_a_to_type(&vault_path) -} - -#[tauri::command] -pub fn flatten_vault(vault_path: String) -> Result { - let vault_path = expand_tilde(&vault_path); - vault::flatten_vault(&vault_path) -} - -#[tauri::command] -pub fn vault_health_check(vault_path: String) -> Result { - let vault_path = expand_tilde(&vault_path); - vault::vault_health_check(&vault_path) -} - -#[tauri::command] -pub fn create_getting_started_vault(target_path: Option) -> Result { - let path = match target_path { - Some(p) if !p.is_empty() => expand_tilde(&p).into_owned(), - _ => vault::default_vault_path()?.to_string_lossy().to_string(), - }; - vault::create_getting_started_vault(&path) -} - -#[tauri::command] -pub fn check_vault_exists(path: String) -> bool { - let path = expand_tilde(&path); - vault::vault_exists(&path) -} - -#[tauri::command] -pub fn get_default_vault_path() -> Result { - vault::default_vault_path().map(|p| p.to_string_lossy().to_string()) -} - -#[tauri::command] -pub async fn reload_vault(path: String) -> Result, String> { - let path = expand_tilde(&path).into_owned(); - tokio::task::spawn_blocking(move || { - vault::invalidate_cache(std::path::Path::new(&path)); - vault::scan_vault_cached(std::path::Path::new(&path)) - }) - .await - .map_err(|e| format!("Task panicked: {e}"))? -} - -#[tauri::command] -pub fn reload_vault_entry(path: String) -> Result { - let path = expand_tilde(&path); - vault::reload_entry(std::path::Path::new(path.as_ref())) -} - -/// Sync the `title` frontmatter field with the filename on note open. -/// Returns `true` if the file was modified (title was absent or desynced). -#[tauri::command] -pub fn sync_note_title(path: String) -> Result { - use vault::SyncAction; - let path = expand_tilde(&path); - let action = vault::sync_title_on_open(std::path::Path::new(path.as_ref()))?; - Ok(matches!(action, SyncAction::Updated { .. })) -} - -#[tauri::command] -pub fn save_image(vault_path: String, filename: String, data: String) -> Result { - let vault_path = expand_tilde(&vault_path); - vault::save_image(&vault_path, &filename, &data) -} - -#[tauri::command] -pub fn copy_image_to_vault(vault_path: String, source_path: String) -> Result { - let vault_path = expand_tilde(&vault_path); - vault::copy_image_to_vault(&vault_path, &source_path) -} - -// ── Frontmatter commands ──────────────────────────────────────────────────── - -#[tauri::command] -pub fn update_frontmatter( - path: String, - key: String, - value: FrontmatterValue, -) -> Result { - let path = expand_tilde(&path); - frontmatter::update_frontmatter(&path, &key, value) -} - -#[tauri::command] -pub fn delete_frontmatter_property(path: String, key: String) -> Result { - let path = expand_tilde(&path); - frontmatter::delete_frontmatter_property(&path, &key) -} - -#[tauri::command] -pub fn batch_archive_notes(paths: Vec) -> Result { - let mut count = 0; - for path in &paths { - let path = expand_tilde(path); - frontmatter::update_frontmatter(&path, "Archived", FrontmatterValue::Bool(true))?; - count += 1; - } - Ok(count) -} - -#[tauri::command] -pub fn batch_trash_notes(paths: Vec) -> Result { - let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); - let mut count = 0; - for path in &paths { - let path = expand_tilde(path); - frontmatter::update_frontmatter(&path, "Trashed", FrontmatterValue::Bool(true))?; - frontmatter::update_frontmatter( - &path, - "Trashed at", - FrontmatterValue::String(now.clone()), - )?; - count += 1; - } - Ok(count) -} - -// ── Git commands ──────────────────────────────────────────────────────────── - -#[cfg(desktop)] -#[tauri::command] -pub fn get_file_history(vault_path: String, path: String) -> Result, String> { - let vault_path = expand_tilde(&vault_path); - let path = expand_tilde(&path); - git::get_file_history(&vault_path, &path) -} - -#[cfg(desktop)] -#[tauri::command] -pub fn get_modified_files(vault_path: String) -> Result, String> { - let vault_path = expand_tilde(&vault_path); - git::get_modified_files(&vault_path) -} - -#[cfg(desktop)] -#[tauri::command] -pub fn get_file_diff(vault_path: String, path: String) -> Result { - let vault_path = expand_tilde(&vault_path); - let path = expand_tilde(&path); - git::get_file_diff(&vault_path, &path) -} - -#[cfg(desktop)] -#[tauri::command] -pub fn get_file_diff_at_commit( - vault_path: String, - path: String, - commit_hash: String, -) -> Result { - let vault_path = expand_tilde(&vault_path); - let path = expand_tilde(&path); - git::get_file_diff_at_commit(&vault_path, &path, &commit_hash) -} - -#[cfg(desktop)] -#[tauri::command] -pub fn get_vault_pulse( - vault_path: String, - limit: Option, - skip: Option, -) -> Result, String> { - let vault_path = expand_tilde(&vault_path); - let limit = limit.unwrap_or(20); - let skip = skip.unwrap_or(0); - git::get_vault_pulse(&vault_path, limit, skip) -} - -#[cfg(desktop)] -#[tauri::command] -pub fn git_commit(vault_path: String, message: String) -> Result { - let vault_path = expand_tilde(&vault_path); - git::git_commit(&vault_path, &message) -} - -#[cfg(desktop)] -#[tauri::command] -pub fn get_last_commit_info(vault_path: String) -> Result, String> { - let vault_path = expand_tilde(&vault_path); - git::get_last_commit_info(&vault_path) -} - -#[cfg(desktop)] -#[tauri::command] -pub async fn git_pull(vault_path: String) -> Result { - let vault_path = expand_tilde(&vault_path).into_owned(); - tokio::task::spawn_blocking(move || git::git_pull(&vault_path)) - .await - .map_err(|e| format!("Task panicked: {e}"))? -} - -#[cfg(desktop)] -#[tauri::command] -pub fn get_conflict_files(vault_path: String) -> Result, String> { - let vault_path = expand_tilde(&vault_path); - git::get_conflict_files(&vault_path) -} - -#[cfg(desktop)] -#[tauri::command] -pub fn get_conflict_mode(vault_path: String) -> String { - let vault_path = expand_tilde(&vault_path); - git::get_conflict_mode(&vault_path) -} - -#[cfg(desktop)] -#[tauri::command] -pub fn git_resolve_conflict( - vault_path: String, - file: String, - strategy: String, -) -> Result<(), String> { - let vault_path = expand_tilde(&vault_path); - git::git_resolve_conflict(&vault_path, &file, &strategy) -} - -#[cfg(desktop)] -#[tauri::command] -pub fn git_commit_conflict_resolution(vault_path: String) -> Result { - let vault_path = expand_tilde(&vault_path); - git::git_commit_conflict_resolution(&vault_path) -} - -#[cfg(desktop)] -#[tauri::command] -pub async fn git_push(vault_path: String) -> Result { - let vault_path = expand_tilde(&vault_path).into_owned(); - tokio::task::spawn_blocking(move || git::git_push(&vault_path)) - .await - .map_err(|e| format!("Task panicked: {e}"))? -} - -#[cfg(desktop)] -#[tauri::command] -pub async fn git_remote_status(vault_path: String) -> Result { - let vault_path = expand_tilde(&vault_path).into_owned(); - tokio::task::spawn_blocking(move || git::git_remote_status(&vault_path)) - .await - .map_err(|e| format!("Task panicked: {e}"))? -} - -// ── Git commands (mobile stubs) ───────────────────────────────────────────── - -#[cfg(mobile)] -#[tauri::command] -pub fn get_file_history(_vault_path: String, _path: String) -> Result, String> { - Err("Git history is not available on mobile".into()) -} - -#[cfg(mobile)] -#[tauri::command] -pub fn get_modified_files(_vault_path: String) -> Result, String> { - Ok(vec![]) -} - -#[cfg(mobile)] -#[tauri::command] -pub fn get_file_diff(_vault_path: String, _path: String) -> Result { - Err("Git diff is not available on mobile".into()) -} - -#[cfg(mobile)] -#[tauri::command] -pub fn get_file_diff_at_commit( - _vault_path: String, - _path: String, - _commit_hash: String, -) -> Result { - Err("Git diff is not available on mobile".into()) -} - -#[cfg(mobile)] -#[tauri::command] -pub fn get_vault_pulse( - _vault_path: String, - _limit: Option, - _skip: Option, -) -> Result, String> { - Ok(vec![]) -} - -#[cfg(mobile)] -#[tauri::command] -pub fn git_commit(_vault_path: String, _message: String) -> Result { - Err("Git commit is not available on mobile".into()) -} - -#[cfg(mobile)] -#[tauri::command] -pub fn get_last_commit_info(_vault_path: String) -> Result, String> { - Ok(None) -} - -#[cfg(mobile)] -#[tauri::command] -pub async fn git_pull(_vault_path: String) -> Result { - Err("Git pull is not available on mobile".into()) -} - -#[cfg(mobile)] -#[tauri::command] -pub fn get_conflict_files(_vault_path: String) -> Result, String> { - Ok(vec![]) -} - -#[cfg(mobile)] -#[tauri::command] -pub fn get_conflict_mode(_vault_path: String) -> String { - "none".to_string() -} - -#[cfg(mobile)] -#[tauri::command] -pub fn git_resolve_conflict( - _vault_path: String, - _file: String, - _strategy: String, -) -> Result<(), String> { - Err("Git conflict resolution is not available on mobile".into()) -} - -#[cfg(mobile)] -#[tauri::command] -pub fn git_commit_conflict_resolution(_vault_path: String) -> Result { - Err("Git conflict resolution is not available on mobile".into()) -} - -#[cfg(mobile)] -#[tauri::command] -pub async fn git_push(_vault_path: String) -> Result { - Err("Git push is not available on mobile".into()) -} - -#[cfg(mobile)] -#[tauri::command] -pub async fn git_remote_status(_vault_path: String) -> Result { - Ok(GitRemoteStatus { - branch: String::new(), - has_remote: false, - ahead: 0, - behind: 0, - }) -} - -// ── GitHub commands ───────────────────────────────────────────────────────── - -#[cfg(desktop)] -#[tauri::command] -pub async fn github_list_repos(token: String) -> Result, String> { - crate::github::github_list_repos(&token).await -} - -#[cfg(desktop)] -#[tauri::command] -pub async fn github_create_repo( - token: String, - name: String, - private: bool, -) -> Result { - crate::github::github_create_repo(&token, &name, private).await -} - -#[cfg(desktop)] -#[tauri::command] -pub fn clone_repo(url: String, token: String, local_path: String) -> Result { - let local_path = expand_tilde(&local_path); - crate::github::clone_repo(&url, &token, &local_path) -} - -#[cfg(desktop)] -#[tauri::command] -pub async fn github_device_flow_start() -> Result { - crate::github::github_device_flow_start().await -} - -#[cfg(desktop)] -#[tauri::command] -pub async fn github_device_flow_poll(device_code: String) -> Result { - crate::github::github_device_flow_poll(&device_code).await -} - -#[cfg(desktop)] -#[tauri::command] -pub async fn github_get_user(token: String) -> Result { - crate::github::github_get_user(&token).await -} - -// ── GitHub commands (mobile stubs) ────────────────────────────────────────── - -#[cfg(mobile)] -#[tauri::command] -pub async fn github_list_repos(_token: String) -> Result, String> { - Err("GitHub integration is not available on mobile".into()) -} - -#[cfg(mobile)] -#[tauri::command] -pub async fn github_create_repo( - _token: String, - _name: String, - _private: bool, -) -> Result { - Err("GitHub integration is not available on mobile".into()) -} - -#[cfg(mobile)] -#[tauri::command] -pub fn clone_repo(_url: String, _token: String, _local_path: String) -> Result { - Err("Git clone is not available on mobile".into()) -} - -#[cfg(mobile)] -#[tauri::command] -pub async fn github_device_flow_start() -> Result { - Err("GitHub integration is not available on mobile".into()) -} - -#[cfg(mobile)] -#[tauri::command] -pub async fn github_device_flow_poll(_device_code: String) -> Result { - Err("GitHub integration is not available on mobile".into()) -} - -#[cfg(mobile)] -#[tauri::command] -pub async fn github_get_user(_token: String) -> Result { - Err("GitHub integration is not available on mobile".into()) -} - -// ── AI / Claude commands ──────────────────────────────────────────────────── - -#[tauri::command] -pub async fn ai_chat(request: AiChatRequest) -> Result { - crate::ai_chat::send_chat(request).await -} - -#[cfg(desktop)] -#[tauri::command] -pub fn check_claude_cli() -> ClaudeCliStatus { - crate::claude_cli::check_cli() -} - -#[cfg(desktop)] -#[tauri::command] -pub async fn stream_claude_chat( - app_handle: tauri::AppHandle, - request: ChatStreamRequest, -) -> Result { - use tauri::Emitter; - tokio::task::spawn_blocking(move || { - crate::claude_cli::run_chat_stream(request, |event: ClaudeStreamEvent| { - let _ = app_handle.emit("claude-stream", &event); - }) - }) - .await - .map_err(|e| format!("Task failed: {e}"))? -} - -#[cfg(desktop)] -#[tauri::command] -pub async fn stream_claude_agent( - app_handle: tauri::AppHandle, - request: AgentStreamRequest, -) -> Result { - use tauri::Emitter; - tokio::task::spawn_blocking(move || { - crate::claude_cli::run_agent_stream(request, |event: ClaudeStreamEvent| { - let _ = app_handle.emit("claude-agent-stream", &event); - }) - }) - .await - .map_err(|e| format!("Task failed: {e}"))? -} - -// ── Claude CLI (mobile stubs) ─────────────────────────────────────────────── - -#[cfg(mobile)] -#[tauri::command] -pub fn check_claude_cli() -> ClaudeCliStatus { - ClaudeCliStatus { - installed: false, - version: None, - } -} - -#[cfg(mobile)] -#[tauri::command] -pub async fn stream_claude_chat( - _app_handle: tauri::AppHandle, - _request: ChatStreamRequest, -) -> Result { - Err("Claude CLI is not available on mobile".into()) -} - -#[cfg(mobile)] -#[tauri::command] -pub async fn stream_claude_agent( - _app_handle: tauri::AppHandle, - _request: AgentStreamRequest, -) -> Result { - Err("Claude CLI is not available on mobile".into()) -} - -// ── Search commands ───────────────────────────────────────────────────────── - -#[tauri::command] -pub async fn search_vault( - vault_path: String, - query: String, - mode: String, - limit: Option, -) -> Result { - let vault_path = expand_tilde(&vault_path).into_owned(); - let limit = limit.unwrap_or(20); - tokio::task::spawn_blocking(move || search::search_vault(&vault_path, &query, &mode, limit)) - .await - .map_err(|e| format!("Search task failed: {}", e))? -} - -// ── MCP commands ──────────────────────────────────────────────────────────── - -#[cfg(desktop)] -#[tauri::command] -pub async fn register_mcp_tools(vault_path: String) -> Result { - let vault_path = expand_tilde(&vault_path).into_owned(); - tokio::task::spawn_blocking(move || crate::mcp::register_mcp(&vault_path)) - .await - .map_err(|e| format!("Registration task failed: {e}"))? -} - -#[cfg(desktop)] -#[tauri::command] -pub async fn check_mcp_status() -> Result { - tokio::task::spawn_blocking(crate::mcp::check_mcp_status) - .await - .map_err(|e| format!("MCP status check failed: {e}")) -} - -#[cfg(mobile)] -#[tauri::command] -pub async fn register_mcp_tools(_vault_path: String) -> Result { - Err("MCP is not available on mobile".into()) -} - -#[cfg(mobile)] -#[tauri::command] -pub async fn check_mcp_status() -> Result { - Ok(crate::mcp::McpStatus::NotInstalled) -} - -#[tauri::command] -pub fn repair_vault(vault_path: String) -> Result { - let vault_path = expand_tilde(&vault_path); - // Migrate legacy is_a/Is A frontmatter → type - vault::migrate_is_a_to_type(&vault_path)?; - // Flatten vault: move notes from type-based subfolders to root - vault::flatten_vault(&vault_path)?; - // Repair config files (AGENTS.md at root, config.md type def) - vault::repair_config_files(&vault_path)?; - // Ensure .gitignore with sensible defaults exists - git::ensure_gitignore(&vault_path)?; - Ok("Vault repaired".to_string()) -} - -// ── Settings & config commands ────────────────────────────────────────────── - -#[tauri::command] -pub fn get_build_number(app_handle: tauri::AppHandle) -> String { - let version = app_handle.package_info().version.to_string(); - parse_build_label(&version) -} - -#[cfg(desktop)] -#[tauri::command] -pub fn update_menu_state( - app_handle: tauri::AppHandle, - has_active_note: bool, - has_modified_files: Option, - has_conflicts: Option, -) -> Result<(), String> { - menu::set_note_items_enabled(&app_handle, has_active_note); - if let Some(v) = has_modified_files { - menu::set_git_commit_items_enabled(&app_handle, v); - } - if let Some(v) = has_conflicts { - menu::set_git_conflict_items_enabled(&app_handle, v); - } - Ok(()) -} - -#[cfg(mobile)] -#[tauri::command] -pub fn update_menu_state( - _app_handle: tauri::AppHandle, - _has_active_note: bool, - _has_modified_files: Option, - _has_conflicts: Option, -) -> Result<(), String> { - Ok(()) -} - -#[tauri::command] -pub fn get_settings() -> Result { - crate::settings::get_settings() -} - -#[tauri::command] -pub fn save_settings(settings: Settings) -> Result<(), String> { - crate::settings::save_settings(settings) -} - -#[tauri::command] -pub fn reinit_telemetry() { - crate::telemetry::reinit_sentry(); -} - -#[tauri::command] -pub fn load_vault_list() -> Result { - vault_list::load_vault_list() -} - -#[tauri::command] -pub fn save_vault_list(list: VaultList) -> Result<(), String> { - vault_list::save_vault_list(&list) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn expand_tilde_with_subpath() { - let home = dirs::home_dir().unwrap(); - let result = expand_tilde("~/Documents/vault"); - assert_eq!(result, format!("{}/Documents/vault", home.display())); - } - - #[test] - fn expand_tilde_alone() { - let home = dirs::home_dir().unwrap(); - let result = expand_tilde("~"); - assert_eq!(result, home.to_string_lossy()); - } - - #[test] - fn expand_tilde_noop_for_absolute_path() { - let result = expand_tilde("/usr/local/bin"); - assert_eq!(result, "/usr/local/bin"); - } - - #[test] - fn expand_tilde_noop_for_relative_path() { - let result = expand_tilde("some/relative/path"); - assert_eq!(result, "some/relative/path"); - } - - #[test] - fn expand_tilde_noop_for_tilde_in_middle() { - let result = expand_tilde("/home/~user/path"); - assert_eq!(result, "/home/~user/path"); - } - - #[test] - fn parse_build_label_release_version() { - assert_eq!(parse_build_label("0.20260303.281"), "b281"); - assert_eq!(parse_build_label("0.20251215.42"), "b42"); - } - - #[test] - fn parse_build_label_dev_version() { - assert_eq!(parse_build_label("0.1.0"), "dev"); - assert_eq!(parse_build_label("0.0.0"), "dev"); - } - - #[test] - fn parse_build_label_malformed() { - assert_eq!(parse_build_label("invalid"), "b?"); - assert_eq!(parse_build_label(""), "b?"); - } - - #[test] - fn test_batch_archive_notes() { - let dir = tempfile::TempDir::new().unwrap(); - let note = dir.path().join("note.md"); - std::fs::write(¬e, "---\nStatus: Active\n---\n# Note\n").unwrap(); - - let result = batch_archive_notes(vec![note.to_str().unwrap().to_string()]); - assert_eq!(result.unwrap(), 1); - - let content = std::fs::read_to_string(¬e).unwrap(); - assert!(content.contains("Archived: true")); - assert!(content.contains("Status: Active")); - } - - #[test] - fn test_batch_trash_notes() { - let dir = tempfile::TempDir::new().unwrap(); - let note = dir.path().join("note.md"); - std::fs::write(¬e, "---\nStatus: Active\n---\n# Note\n").unwrap(); - - let result = batch_trash_notes(vec![note.to_str().unwrap().to_string()]); - assert_eq!(result.unwrap(), 1); - - let content = std::fs::read_to_string(¬e).unwrap(); - assert!(content.contains("Trashed: true")); - assert!(content.contains("Trashed at")); - } - - #[test] - fn test_reload_vault_entry_reads_from_disk() { - let dir = tempfile::TempDir::new().unwrap(); - let note = dir.path().join("test.md"); - std::fs::write(¬e, "---\ntitle: Test\nStatus: Active\n---\n# Test\n").unwrap(); - - let entry = reload_vault_entry(note.to_str().unwrap().to_string()).unwrap(); - assert_eq!(entry.title, "Test"); - assert_eq!(entry.status, Some("Active".to_string())); - - // Modify file on disk - std::fs::write(¬e, "---\ntitle: Test\nStatus: Done\n---\n# Test\n").unwrap(); - let fresh = reload_vault_entry(note.to_str().unwrap().to_string()).unwrap(); - assert_eq!(fresh.status, Some("Done".to_string())); - } - - #[test] - fn test_reload_vault_entry_nonexistent() { - let result = reload_vault_entry("/nonexistent/path.md".to_string()); - assert!(result.is_err()); - } - - #[test] - fn test_reload_vault_invalidates_cache_and_rescans() { - let dir = tempfile::TempDir::new().unwrap(); - let vault = dir.path(); - // Init git repo for caching to work - std::process::Command::new("git") - .args(["init"]) - .current_dir(vault) - .output() - .unwrap(); - std::process::Command::new("git") - .args(["config", "user.email", "t@t.com"]) - .current_dir(vault) - .output() - .unwrap(); - std::process::Command::new("git") - .args(["config", "user.name", "T"]) - .current_dir(vault) - .output() - .unwrap(); - - // Set test cache dir to avoid polluting real cache - let cache_dir = tempfile::TempDir::new().unwrap(); - std::env::set_var( - "LAPUTA_CACHE_DIR", - cache_dir.path().to_string_lossy().as_ref(), - ); - - std::fs::write(vault.join("note.md"), "---\nTrashed: false\n---\n# Note\n").unwrap(); - std::process::Command::new("git") - .args(["add", "."]) - .current_dir(vault) - .output() - .unwrap(); - std::process::Command::new("git") - .args(["commit", "-m", "init"]) - .current_dir(vault) - .output() - .unwrap(); - - // Prime cache via list_vault - let entries = list_vault(vault.to_str().unwrap().to_string()).unwrap(); - assert!(!entries[0].trashed); - - // Trash the note on disk - std::fs::write(vault.join("note.md"), "---\nTrashed: true\n---\n# Note\n").unwrap(); - - // reload_vault must return the updated trashed state - let vault_str = vault.to_str().unwrap(); - vault::invalidate_cache(std::path::Path::new(vault_str)); - let fresh = vault::scan_vault_cached(std::path::Path::new(vault_str)).unwrap(); - assert!( - fresh[0].trashed, - "reload_vault must reflect disk state after trashing" - ); - } - - #[test] - fn test_check_vault_exists_false() { - assert!(!check_vault_exists("/nonexistent/path/abc123".to_string())); - } - - #[test] - fn test_get_default_vault_path_returns_ok() { - let result = get_default_vault_path(); - assert!(result.is_ok()); - } - - #[test] - fn test_repair_vault_flattens_type_folders() { - let dir = tempfile::TempDir::new().unwrap(); - let vault = dir.path(); - let note_dir = vault.join("note"); - std::fs::create_dir_all(¬e_dir).unwrap(); - std::fs::write(note_dir.join("hello.md"), "---\nis_a: Note\n---\n# Hello\n").unwrap(); - - let result = repair_vault(vault.to_str().unwrap().to_string()); - assert!(result.is_ok()); - // Note moved from note/ subfolder to root - assert!(vault.join("hello.md").exists()); - assert!(!note_dir.join("hello.md").exists()); - // Legacy is_a migrated to type - let content = std::fs::read_to_string(vault.join("hello.md")).unwrap(); - assert!(content.contains("type: Note")); - assert!(!content.contains("is_a:")); - } - - #[test] - fn test_repair_vault_creates_config_files() { - let dir = tempfile::TempDir::new().unwrap(); - let vault = dir.path(); - - let result = repair_vault(vault.to_str().unwrap().to_string()); - assert!(result.is_ok()); - // Config files at root - assert!(vault.join("AGENTS.md").exists()); - assert!(vault.join("config.md").exists()); - // .gitignore - assert!(vault.join(".gitignore").exists()); - } -} diff --git a/src-tauri/src/commands/ai.rs b/src-tauri/src/commands/ai.rs new file mode 100644 index 00000000..ada1cf16 --- /dev/null +++ b/src-tauri/src/commands/ai.rs @@ -0,0 +1,78 @@ +use crate::ai_chat::{AiChatRequest, AiChatResponse}; +#[cfg(desktop)] +use crate::claude_cli::ClaudeStreamEvent; +use crate::claude_cli::{AgentStreamRequest, ChatStreamRequest, ClaudeCliStatus}; + +// ── AI / Claude commands (desktop) ────────────────────────────────────────── + +#[tauri::command] +pub async fn ai_chat(request: AiChatRequest) -> Result { + crate::ai_chat::send_chat(request).await +} + +#[cfg(desktop)] +#[tauri::command] +pub fn check_claude_cli() -> ClaudeCliStatus { + crate::claude_cli::check_cli() +} + +#[cfg(desktop)] +#[tauri::command] +pub async fn stream_claude_chat( + app_handle: tauri::AppHandle, + request: ChatStreamRequest, +) -> Result { + use tauri::Emitter; + tokio::task::spawn_blocking(move || { + crate::claude_cli::run_chat_stream(request, |event: ClaudeStreamEvent| { + let _ = app_handle.emit("claude-stream", &event); + }) + }) + .await + .map_err(|e| format!("Task failed: {e}"))? +} + +#[cfg(desktop)] +#[tauri::command] +pub async fn stream_claude_agent( + app_handle: tauri::AppHandle, + request: AgentStreamRequest, +) -> Result { + use tauri::Emitter; + tokio::task::spawn_blocking(move || { + crate::claude_cli::run_agent_stream(request, |event: ClaudeStreamEvent| { + let _ = app_handle.emit("claude-agent-stream", &event); + }) + }) + .await + .map_err(|e| format!("Task failed: {e}"))? +} + +// ── Claude CLI (mobile stubs) ─────────────────────────────────────────────── + +#[cfg(mobile)] +#[tauri::command] +pub fn check_claude_cli() -> ClaudeCliStatus { + ClaudeCliStatus { + installed: false, + version: None, + } +} + +#[cfg(mobile)] +#[tauri::command] +pub async fn stream_claude_chat( + _app_handle: tauri::AppHandle, + _request: ChatStreamRequest, +) -> Result { + Err("Claude CLI is not available on mobile".into()) +} + +#[cfg(mobile)] +#[tauri::command] +pub async fn stream_claude_agent( + _app_handle: tauri::AppHandle, + _request: AgentStreamRequest, +) -> Result { + Err("Claude CLI is not available on mobile".into()) +} diff --git a/src-tauri/src/commands/git.rs b/src-tauri/src/commands/git.rs new file mode 100644 index 00000000..1b2737c8 --- /dev/null +++ b/src-tauri/src/commands/git.rs @@ -0,0 +1,318 @@ +use crate::git::{ + GitCommit, GitPullResult, GitPushResult, GitRemoteStatus, LastCommitInfo, ModifiedFile, + PulseCommit, +}; +use crate::github::{DeviceFlowPollResult, DeviceFlowStart, GitHubUser, GithubRepo}; + +use super::expand_tilde; + +// ── Git commands (desktop) ────────────────────────────────────────────────── + +#[cfg(desktop)] +#[tauri::command] +pub fn get_file_history(vault_path: String, path: String) -> Result, String> { + let vault_path = expand_tilde(&vault_path); + let path = expand_tilde(&path); + crate::git::get_file_history(&vault_path, &path) +} + +#[cfg(desktop)] +#[tauri::command] +pub fn get_modified_files(vault_path: String) -> Result, String> { + let vault_path = expand_tilde(&vault_path); + crate::git::get_modified_files(&vault_path) +} + +#[cfg(desktop)] +#[tauri::command] +pub fn get_file_diff(vault_path: String, path: String) -> Result { + let vault_path = expand_tilde(&vault_path); + let path = expand_tilde(&path); + crate::git::get_file_diff(&vault_path, &path) +} + +#[cfg(desktop)] +#[tauri::command] +pub fn get_file_diff_at_commit( + vault_path: String, + path: String, + commit_hash: String, +) -> Result { + let vault_path = expand_tilde(&vault_path); + let path = expand_tilde(&path); + crate::git::get_file_diff_at_commit(&vault_path, &path, &commit_hash) +} + +#[cfg(desktop)] +#[tauri::command] +pub fn get_vault_pulse( + vault_path: String, + limit: Option, + skip: Option, +) -> Result, String> { + let vault_path = expand_tilde(&vault_path); + let limit = limit.unwrap_or(20); + let skip = skip.unwrap_or(0); + crate::git::get_vault_pulse(&vault_path, limit, skip) +} + +#[cfg(desktop)] +#[tauri::command] +pub fn git_commit(vault_path: String, message: String) -> Result { + let vault_path = expand_tilde(&vault_path); + crate::git::git_commit(&vault_path, &message) +} + +#[cfg(desktop)] +#[tauri::command] +pub fn get_last_commit_info(vault_path: String) -> Result, String> { + let vault_path = expand_tilde(&vault_path); + crate::git::get_last_commit_info(&vault_path) +} + +#[cfg(desktop)] +#[tauri::command] +pub async fn git_pull(vault_path: String) -> Result { + let vault_path = expand_tilde(&vault_path).into_owned(); + tokio::task::spawn_blocking(move || crate::git::git_pull(&vault_path)) + .await + .map_err(|e| format!("Task panicked: {e}"))? +} + +#[cfg(desktop)] +#[tauri::command] +pub fn get_conflict_files(vault_path: String) -> Result, String> { + let vault_path = expand_tilde(&vault_path); + crate::git::get_conflict_files(&vault_path) +} + +#[cfg(desktop)] +#[tauri::command] +pub fn get_conflict_mode(vault_path: String) -> String { + let vault_path = expand_tilde(&vault_path); + crate::git::get_conflict_mode(&vault_path) +} + +#[cfg(desktop)] +#[tauri::command] +pub fn git_resolve_conflict( + vault_path: String, + file: String, + strategy: String, +) -> Result<(), String> { + let vault_path = expand_tilde(&vault_path); + crate::git::git_resolve_conflict(&vault_path, &file, &strategy) +} + +#[cfg(desktop)] +#[tauri::command] +pub fn git_commit_conflict_resolution(vault_path: String) -> Result { + let vault_path = expand_tilde(&vault_path); + crate::git::git_commit_conflict_resolution(&vault_path) +} + +#[cfg(desktop)] +#[tauri::command] +pub async fn git_push(vault_path: String) -> Result { + let vault_path = expand_tilde(&vault_path).into_owned(); + tokio::task::spawn_blocking(move || crate::git::git_push(&vault_path)) + .await + .map_err(|e| format!("Task panicked: {e}"))? +} + +#[cfg(desktop)] +#[tauri::command] +pub async fn git_remote_status(vault_path: String) -> Result { + let vault_path = expand_tilde(&vault_path).into_owned(); + tokio::task::spawn_blocking(move || crate::git::git_remote_status(&vault_path)) + .await + .map_err(|e| format!("Task panicked: {e}"))? +} + +// ── Git commands (mobile stubs) ───────────────────────────────────────────── + +#[cfg(mobile)] +#[tauri::command] +pub fn get_file_history(_vault_path: String, _path: String) -> Result, String> { + Err("Git history is not available on mobile".into()) +} + +#[cfg(mobile)] +#[tauri::command] +pub fn get_modified_files(_vault_path: String) -> Result, String> { + Ok(vec![]) +} + +#[cfg(mobile)] +#[tauri::command] +pub fn get_file_diff(_vault_path: String, _path: String) -> Result { + Err("Git diff is not available on mobile".into()) +} + +#[cfg(mobile)] +#[tauri::command] +pub fn get_file_diff_at_commit( + _vault_path: String, + _path: String, + _commit_hash: String, +) -> Result { + Err("Git diff is not available on mobile".into()) +} + +#[cfg(mobile)] +#[tauri::command] +pub fn get_vault_pulse( + _vault_path: String, + _limit: Option, + _skip: Option, +) -> Result, String> { + Ok(vec![]) +} + +#[cfg(mobile)] +#[tauri::command] +pub fn git_commit(_vault_path: String, _message: String) -> Result { + Err("Git commit is not available on mobile".into()) +} + +#[cfg(mobile)] +#[tauri::command] +pub fn get_last_commit_info(_vault_path: String) -> Result, String> { + Ok(None) +} + +#[cfg(mobile)] +#[tauri::command] +pub async fn git_pull(_vault_path: String) -> Result { + Err("Git pull is not available on mobile".into()) +} + +#[cfg(mobile)] +#[tauri::command] +pub fn get_conflict_files(_vault_path: String) -> Result, String> { + Ok(vec![]) +} + +#[cfg(mobile)] +#[tauri::command] +pub fn get_conflict_mode(_vault_path: String) -> String { + "none".to_string() +} + +#[cfg(mobile)] +#[tauri::command] +pub fn git_resolve_conflict( + _vault_path: String, + _file: String, + _strategy: String, +) -> Result<(), String> { + Err("Git conflict resolution is not available on mobile".into()) +} + +#[cfg(mobile)] +#[tauri::command] +pub fn git_commit_conflict_resolution(_vault_path: String) -> Result { + Err("Git conflict resolution is not available on mobile".into()) +} + +#[cfg(mobile)] +#[tauri::command] +pub async fn git_push(_vault_path: String) -> Result { + Err("Git push is not available on mobile".into()) +} + +#[cfg(mobile)] +#[tauri::command] +pub async fn git_remote_status(_vault_path: String) -> Result { + Ok(GitRemoteStatus { + branch: String::new(), + has_remote: false, + ahead: 0, + behind: 0, + }) +} + +// ── GitHub commands (desktop) ─────────────────────────────────────────────── + +#[cfg(desktop)] +#[tauri::command] +pub async fn github_list_repos(token: String) -> Result, String> { + crate::github::github_list_repos(&token).await +} + +#[cfg(desktop)] +#[tauri::command] +pub async fn github_create_repo( + token: String, + name: String, + private: bool, +) -> Result { + crate::github::github_create_repo(&token, &name, private).await +} + +#[cfg(desktop)] +#[tauri::command] +pub fn clone_repo(url: String, token: String, local_path: String) -> Result { + let local_path = expand_tilde(&local_path); + crate::github::clone_repo(&url, &token, &local_path) +} + +#[cfg(desktop)] +#[tauri::command] +pub async fn github_device_flow_start() -> Result { + crate::github::github_device_flow_start().await +} + +#[cfg(desktop)] +#[tauri::command] +pub async fn github_device_flow_poll(device_code: String) -> Result { + crate::github::github_device_flow_poll(&device_code).await +} + +#[cfg(desktop)] +#[tauri::command] +pub async fn github_get_user(token: String) -> Result { + crate::github::github_get_user(&token).await +} + +// ── GitHub commands (mobile stubs) ────────────────────────────────────────── + +#[cfg(mobile)] +#[tauri::command] +pub async fn github_list_repos(_token: String) -> Result, String> { + Err("GitHub integration is not available on mobile".into()) +} + +#[cfg(mobile)] +#[tauri::command] +pub async fn github_create_repo( + _token: String, + _name: String, + _private: bool, +) -> Result { + Err("GitHub integration is not available on mobile".into()) +} + +#[cfg(mobile)] +#[tauri::command] +pub fn clone_repo(_url: String, _token: String, _local_path: String) -> Result { + Err("Git clone is not available on mobile".into()) +} + +#[cfg(mobile)] +#[tauri::command] +pub async fn github_device_flow_start() -> Result { + Err("GitHub integration is not available on mobile".into()) +} + +#[cfg(mobile)] +#[tauri::command] +pub async fn github_device_flow_poll(_device_code: String) -> Result { + Err("GitHub integration is not available on mobile".into()) +} + +#[cfg(mobile)] +#[tauri::command] +pub async fn github_get_user(_token: String) -> Result { + Err("GitHub integration is not available on mobile".into()) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs new file mode 100644 index 00000000..f158c3f3 --- /dev/null +++ b/src-tauri/src/commands/mod.rs @@ -0,0 +1,254 @@ +mod ai; +mod git; +mod system; +mod vault; + +use std::borrow::Cow; + +pub use ai::*; +pub use git::*; +pub use system::*; +pub use vault::*; + +/// Expand a leading `~` or `~/` in a path string to the user's home directory. +/// Returns the original string unchanged if it doesn't start with `~` or if the +/// home directory cannot be determined. +pub fn expand_tilde(path: &str) -> Cow<'_, str> { + if path == "~" { + if let Some(home) = dirs::home_dir() { + return Cow::Owned(home.to_string_lossy().into_owned()); + } + } else if let Some(rest) = path.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + return Cow::Owned(format!("{}/{}", home.to_string_lossy(), rest)); + } + } + Cow::Borrowed(path) +} + +pub fn parse_build_label(version: &str) -> String { + let parts: Vec<&str> = version.split('.').collect(); + match parts.as_slice() { + [_, minor, patch] if minor.len() >= 6 => format!("b{}", patch), + [_, _, _] => "dev".to_string(), + _ => "b?".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn expand_tilde_with_subpath() { + let home = dirs::home_dir().unwrap(); + let result = expand_tilde("~/Documents/vault"); + assert_eq!(result, format!("{}/Documents/vault", home.display())); + } + + #[test] + fn expand_tilde_alone() { + let home = dirs::home_dir().unwrap(); + let result = expand_tilde("~"); + assert_eq!(result, home.to_string_lossy()); + } + + #[test] + fn expand_tilde_noop_for_absolute_path() { + let result = expand_tilde("/usr/local/bin"); + assert_eq!(result, "/usr/local/bin"); + } + + #[test] + fn expand_tilde_noop_for_relative_path() { + let result = expand_tilde("some/relative/path"); + assert_eq!(result, "some/relative/path"); + } + + #[test] + fn expand_tilde_noop_for_tilde_in_middle() { + let result = expand_tilde("/home/~user/path"); + assert_eq!(result, "/home/~user/path"); + } + + #[test] + fn parse_build_label_release_version() { + assert_eq!(parse_build_label("0.20260303.281"), "b281"); + assert_eq!(parse_build_label("0.20251215.42"), "b42"); + } + + #[test] + fn parse_build_label_dev_version() { + assert_eq!(parse_build_label("0.1.0"), "dev"); + assert_eq!(parse_build_label("0.0.0"), "dev"); + } + + #[test] + fn parse_build_label_malformed() { + assert_eq!(parse_build_label("invalid"), "b?"); + assert_eq!(parse_build_label(""), "b?"); + } + + #[test] + fn test_batch_archive_notes() { + let dir = tempfile::TempDir::new().unwrap(); + let note = dir.path().join("note.md"); + std::fs::write(¬e, "---\nStatus: Active\n---\n# Note\n").unwrap(); + + let result = batch_archive_notes(vec![note.to_str().unwrap().to_string()]); + assert_eq!(result.unwrap(), 1); + + let content = std::fs::read_to_string(¬e).unwrap(); + assert!(content.contains("Archived: true")); + assert!(content.contains("Status: Active")); + } + + #[test] + fn test_batch_trash_notes() { + let dir = tempfile::TempDir::new().unwrap(); + let note = dir.path().join("note.md"); + std::fs::write(¬e, "---\nStatus: Active\n---\n# Note\n").unwrap(); + + let result = batch_trash_notes(vec![note.to_str().unwrap().to_string()]); + assert_eq!(result.unwrap(), 1); + + let content = std::fs::read_to_string(¬e).unwrap(); + assert!(content.contains("Trashed: true")); + assert!(content.contains("Trashed at")); + } + + #[test] + fn test_reload_vault_entry_reads_from_disk() { + let dir = tempfile::TempDir::new().unwrap(); + let note = dir.path().join("test.md"); + std::fs::write(¬e, "---\ntitle: Test\nStatus: Active\n---\n# Test\n").unwrap(); + + let entry = reload_vault_entry(note.to_str().unwrap().to_string()).unwrap(); + assert_eq!(entry.title, "Test"); + assert_eq!(entry.status, Some("Active".to_string())); + + // Modify file on disk + std::fs::write(¬e, "---\ntitle: Test\nStatus: Done\n---\n# Test\n").unwrap(); + let fresh = reload_vault_entry(note.to_str().unwrap().to_string()).unwrap(); + assert_eq!(fresh.status, Some("Done".to_string())); + } + + #[test] + fn test_reload_vault_entry_nonexistent() { + let result = reload_vault_entry("/nonexistent/path.md".to_string()); + assert!(result.is_err()); + } + + #[test] + fn test_reload_vault_invalidates_cache_and_rescans() { + let dir = tempfile::TempDir::new().unwrap(); + let vault_path = dir.path(); + // Init git repo for caching to work + std::process::Command::new("git") + .args(["init"]) + .current_dir(vault_path) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["config", "user.email", "t@t.com"]) + .current_dir(vault_path) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["config", "user.name", "T"]) + .current_dir(vault_path) + .output() + .unwrap(); + + // Set test cache dir to avoid polluting real cache + let cache_dir = tempfile::TempDir::new().unwrap(); + std::env::set_var( + "LAPUTA_CACHE_DIR", + cache_dir.path().to_string_lossy().as_ref(), + ); + + std::fs::write( + vault_path.join("note.md"), + "---\nTrashed: false\n---\n# Note\n", + ) + .unwrap(); + std::process::Command::new("git") + .args(["add", "."]) + .current_dir(vault_path) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["commit", "-m", "init"]) + .current_dir(vault_path) + .output() + .unwrap(); + + // Prime cache via list_vault + let entries = list_vault(vault_path.to_str().unwrap().to_string()).unwrap(); + assert!(!entries[0].trashed); + + // Trash the note on disk + std::fs::write( + vault_path.join("note.md"), + "---\nTrashed: true\n---\n# Note\n", + ) + .unwrap(); + + // reload_vault must return the updated trashed state + let vp_str = vault_path.to_str().unwrap(); + crate::vault::invalidate_cache(std::path::Path::new(vp_str)); + let fresh = crate::vault::scan_vault_cached(std::path::Path::new(vp_str)).unwrap(); + assert!( + fresh[0].trashed, + "reload_vault must reflect disk state after trashing" + ); + } + + #[test] + fn test_check_vault_exists_false() { + assert!(!check_vault_exists("/nonexistent/path/abc123".to_string())); + } + + #[test] + fn test_get_default_vault_path_returns_ok() { + let result = get_default_vault_path(); + assert!(result.is_ok()); + } + + #[test] + fn test_repair_vault_flattens_type_folders() { + let dir = tempfile::TempDir::new().unwrap(); + let vault_path = dir.path(); + let note_dir = vault_path.join("note"); + std::fs::create_dir_all(¬e_dir).unwrap(); + std::fs::write( + note_dir.join("hello.md"), + "---\nis_a: Note\n---\n# Hello\n", + ) + .unwrap(); + + let result = repair_vault(vault_path.to_str().unwrap().to_string()); + assert!(result.is_ok()); + // Note moved from note/ subfolder to root + assert!(vault_path.join("hello.md").exists()); + assert!(!note_dir.join("hello.md").exists()); + // Legacy is_a migrated to type + let content = std::fs::read_to_string(vault_path.join("hello.md")).unwrap(); + assert!(content.contains("type: Note")); + assert!(!content.contains("is_a:")); + } + + #[test] + fn test_repair_vault_creates_config_files() { + let dir = tempfile::TempDir::new().unwrap(); + let vault_path = dir.path(); + + let result = repair_vault(vault_path.to_str().unwrap().to_string()); + assert!(result.is_ok()); + // Config files at root + assert!(vault_path.join("AGENTS.md").exists()); + assert!(vault_path.join("config.md").exists()); + // .gitignore + assert!(vault_path.join(".gitignore").exists()); + } +} diff --git a/src-tauri/src/commands/system.rs b/src-tauri/src/commands/system.rs new file mode 100644 index 00000000..ca318503 --- /dev/null +++ b/src-tauri/src/commands/system.rs @@ -0,0 +1,104 @@ +#[cfg(desktop)] +use crate::menu; +use crate::settings::Settings; +use crate::vault_list::VaultList; +use crate::{vault_list}; + +use super::parse_build_label; + +// ── MCP commands (desktop) ────────────────────────────────────────────────── + +#[cfg(desktop)] +#[tauri::command] +pub async fn register_mcp_tools(vault_path: String) -> Result { + let vault_path = super::expand_tilde(&vault_path).into_owned(); + tokio::task::spawn_blocking(move || crate::mcp::register_mcp(&vault_path)) + .await + .map_err(|e| format!("Registration task failed: {e}"))? +} + +#[cfg(desktop)] +#[tauri::command] +pub async fn check_mcp_status() -> Result { + tokio::task::spawn_blocking(crate::mcp::check_mcp_status) + .await + .map_err(|e| format!("MCP status check failed: {e}")) +} + +// ── MCP commands (mobile stubs) ───────────────────────────────────────────── + +#[cfg(mobile)] +#[tauri::command] +pub async fn register_mcp_tools(_vault_path: String) -> Result { + Err("MCP is not available on mobile".into()) +} + +#[cfg(mobile)] +#[tauri::command] +pub async fn check_mcp_status() -> Result { + Ok(crate::mcp::McpStatus::NotInstalled) +} + +// ── Menu commands ─────────────────────────────────────────────────────────── + +#[cfg(desktop)] +#[tauri::command] +pub fn update_menu_state( + app_handle: tauri::AppHandle, + has_active_note: bool, + has_modified_files: Option, + has_conflicts: Option, +) -> Result<(), String> { + menu::set_note_items_enabled(&app_handle, has_active_note); + if let Some(v) = has_modified_files { + menu::set_git_commit_items_enabled(&app_handle, v); + } + if let Some(v) = has_conflicts { + menu::set_git_conflict_items_enabled(&app_handle, v); + } + Ok(()) +} + +#[cfg(mobile)] +#[tauri::command] +pub fn update_menu_state( + _app_handle: tauri::AppHandle, + _has_active_note: bool, + _has_modified_files: Option, + _has_conflicts: Option, +) -> Result<(), String> { + Ok(()) +} + +// ── Settings & config commands ────────────────────────────────────────────── + +#[tauri::command] +pub fn get_build_number(app_handle: tauri::AppHandle) -> String { + let version = app_handle.package_info().version.to_string(); + parse_build_label(&version) +} + +#[tauri::command] +pub fn get_settings() -> Result { + crate::settings::get_settings() +} + +#[tauri::command] +pub fn save_settings(settings: Settings) -> Result<(), String> { + crate::settings::save_settings(settings) +} + +#[tauri::command] +pub fn reinit_telemetry() { + crate::telemetry::reinit_sentry(); +} + +#[tauri::command] +pub fn load_vault_list() -> Result { + vault_list::load_vault_list() +} + +#[tauri::command] +pub fn save_vault_list(list: VaultList) -> Result<(), String> { + vault_list::save_vault_list(&list) +} diff --git a/src-tauri/src/commands/vault.rs b/src-tauri/src/commands/vault.rs new file mode 100644 index 00000000..1d5b8ff8 --- /dev/null +++ b/src-tauri/src/commands/vault.rs @@ -0,0 +1,213 @@ +use crate::frontmatter::FrontmatterValue; +use crate::search::SearchResponse; +use crate::vault::{RenameResult, VaultEntry}; +use crate::{frontmatter, git, search, vault}; + +use super::expand_tilde; + +// ── Vault commands ────────────────────────────────────────────────────────── + +#[tauri::command] +pub fn list_vault(path: String) -> Result, String> { + let path = expand_tilde(&path); + vault::scan_vault_cached(std::path::Path::new(path.as_ref())) +} + +#[tauri::command] +pub fn get_note_content(path: String) -> Result { + let path = expand_tilde(&path); + vault::get_note_content(std::path::Path::new(path.as_ref())) +} + +#[tauri::command] +pub fn save_note_content(path: String, content: String) -> Result<(), String> { + let path = expand_tilde(&path); + vault::save_note_content(&path, &content) +} + +#[tauri::command] +pub fn rename_note( + vault_path: String, + old_path: String, + new_title: String, + old_title: Option, +) -> Result { + let vault_path = expand_tilde(&vault_path); + let old_path = expand_tilde(&old_path); + vault::rename_note(&vault_path, &old_path, &new_title, old_title.as_deref()) +} + +#[tauri::command] +pub fn purge_trash(vault_path: String) -> Result, String> { + let vault_path = expand_tilde(&vault_path); + vault::purge_trash(&vault_path) +} + +#[tauri::command] +pub fn delete_note(path: String) -> Result { + let path = expand_tilde(&path); + vault::delete_note(&path) +} + +#[tauri::command] +pub fn batch_delete_notes(paths: Vec) -> Result, String> { + let expanded: Vec = paths.iter().map(|p| expand_tilde(p).into_owned()).collect(); + vault::batch_delete_notes(&expanded) +} + +#[tauri::command] +pub fn empty_trash(vault_path: String) -> Result, String> { + let vault_path = expand_tilde(&vault_path); + vault::empty_trash(&vault_path) +} + +#[tauri::command] +pub fn migrate_is_a_to_type(vault_path: String) -> Result { + let vault_path = expand_tilde(&vault_path); + vault::migrate_is_a_to_type(&vault_path) +} + +#[tauri::command] +pub fn flatten_vault(vault_path: String) -> Result { + let vault_path = expand_tilde(&vault_path); + vault::flatten_vault(&vault_path) +} + +#[tauri::command] +pub fn vault_health_check(vault_path: String) -> Result { + let vault_path = expand_tilde(&vault_path); + vault::vault_health_check(&vault_path) +} + +#[tauri::command] +pub fn create_getting_started_vault(target_path: Option) -> Result { + let path = match target_path { + Some(p) if !p.is_empty() => expand_tilde(&p).into_owned(), + _ => vault::default_vault_path()?.to_string_lossy().to_string(), + }; + vault::create_getting_started_vault(&path) +} + +#[tauri::command] +pub fn check_vault_exists(path: String) -> bool { + let path = expand_tilde(&path); + vault::vault_exists(&path) +} + +#[tauri::command] +pub fn get_default_vault_path() -> Result { + vault::default_vault_path().map(|p| p.to_string_lossy().to_string()) +} + +#[tauri::command] +pub async fn reload_vault(path: String) -> Result, String> { + let path = expand_tilde(&path).into_owned(); + tokio::task::spawn_blocking(move || { + vault::invalidate_cache(std::path::Path::new(&path)); + vault::scan_vault_cached(std::path::Path::new(&path)) + }) + .await + .map_err(|e| format!("Task panicked: {e}"))? +} + +#[tauri::command] +pub fn reload_vault_entry(path: String) -> Result { + let path = expand_tilde(&path); + vault::reload_entry(std::path::Path::new(path.as_ref())) +} + +/// Sync the `title` frontmatter field with the filename on note open. +/// Returns `true` if the file was modified (title was absent or desynced). +#[tauri::command] +pub fn sync_note_title(path: String) -> Result { + use vault::SyncAction; + let path = expand_tilde(&path); + let action = vault::sync_title_on_open(std::path::Path::new(path.as_ref()))?; + Ok(matches!(action, SyncAction::Updated { .. })) +} + +#[tauri::command] +pub fn save_image(vault_path: String, filename: String, data: String) -> Result { + let vault_path = expand_tilde(&vault_path); + vault::save_image(&vault_path, &filename, &data) +} + +#[tauri::command] +pub fn copy_image_to_vault(vault_path: String, source_path: String) -> Result { + let vault_path = expand_tilde(&vault_path); + vault::copy_image_to_vault(&vault_path, &source_path) +} + +// ── Frontmatter commands ──────────────────────────────────────────────────── + +#[tauri::command] +pub fn update_frontmatter( + path: String, + key: String, + value: FrontmatterValue, +) -> Result { + let path = expand_tilde(&path); + frontmatter::update_frontmatter(&path, &key, value) +} + +#[tauri::command] +pub fn delete_frontmatter_property(path: String, key: String) -> Result { + let path = expand_tilde(&path); + frontmatter::delete_frontmatter_property(&path, &key) +} + +#[tauri::command] +pub fn batch_archive_notes(paths: Vec) -> Result { + let mut count = 0; + for path in &paths { + let path = expand_tilde(path); + frontmatter::update_frontmatter(&path, "Archived", FrontmatterValue::Bool(true))?; + count += 1; + } + Ok(count) +} + +#[tauri::command] +pub fn batch_trash_notes(paths: Vec) -> Result { + let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); + let mut count = 0; + for path in &paths { + let path = expand_tilde(path); + frontmatter::update_frontmatter(&path, "Trashed", FrontmatterValue::Bool(true))?; + frontmatter::update_frontmatter( + &path, + "Trashed at", + FrontmatterValue::String(now.clone()), + )?; + count += 1; + } + Ok(count) +} + +// ── Search commands ───────────────────────────────────────────────────────── + +#[tauri::command] +pub async fn search_vault( + vault_path: String, + query: String, + mode: String, + limit: Option, +) -> Result { + let vault_path = expand_tilde(&vault_path).into_owned(); + let limit = limit.unwrap_or(20); + tokio::task::spawn_blocking(move || search::search_vault(&vault_path, &query, &mode, limit)) + .await + .map_err(|e| format!("Search task failed: {}", e))? +} + +// ── Repair command ────────────────────────────────────────────────────────── + +#[tauri::command] +pub fn repair_vault(vault_path: String) -> Result { + let vault_path = expand_tilde(&vault_path); + vault::migrate_is_a_to_type(&vault_path)?; + vault::flatten_vault(&vault_path)?; + vault::repair_config_files(&vault_path)?; + git::ensure_gitignore(&vault_path)?; + Ok("Vault repaired".to_string()) +}