refactor: split commands.rs into commands/ module (ai, git, system, vault, mod)

937-line monolith split into focused modules:
- commands/ai.rs — AI chat commands
- commands/git.rs — git/vault sync commands
- commands/system.rs — system/window commands
- commands/vault.rs — vault CRUD commands
- commands/mod.rs — re-exports
This commit is contained in:
Test
2026-03-29 09:53:35 +02:00
parent 89f53a1214
commit a59640634e
6 changed files with 967 additions and 937 deletions

View File

@@ -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<Vec<VaultEntry>, 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<String, String> {
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<String>,
) -> Result<RenameResult, String> {
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<Vec<String>, String> {
let vault_path = expand_tilde(&vault_path);
vault::purge_trash(&vault_path)
}
#[tauri::command]
pub fn delete_note(path: String) -> Result<String, String> {
let path = expand_tilde(&path);
vault::delete_note(&path)
}
#[tauri::command]
pub fn batch_delete_notes(paths: Vec<String>) -> Result<Vec<String>, String> {
let expanded: Vec<String> = 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<Vec<String>, 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<usize, String> {
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<usize, String> {
let vault_path = expand_tilde(&vault_path);
vault::flatten_vault(&vault_path)
}
#[tauri::command]
pub fn vault_health_check(vault_path: String) -> Result<vault::VaultHealthReport, String> {
let vault_path = expand_tilde(&vault_path);
vault::vault_health_check(&vault_path)
}
#[tauri::command]
pub fn create_getting_started_vault(target_path: Option<String>) -> Result<String, String> {
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<String, String> {
vault::default_vault_path().map(|p| p.to_string_lossy().to_string())
}
#[tauri::command]
pub async fn reload_vault(path: String) -> Result<Vec<VaultEntry>, 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<VaultEntry, String> {
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<bool, String> {
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<String, String> {
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<String, String> {
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<String, String> {
let path = expand_tilde(&path);
frontmatter::update_frontmatter(&path, &key, value)
}
#[tauri::command]
pub fn delete_frontmatter_property(path: String, key: String) -> Result<String, String> {
let path = expand_tilde(&path);
frontmatter::delete_frontmatter_property(&path, &key)
}
#[tauri::command]
pub fn batch_archive_notes(paths: Vec<String>) -> Result<usize, String> {
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<String>) -> Result<usize, String> {
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<Vec<GitCommit>, 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<Vec<ModifiedFile>, 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<String, String> {
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<String, String> {
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<usize>,
skip: Option<usize>,
) -> Result<Vec<PulseCommit>, 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<String, String> {
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<Option<LastCommitInfo>, 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<GitPullResult, String> {
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<Vec<String>, 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<String, String> {
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<GitPushResult, String> {
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<GitRemoteStatus, String> {
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<Vec<GitCommit>, String> {
Err("Git history is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub fn get_modified_files(_vault_path: String) -> Result<Vec<ModifiedFile>, String> {
Ok(vec![])
}
#[cfg(mobile)]
#[tauri::command]
pub fn get_file_diff(_vault_path: String, _path: String) -> Result<String, String> {
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<String, String> {
Err("Git diff is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub fn get_vault_pulse(
_vault_path: String,
_limit: Option<usize>,
_skip: Option<usize>,
) -> Result<Vec<PulseCommit>, String> {
Ok(vec![])
}
#[cfg(mobile)]
#[tauri::command]
pub fn git_commit(_vault_path: String, _message: String) -> Result<String, String> {
Err("Git commit is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub fn get_last_commit_info(_vault_path: String) -> Result<Option<LastCommitInfo>, String> {
Ok(None)
}
#[cfg(mobile)]
#[tauri::command]
pub async fn git_pull(_vault_path: String) -> Result<GitPullResult, String> {
Err("Git pull is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub fn get_conflict_files(_vault_path: String) -> Result<Vec<String>, 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<String, String> {
Err("Git conflict resolution is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn git_push(_vault_path: String) -> Result<GitPushResult, String> {
Err("Git push is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn git_remote_status(_vault_path: String) -> Result<GitRemoteStatus, String> {
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<Vec<GithubRepo>, 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<GithubRepo, String> {
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<String, String> {
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<DeviceFlowStart, String> {
crate::github::github_device_flow_start().await
}
#[cfg(desktop)]
#[tauri::command]
pub async fn github_device_flow_poll(device_code: String) -> Result<DeviceFlowPollResult, String> {
crate::github::github_device_flow_poll(&device_code).await
}
#[cfg(desktop)]
#[tauri::command]
pub async fn github_get_user(token: String) -> Result<GitHubUser, String> {
crate::github::github_get_user(&token).await
}
// ── GitHub commands (mobile stubs) ──────────────────────────────────────────
#[cfg(mobile)]
#[tauri::command]
pub async fn github_list_repos(_token: String) -> Result<Vec<GithubRepo>, 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<GithubRepo, String> {
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<String, String> {
Err("Git clone is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn github_device_flow_start() -> Result<DeviceFlowStart, String> {
Err("GitHub integration is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn github_device_flow_poll(_device_code: String) -> Result<DeviceFlowPollResult, String> {
Err("GitHub integration is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn github_get_user(_token: String) -> Result<GitHubUser, String> {
Err("GitHub integration is not available on mobile".into())
}
// ── AI / Claude commands ────────────────────────────────────────────────────
#[tauri::command]
pub async fn ai_chat(request: AiChatRequest) -> Result<AiChatResponse, String> {
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<String, String> {
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<String, String> {
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<String, String> {
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<String, String> {
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<usize>,
) -> Result<SearchResponse, String> {
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<String, String> {
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<crate::mcp::McpStatus, String> {
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<String, String> {
Err("MCP is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn check_mcp_status() -> Result<crate::mcp::McpStatus, String> {
Ok(crate::mcp::McpStatus::NotInstalled)
}
#[tauri::command]
pub fn repair_vault(vault_path: String) -> Result<String, String> {
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<bool>,
has_conflicts: Option<bool>,
) -> 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<bool>,
_has_conflicts: Option<bool>,
) -> Result<(), String> {
Ok(())
}
#[tauri::command]
pub fn get_settings() -> Result<Settings, String> {
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<VaultList, String> {
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(&note, "---\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(&note).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(&note, "---\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(&note).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(&note, "---\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(&note, "---\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(&note_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());
}
}

View File

@@ -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<AiChatResponse, String> {
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<String, String> {
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<String, String> {
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<String, String> {
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<String, String> {
Err("Claude CLI is not available on mobile".into())
}

View File

@@ -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<Vec<GitCommit>, 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<Vec<ModifiedFile>, 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<String, String> {
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<String, String> {
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<usize>,
skip: Option<usize>,
) -> Result<Vec<PulseCommit>, 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<String, String> {
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<Option<LastCommitInfo>, 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<GitPullResult, String> {
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<Vec<String>, 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<String, String> {
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<GitPushResult, String> {
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<GitRemoteStatus, String> {
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<Vec<GitCommit>, String> {
Err("Git history is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub fn get_modified_files(_vault_path: String) -> Result<Vec<ModifiedFile>, String> {
Ok(vec![])
}
#[cfg(mobile)]
#[tauri::command]
pub fn get_file_diff(_vault_path: String, _path: String) -> Result<String, String> {
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<String, String> {
Err("Git diff is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub fn get_vault_pulse(
_vault_path: String,
_limit: Option<usize>,
_skip: Option<usize>,
) -> Result<Vec<PulseCommit>, String> {
Ok(vec![])
}
#[cfg(mobile)]
#[tauri::command]
pub fn git_commit(_vault_path: String, _message: String) -> Result<String, String> {
Err("Git commit is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub fn get_last_commit_info(_vault_path: String) -> Result<Option<LastCommitInfo>, String> {
Ok(None)
}
#[cfg(mobile)]
#[tauri::command]
pub async fn git_pull(_vault_path: String) -> Result<GitPullResult, String> {
Err("Git pull is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub fn get_conflict_files(_vault_path: String) -> Result<Vec<String>, 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<String, String> {
Err("Git conflict resolution is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn git_push(_vault_path: String) -> Result<GitPushResult, String> {
Err("Git push is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn git_remote_status(_vault_path: String) -> Result<GitRemoteStatus, String> {
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<Vec<GithubRepo>, 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<GithubRepo, String> {
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<String, String> {
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<DeviceFlowStart, String> {
crate::github::github_device_flow_start().await
}
#[cfg(desktop)]
#[tauri::command]
pub async fn github_device_flow_poll(device_code: String) -> Result<DeviceFlowPollResult, String> {
crate::github::github_device_flow_poll(&device_code).await
}
#[cfg(desktop)]
#[tauri::command]
pub async fn github_get_user(token: String) -> Result<GitHubUser, String> {
crate::github::github_get_user(&token).await
}
// ── GitHub commands (mobile stubs) ──────────────────────────────────────────
#[cfg(mobile)]
#[tauri::command]
pub async fn github_list_repos(_token: String) -> Result<Vec<GithubRepo>, 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<GithubRepo, String> {
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<String, String> {
Err("Git clone is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn github_device_flow_start() -> Result<DeviceFlowStart, String> {
Err("GitHub integration is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn github_device_flow_poll(_device_code: String) -> Result<DeviceFlowPollResult, String> {
Err("GitHub integration is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn github_get_user(_token: String) -> Result<GitHubUser, String> {
Err("GitHub integration is not available on mobile".into())
}

View File

@@ -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(&note, "---\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(&note).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(&note, "---\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(&note).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(&note, "---\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(&note, "---\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(&note_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());
}
}

View File

@@ -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<String, String> {
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<crate::mcp::McpStatus, String> {
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<String, String> {
Err("MCP is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn check_mcp_status() -> Result<crate::mcp::McpStatus, String> {
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<bool>,
has_conflicts: Option<bool>,
) -> 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<bool>,
_has_conflicts: Option<bool>,
) -> 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<Settings, String> {
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<VaultList, String> {
vault_list::load_vault_list()
}
#[tauri::command]
pub fn save_vault_list(list: VaultList) -> Result<(), String> {
vault_list::save_vault_list(&list)
}

View File

@@ -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<Vec<VaultEntry>, 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<String, String> {
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<String>,
) -> Result<RenameResult, String> {
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<Vec<String>, String> {
let vault_path = expand_tilde(&vault_path);
vault::purge_trash(&vault_path)
}
#[tauri::command]
pub fn delete_note(path: String) -> Result<String, String> {
let path = expand_tilde(&path);
vault::delete_note(&path)
}
#[tauri::command]
pub fn batch_delete_notes(paths: Vec<String>) -> Result<Vec<String>, String> {
let expanded: Vec<String> = 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<Vec<String>, 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<usize, String> {
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<usize, String> {
let vault_path = expand_tilde(&vault_path);
vault::flatten_vault(&vault_path)
}
#[tauri::command]
pub fn vault_health_check(vault_path: String) -> Result<vault::VaultHealthReport, String> {
let vault_path = expand_tilde(&vault_path);
vault::vault_health_check(&vault_path)
}
#[tauri::command]
pub fn create_getting_started_vault(target_path: Option<String>) -> Result<String, String> {
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<String, String> {
vault::default_vault_path().map(|p| p.to_string_lossy().to_string())
}
#[tauri::command]
pub async fn reload_vault(path: String) -> Result<Vec<VaultEntry>, 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<VaultEntry, String> {
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<bool, String> {
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<String, String> {
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<String, String> {
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<String, String> {
let path = expand_tilde(&path);
frontmatter::update_frontmatter(&path, &key, value)
}
#[tauri::command]
pub fn delete_frontmatter_property(path: String, key: String) -> Result<String, String> {
let path = expand_tilde(&path);
frontmatter::delete_frontmatter_property(&path, &key)
}
#[tauri::command]
pub fn batch_archive_notes(paths: Vec<String>) -> Result<usize, String> {
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<String>) -> Result<usize, String> {
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<usize>,
) -> Result<SearchResponse, String> {
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<String, String> {
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())
}