* design: add multi-select notelist wireframes Three frames: selected notes with bulk action bar, Shift+click range select, and empty selection / hover states. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add multi-select notes with bulk archive and trash actions Cmd/Ctrl+Click toggles individual note selection, Shift+Click selects a range, Escape clears. A bulk action bar appears at the bottom with Archive and Trash buttons. Includes useMultiSelect hook, BulkActionBar component, Rust batch_archive_notes/batch_trash_notes commands, and 9 new tests covering the multi-select behavior. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * ci: trigger GitHub Actions run --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
304 lines
8.4 KiB
Rust
304 lines
8.4 KiB
Rust
pub mod ai_chat;
|
|
pub mod frontmatter;
|
|
pub mod git;
|
|
pub mod github;
|
|
pub mod menu;
|
|
pub mod search;
|
|
pub mod settings;
|
|
pub mod vault;
|
|
|
|
use std::path::Path;
|
|
|
|
use ai_chat::{AiChatRequest, AiChatResponse};
|
|
use frontmatter::FrontmatterValue;
|
|
use git::{GitCommit, GitPullResult, LastCommitInfo, ModifiedFile};
|
|
use github::{DeviceFlowPollResult, DeviceFlowStart, GitHubUser, GithubRepo};
|
|
use search::SearchResponse;
|
|
use settings::Settings;
|
|
use vault::{RenameResult, VaultEntry};
|
|
|
|
#[tauri::command]
|
|
fn list_vault(path: String) -> Result<Vec<VaultEntry>, String> {
|
|
vault::scan_vault_cached(Path::new(&path))
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn get_note_content(path: String) -> Result<String, String> {
|
|
vault::get_note_content(Path::new(&path))
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn save_note_content(path: String, content: String) -> Result<(), String> {
|
|
vault::save_note_content(&path, &content)
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn update_frontmatter(
|
|
path: String,
|
|
key: String,
|
|
value: FrontmatterValue,
|
|
) -> Result<String, String> {
|
|
frontmatter::update_frontmatter(&path, &key, value)
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn delete_frontmatter_property(path: String, key: String) -> Result<String, String> {
|
|
frontmatter::delete_frontmatter_property(&path, &key)
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn get_file_history(vault_path: String, path: String) -> Result<Vec<GitCommit>, String> {
|
|
git::get_file_history(&vault_path, &path)
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn get_modified_files(vault_path: String) -> Result<Vec<ModifiedFile>, String> {
|
|
git::get_modified_files(&vault_path)
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn get_file_diff(vault_path: String, path: String) -> Result<String, String> {
|
|
git::get_file_diff(&vault_path, &path)
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn get_file_diff_at_commit(
|
|
vault_path: String,
|
|
path: String,
|
|
commit_hash: String,
|
|
) -> Result<String, String> {
|
|
git::get_file_diff_at_commit(&vault_path, &path, &commit_hash)
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn git_commit(vault_path: String, message: String) -> Result<String, String> {
|
|
git::git_commit(&vault_path, &message)
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn get_last_commit_info(vault_path: String) -> Result<Option<LastCommitInfo>, String> {
|
|
git::get_last_commit_info(&vault_path)
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn git_pull(vault_path: String) -> Result<GitPullResult, String> {
|
|
git::git_pull(&vault_path)
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn git_push(vault_path: String) -> Result<String, String> {
|
|
git::git_push(&vault_path)
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn ai_chat(request: AiChatRequest) -> Result<AiChatResponse, String> {
|
|
ai_chat::send_chat(request).await
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn save_image(vault_path: String, filename: String, data: String) -> Result<String, String> {
|
|
vault::save_image(&vault_path, &filename, &data)
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn rename_note(
|
|
vault_path: String,
|
|
old_path: String,
|
|
new_title: String,
|
|
) -> Result<RenameResult, String> {
|
|
vault::rename_note(&vault_path, &old_path, &new_title)
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn purge_trash(vault_path: String) -> Result<Vec<String>, String> {
|
|
vault::purge_trash(&vault_path)
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn migrate_is_a_to_type(vault_path: String) -> Result<usize, String> {
|
|
vault::migrate_is_a_to_type(&vault_path)
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn batch_archive_notes(paths: Vec<String>) -> Result<usize, String> {
|
|
let mut count = 0;
|
|
for path in &paths {
|
|
frontmatter::update_frontmatter(path, "Archived", FrontmatterValue::Bool(true))?;
|
|
count += 1;
|
|
}
|
|
Ok(count)
|
|
}
|
|
|
|
#[tauri::command]
|
|
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 {
|
|
frontmatter::update_frontmatter(path, "Trashed", FrontmatterValue::Bool(true))?;
|
|
frontmatter::update_frontmatter(path, "Trashed at", FrontmatterValue::String(now.clone()))?;
|
|
count += 1;
|
|
}
|
|
Ok(count)
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn update_menu_state(app_handle: tauri::AppHandle, has_active_note: bool) -> Result<(), String> {
|
|
menu::set_note_items_enabled(&app_handle, has_active_note);
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn get_settings() -> Result<Settings, String> {
|
|
settings::get_settings()
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn save_settings(settings: Settings) -> Result<(), String> {
|
|
settings::save_settings(settings)
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn github_list_repos(token: String) -> Result<Vec<GithubRepo>, String> {
|
|
github::github_list_repos(&token).await
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn github_create_repo(
|
|
token: String,
|
|
name: String,
|
|
private: bool,
|
|
) -> Result<GithubRepo, String> {
|
|
github::github_create_repo(&token, &name, private).await
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn clone_repo(url: String, token: String, local_path: String) -> Result<String, String> {
|
|
github::clone_repo(&url, &token, &local_path)
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn github_device_flow_start() -> Result<DeviceFlowStart, String> {
|
|
github::github_device_flow_start().await
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn github_device_flow_poll(device_code: String) -> Result<DeviceFlowPollResult, String> {
|
|
github::github_device_flow_poll(&device_code).await
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn github_get_user(token: String) -> Result<GitHubUser, String> {
|
|
github::github_get_user(&token).await
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn search_vault(
|
|
vault_path: String,
|
|
query: String,
|
|
mode: String,
|
|
limit: Option<usize>,
|
|
) -> Result<SearchResponse, String> {
|
|
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))?
|
|
}
|
|
|
|
fn log_startup_result(label: &str, result: Result<usize, String>) {
|
|
match result {
|
|
Ok(n) if n > 0 => log::info!("{}: {} files", label, n),
|
|
Err(e) => log::warn!("{}: {}", label, e),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// Run startup housekeeping on the default vault (purge old trash, migrate legacy frontmatter).
|
|
fn run_startup_tasks() {
|
|
let vault_path = dirs::home_dir()
|
|
.map(|h| h.join("Laputa"))
|
|
.unwrap_or_default();
|
|
if !vault_path.is_dir() {
|
|
return;
|
|
}
|
|
let vp_str = vault_path.to_str().unwrap_or_default();
|
|
log_startup_result(
|
|
"Purged trashed files on startup",
|
|
vault::purge_trash(vp_str).map(|d| d.len()),
|
|
);
|
|
log_startup_result(
|
|
"Migrated is_a to type on startup",
|
|
vault::migrate_is_a_to_type(vp_str),
|
|
);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {}
|
|
|
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
|
pub fn run() {
|
|
tauri::Builder::default()
|
|
.setup(|app| {
|
|
if cfg!(debug_assertions) {
|
|
app.handle().plugin(
|
|
tauri_plugin_log::Builder::default()
|
|
.level(log::LevelFilter::Info)
|
|
.build(),
|
|
)?;
|
|
// Open devtools automatically in debug builds
|
|
use tauri::Manager;
|
|
if let Some(window) = app.get_webview_window("main") {
|
|
window.open_devtools();
|
|
}
|
|
}
|
|
|
|
app.handle().plugin(tauri_plugin_dialog::init())?;
|
|
|
|
#[cfg(desktop)]
|
|
{
|
|
app.handle()
|
|
.plugin(tauri_plugin_updater::Builder::new().build())?;
|
|
app.handle().plugin(tauri_plugin_process::init())?;
|
|
app.handle().plugin(tauri_plugin_opener::init())?;
|
|
menu::setup_menu(app)?;
|
|
}
|
|
|
|
run_startup_tasks();
|
|
|
|
Ok(())
|
|
})
|
|
.invoke_handler(tauri::generate_handler![
|
|
list_vault,
|
|
get_note_content,
|
|
save_note_content,
|
|
update_frontmatter,
|
|
delete_frontmatter_property,
|
|
rename_note,
|
|
get_file_history,
|
|
get_modified_files,
|
|
get_file_diff,
|
|
get_file_diff_at_commit,
|
|
git_commit,
|
|
get_last_commit_info,
|
|
git_pull,
|
|
git_push,
|
|
ai_chat,
|
|
save_image,
|
|
purge_trash,
|
|
migrate_is_a_to_type,
|
|
batch_archive_notes,
|
|
batch_trash_notes,
|
|
get_settings,
|
|
update_menu_state,
|
|
save_settings,
|
|
github_list_repos,
|
|
github_create_repo,
|
|
clone_repo,
|
|
github_device_flow_start,
|
|
github_device_flow_poll,
|
|
github_get_user,
|
|
search_vault
|
|
])
|
|
.run(tauri::generate_context!())
|
|
.expect("error while running tauri application");
|
|
}
|