onyx-tasks/apps/tauri/src-tauri/src/lib.rs
Tristan Michael bb735ecd4a Add workspace rename and restructure settings screen
Full-stack workspace rename: renames folder on disk, updates config
key/path, refreshes frontend. Restructure settings screen with generic
'Workspace Settings' header, workspace name row with kebab menu
(Rename + Delete). Replace per-workspace kebab dropdown in workspace
list with a direct settings gear button. Remove Appearance heading
and border box from theme section. Clean up unused wsMenuName state.
2026-04-05 14:30:22 -07:00

698 lines
22 KiB
Rust

use std::path::PathBuf;
use std::sync::Mutex;
use std::time::Instant;
use chrono::Utc;
#[cfg(not(target_os = "android"))]
use notify_debouncer_mini::{new_debouncer, DebouncedEventKind};
use serde::{Deserialize, Serialize};
use tauri::{Emitter, Manager, State};
use uuid::Uuid;
use onyx_core::{
config::{AppConfig, WorkspaceConfig, WorkspaceMode},
models::{Task, TaskList, TaskStatus},
repository::TaskRepository,
sync::{self, SyncMode, SyncResult as CoreSyncResult},
webdav,
};
#[cfg(not(target_os = "android"))]
/// Active file watcher stored globally so it lives for the app lifetime.
static WATCHER: Mutex<Option<notify_debouncer_mini::Debouncer<notify::RecommendedWatcher>>> =
Mutex::new(None);
#[cfg(not(target_os = "android"))]
/// Shared mute timestamp — set before writes, checked by the watcher.
static LAST_WRITE: Mutex<Option<Instant>> = Mutex::new(None);
/// Shared application state behind a mutex.
struct AppState {
config: AppConfig,
config_path: PathBuf,
app_data_dir: PathBuf,
repo: Option<TaskRepository>,
}
/// Lock the AppState mutex, converting poisoned locks into an error string.
fn lock_state(state: &Mutex<AppState>) -> Result<std::sync::MutexGuard<'_, AppState>, String> {
state.lock().map_err(|e| format!("State lock poisoned: {}", e))
}
/// Serializable sync result for the frontend.
#[derive(Debug, Serialize, Deserialize, Clone)]
struct SyncResult {
uploaded: u32,
downloaded: u32,
deleted_local: u32,
deleted_remote: u32,
conflicts: u32,
errors: Vec<String>,
}
impl From<CoreSyncResult> for SyncResult {
fn from(r: CoreSyncResult) -> Self {
Self {
uploaded: r.uploaded,
downloaded: r.downloaded,
deleted_local: r.deleted_local,
deleted_remote: r.deleted_remote,
conflicts: r.conflicts,
errors: r.errors,
}
}
}
/// Suppress file watcher events for the next second (call before writes).
#[cfg(not(target_os = "android"))]
fn mute_watcher(_state: &mut AppState) {
if let Ok(mut t) = LAST_WRITE.lock() {
*t = Some(Instant::now());
}
}
#[cfg(target_os = "android")]
fn mute_watcher(_state: &mut AppState) {}
/// Helper: get or open a TaskRepository for the current workspace.
fn ensure_repo(state: &mut AppState) -> Result<(), String> {
if state.repo.is_some() {
return Ok(());
}
let (_name, ws) = state
.config
.get_current_workspace()
.map_err(|e| e.to_string())?;
let repo = TaskRepository::new(ws.path.clone()).map_err(|e| e.to_string())?;
state.repo = Some(repo);
Ok(())
}
/// Get an immutable reference to the repo, returning an error if not initialized.
fn repo_ref(state: &AppState) -> Result<&TaskRepository, String> {
state.repo.as_ref().ok_or_else(|| "Repository not initialized".to_string())
}
/// Get a mutable reference to the repo, returning an error if not initialized.
fn repo_mut(state: &mut AppState) -> Result<&mut TaskRepository, String> {
state.repo.as_mut().ok_or_else(|| "Repository not initialized".to_string())
}
// ── Config commands ──────────────────────────────────────────────────
#[tauri::command]
fn get_config(state: State<'_, Mutex<AppState>>) -> Result<AppConfig, String> {
let s = lock_state(&state)?;
Ok(s.config.clone())
}
#[tauri::command]
fn save_config(state: State<'_, Mutex<AppState>>) -> Result<(), String> {
let s = lock_state(&state)?;
s.config.save_to_file(&s.config_path).map_err(|e| e.to_string())
}
#[tauri::command]
fn add_workspace(
name: String,
path: String,
state: State<'_, Mutex<AppState>>,
) -> Result<(), String> {
let mut s = lock_state(&state)?;
let ws = WorkspaceConfig::new(PathBuf::from(&path));
s.config.add_workspace(name.clone(), ws);
s.config
.set_current_workspace(name)
.map_err(|e| e.to_string())?;
// Reset repo so it reopens on next access
s.repo = None;
s.config
.save_to_file(&s.config_path.clone())
.map_err(|e| e.to_string())
}
#[tauri::command]
fn set_current_workspace(
name: String,
state: State<'_, Mutex<AppState>>,
) -> Result<(), String> {
let mut s = lock_state(&state)?;
s.config
.set_current_workspace(name)
.map_err(|e| e.to_string())?;
s.repo = None;
s.config
.save_to_file(&s.config_path.clone())
.map_err(|e| e.to_string())
}
#[tauri::command]
fn remove_workspace(
name: String,
state: State<'_, Mutex<AppState>>,
) -> Result<(), String> {
let mut s = lock_state(&state)?;
s.config.remove_workspace(&name);
s.repo = None;
s.config
.save_to_file(&s.config_path.clone())
.map_err(|e| e.to_string())
}
#[tauri::command]
fn rename_workspace(
old_name: String,
new_name: String,
state: State<'_, Mutex<AppState>>,
) -> Result<(), String> {
let mut s = lock_state(&state)?;
let ws = s.config.get_workspace(&old_name)
.ok_or_else(|| format!("Workspace '{}' not found", old_name))?;
let old_path = ws.path.clone();
let new_path = old_path.parent()
.ok_or("Workspace path has no parent directory")?
.join(&new_name);
std::fs::rename(&old_path, &new_path).map_err(|e| e.to_string())?;
s.config.rename_workspace(&old_name, new_name.clone()).map_err(|e| e.to_string())?;
s.config.workspaces.get_mut(&new_name).unwrap().path = new_path;
s.repo = None;
s.config.save_to_file(&s.config_path.clone()).map_err(|e| e.to_string())
}
// ── Workspace init ───────────────────────────────────────────────────
#[tauri::command]
fn init_workspace(path: String) -> Result<(), String> {
TaskRepository::init(PathBuf::from(path))
.map(|_| ())
.map_err(|e| e.to_string())
}
// ── List commands ────────────────────────────────────────────────────
#[tauri::command]
fn get_lists(state: State<'_, Mutex<AppState>>) -> Result<Vec<TaskList>, String> {
let mut s = lock_state(&state)?;
ensure_repo(&mut s)?;
repo_ref(&s)?
.get_lists()
.map_err(|e| e.to_string())
}
#[tauri::command]
fn create_list(
name: String,
state: State<'_, Mutex<AppState>>,
) -> Result<TaskList, String> {
let mut s = lock_state(&state)?;
ensure_repo(&mut s)?;
mute_watcher(&mut s);
repo_mut(&mut s)?
.create_list(name)
.map_err(|e| e.to_string())
}
#[tauri::command]
fn delete_list(
list_id: String,
state: State<'_, Mutex<AppState>>,
) -> Result<(), String> {
let mut s = lock_state(&state)?;
ensure_repo(&mut s)?;
mute_watcher(&mut s);
let id = Uuid::parse_str(&list_id).map_err(|e| e.to_string())?;
repo_mut(&mut s)?
.delete_list(id)
.map_err(|e| e.to_string())
}
// ── Task commands ────────────────────────────────────────────────────
#[tauri::command]
fn list_tasks(
list_id: String,
state: State<'_, Mutex<AppState>>,
) -> Result<Vec<Task>, String> {
let mut s = lock_state(&state)?;
ensure_repo(&mut s)?;
let id = Uuid::parse_str(&list_id).map_err(|e| e.to_string())?;
repo_ref(&s)?
.list_tasks(id)
.map_err(|e| e.to_string())
}
#[tauri::command]
fn create_task(
list_id: String,
title: String,
description: Option<String>,
parent_id: Option<String>,
state: State<'_, Mutex<AppState>>,
) -> Result<Task, String> {
let mut s = lock_state(&state)?;
ensure_repo(&mut s)?;
mute_watcher(&mut s);
let id = Uuid::parse_str(&list_id).map_err(|e| e.to_string())?;
let mut task = Task::new(title);
if let Some(desc) = description.filter(|d| !d.is_empty()) {
task.description = desc;
}
if let Some(pid) = parent_id {
let parent_uuid = Uuid::parse_str(&pid).map_err(|e| e.to_string())?;
task.parent_id = Some(parent_uuid);
}
repo_mut(&mut s)?
.create_task(id, task)
.map_err(|e| e.to_string())
}
#[tauri::command]
fn update_task(
list_id: String,
task: Task,
state: State<'_, Mutex<AppState>>,
) -> Result<(), String> {
let mut s = lock_state(&state)?;
ensure_repo(&mut s)?;
mute_watcher(&mut s);
let id = Uuid::parse_str(&list_id).map_err(|e| e.to_string())?;
repo_mut(&mut s)?
.update_task(id, task)
.map_err(|e| e.to_string())
}
#[tauri::command]
fn delete_task(
list_id: String,
task_id: String,
state: State<'_, Mutex<AppState>>,
) -> Result<(), String> {
let mut s = lock_state(&state)?;
ensure_repo(&mut s)?;
mute_watcher(&mut s);
let lid = Uuid::parse_str(&list_id).map_err(|e| e.to_string())?;
let tid = Uuid::parse_str(&task_id).map_err(|e| e.to_string())?;
let repo = repo_mut(&mut s)?;
// Cascade-delete subtasks first
let all_tasks = repo.list_tasks(lid).map_err(|e| e.to_string())?;
let child_ids: Vec<Uuid> = all_tasks
.iter()
.filter(|t| t.parent_id == Some(tid))
.map(|t| t.id)
.collect();
for child_id in child_ids {
repo.delete_task(lid, child_id).map_err(|e| format!("Failed to delete subtask {}: {}", child_id, e))?;
}
repo.delete_task(lid, tid)
.map_err(|e| e.to_string())
}
#[tauri::command]
fn toggle_task(
list_id: String,
task_id: String,
state: State<'_, Mutex<AppState>>,
) -> Result<Task, String> {
let mut s = lock_state(&state)?;
ensure_repo(&mut s)?;
mute_watcher(&mut s);
let lid = Uuid::parse_str(&list_id).map_err(|e| e.to_string())?;
let tid = Uuid::parse_str(&task_id).map_err(|e| e.to_string())?;
let repo = repo_mut(&mut s)?;
let mut task = repo.get_task(lid, tid).map_err(|e| e.to_string())?;
match task.status {
TaskStatus::Backlog => task.complete(),
TaskStatus::Completed => task.uncomplete(),
}
repo.update_task(lid, task.clone())
.map_err(|e| e.to_string())?;
// Cascade: complete/uncomplete subtasks to match parent
let all_tasks = repo.list_tasks(lid).map_err(|e| e.to_string())?;
for mut child in all_tasks.into_iter().filter(|t| t.parent_id == Some(tid)) {
if child.status != task.status {
match task.status {
TaskStatus::Backlog => child.uncomplete(),
TaskStatus::Completed => child.complete(),
}
let _ = repo.update_task(lid, child);
}
}
Ok(task)
}
#[tauri::command]
fn reorder_task(
list_id: String,
task_id: String,
new_position: usize,
state: State<'_, Mutex<AppState>>,
) -> Result<(), String> {
let mut s = lock_state(&state)?;
ensure_repo(&mut s)?;
mute_watcher(&mut s);
let lid = Uuid::parse_str(&list_id).map_err(|e| e.to_string())?;
let tid = Uuid::parse_str(&task_id).map_err(|e| e.to_string())?;
repo_mut(&mut s)?
.reorder_task(lid, tid, new_position)
.map_err(|e| e.to_string())
}
// ── Move / rename / grouping ────────────────────────────────────────
#[tauri::command]
fn move_task(
from_list_id: String,
to_list_id: String,
task_id: String,
state: State<'_, Mutex<AppState>>,
) -> Result<(), String> {
let mut s = lock_state(&state)?;
ensure_repo(&mut s)?;
mute_watcher(&mut s);
let from = Uuid::parse_str(&from_list_id).map_err(|e| e.to_string())?;
let to = Uuid::parse_str(&to_list_id).map_err(|e| e.to_string())?;
let tid = Uuid::parse_str(&task_id).map_err(|e| e.to_string())?;
repo_mut(&mut s)?
.move_task(from, to, tid)
.map_err(|e| e.to_string())
}
#[tauri::command]
fn rename_list(
list_id: String,
new_name: String,
state: State<'_, Mutex<AppState>>,
) -> Result<(), String> {
let mut s = lock_state(&state)?;
ensure_repo(&mut s)?;
mute_watcher(&mut s);
let id = Uuid::parse_str(&list_id).map_err(|e| e.to_string())?;
repo_mut(&mut s)?
.rename_list(id, new_name)
.map_err(|e| e.to_string())
}
#[tauri::command]
fn set_group_by_due_date(
list_id: String,
enabled: bool,
state: State<'_, Mutex<AppState>>,
) -> Result<(), String> {
let mut s = lock_state(&state)?;
ensure_repo(&mut s)?;
mute_watcher(&mut s);
let id = Uuid::parse_str(&list_id).map_err(|e| e.to_string())?;
repo_mut(&mut s)?
.set_group_by_due_date(id, enabled)
.map_err(|e| e.to_string())
}
#[tauri::command]
fn get_group_by_due_date(
list_id: String,
state: State<'_, Mutex<AppState>>,
) -> Result<bool, String> {
let mut s = lock_state(&state)?;
ensure_repo(&mut s)?;
let id = Uuid::parse_str(&list_id).map_err(|e| e.to_string())?;
repo_ref(&s)?
.get_group_by_due_date(id)
.map_err(|e| e.to_string())
}
// ── Sync commands ────────────────────────────────────────────────────
#[tauri::command]
fn set_webdav_config(
workspace_name: String,
webdav_url: String,
state: State<'_, Mutex<AppState>>,
) -> Result<(), String> {
let mut s = lock_state(&state)?;
if let Some(ws) = s.config.workspaces.get_mut(&workspace_name) {
ws.webdav_url = Some(webdav_url);
}
s.config
.save_to_file(&s.config_path.clone())
.map_err(|e| e.to_string())
}
#[tauri::command]
fn set_workspace_theme(
workspace_name: String,
theme: Option<String>,
state: State<'_, Mutex<AppState>>,
) -> Result<(), String> {
let mut s = lock_state(&state)?;
if let Some(ws) = s.config.workspaces.get_mut(&workspace_name) {
ws.theme = theme;
}
s.config.save_to_file(&s.config_path.clone()).map_err(|e| e.to_string())
}
#[tauri::command]
fn add_webdav_workspace(
name: String,
webdav_url: String,
username: String,
password: String,
state: State<'_, Mutex<AppState>>,
) -> Result<(), String> {
let mut s = lock_state(&state)?;
let managed_dir = s.app_data_dir.join("workspaces").join(&name);
std::fs::create_dir_all(&managed_dir).map_err(|e| e.to_string())?;
TaskRepository::init(managed_dir.clone()).map(|_| ()).map_err(|e| e.to_string())?;
let mut ws = WorkspaceConfig::new(managed_dir);
ws.mode = WorkspaceMode::Webdav;
ws.webdav_url = Some(webdav_url.clone());
s.config.add_workspace(name.clone(), ws);
s.config.set_current_workspace(name).map_err(|e| e.to_string())?;
s.repo = None;
// Store credentials keyed by hostname
let domain = webdav_url
.split("://")
.nth(1)
.and_then(|rest| rest.split('/').next())
.unwrap_or("")
.to_string();
s.config.save_to_file(&s.config_path.clone()).map_err(|e| e.to_string())?;
drop(s);
webdav::store_credentials(&domain, &username, &password).map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
async fn store_credentials(
domain: String,
username: String,
password: String,
) -> Result<(), String> {
tokio::task::spawn_blocking(move || {
webdav::store_credentials(&domain, &username, &password).map_err(|e| e.to_string())
})
.await
.map_err(|e| e.to_string())?
}
#[tauri::command]
async fn load_credentials(domain: String) -> Result<(String, String), String> {
tokio::task::spawn_blocking(move || {
webdav::load_credentials(&domain)
.map(|(u, p)| ((*u).clone(), (*p).clone()))
.map_err(|e| e.to_string())
})
.await
.map_err(|e| e.to_string())?
}
#[tauri::command]
async fn test_webdav_connection(
url: String,
username: String,
password: String,
) -> Result<(), String> {
let client = onyx_core::webdav::WebDavClient::new(&url, &username, &password)
.map_err(|e| e.to_string())?;
client
.test_connection()
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn sync_workspace(
workspace_name: String,
mode: String,
state: State<'_, Mutex<AppState>>,
) -> Result<SyncResult, String> {
// Step 1: read config
let (workspace_path, webdav_url) = {
let s = lock_state(&state)?;
let ws = s.config.workspaces.get(&workspace_name)
.ok_or("Workspace not found")?;
(ws.path.clone(), ws.webdav_url.clone().ok_or("No WebDAV URL configured")?)
};
// Step 2: load credentials
let domain = webdav_url
.split("://")
.nth(1)
.and_then(|rest| rest.split('/').next())
.unwrap_or("")
.to_string();
let (username, password) = tokio::task::spawn_blocking(move || {
webdav::load_credentials(&domain)
.map(|(u, p)| ((*u).clone(), (*p).clone()))
.map_err(|e| e.to_string())
})
.await
.map_err(|e| e.to_string())??;
let sync_mode = match mode.as_str() {
"push" => SyncMode::Push,
"pull" => SyncMode::Pull,
_ => SyncMode::Full,
};
let result = sync::sync_workspace(
&workspace_path,
&webdav_url,
&username,
&password,
sync_mode,
None,
)
.await
.map_err(|e| e.to_string())?;
{
let mut s = lock_state(&state)?;
if let Some(ws) = s.config.workspaces.get_mut(&workspace_name) {
ws.last_sync = Some(Utc::now());
}
s.config.save_to_file(&s.config_path.clone()).map_err(|e| e.to_string())?;
}
Ok(result.into())
}
// ── File watcher ────────────────────────────────────────────────────
#[cfg(not(target_os = "android"))]
fn start_watcher(handle: tauri::AppHandle, path: PathBuf) {
// Stop any existing watcher before starting a new one
if let Ok(mut w) = WATCHER.lock() {
*w = None;
}
let handle = handle.clone();
let debouncer = new_debouncer(
std::time::Duration::from_millis(500),
move |events: Result<Vec<notify_debouncer_mini::DebouncedEvent>, notify::Error>| {
let Ok(events) = events else { return };
// Only care about data file changes
let has_data_change = events.iter().any(|e| {
if e.kind != DebouncedEventKind::Any { return false; }
let p = e.path.to_string_lossy();
p.ends_with(".md") || p.ends_with(".json")
});
if !has_data_change { return; }
// Skip if we wrote recently (self-change suppression)
if let Ok(guard) = LAST_WRITE.lock() {
if let Some(t) = *guard {
if t.elapsed() < std::time::Duration::from_secs(1) { return; }
}
}
let _ = handle.emit("fs-changed", ());
},
);
match debouncer {
Ok(mut d) => {
if let Err(e) = d.watcher().watch(&path, notify::RecursiveMode::Recursive) {
eprintln!("Failed to watch path {}: {e}", path.display());
}
if let Ok(mut w) = WATCHER.lock() {
*w = Some(d);
}
}
Err(e) => eprintln!("Failed to start file watcher: {e}"),
}
}
#[cfg(not(target_os = "android"))]
#[tauri::command]
fn watch_workspace(path: String, app_handle: tauri::AppHandle) -> Result<(), String> {
start_watcher(app_handle, PathBuf::from(path));
Ok(())
}
#[cfg(target_os = "android")]
#[tauri::command]
fn watch_workspace(_path: String, _app_handle: tauri::AppHandle) -> Result<(), String> {
Ok(())
}
// ── App entry ────────────────────────────────────────────────────────
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_os::init())
.setup(|app| {
// Resolve app data dir and config path
let app_data_dir = app.path().app_data_dir()
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
let config_path = {
#[cfg(target_os = "android")]
{ app_data_dir.join("config.json") }
#[cfg(not(target_os = "android"))]
{ AppConfig::get_config_path() }
};
let config = AppConfig::load_from_file(&config_path).unwrap_or_default();
let workspace_path = config.get_current_workspace().ok().map(|(_, ws)| ws.path.clone());
app.manage(Mutex::new(AppState { config, config_path, app_data_dir, repo: None }));
#[cfg(not(target_os = "android"))]
if let Some(path) = workspace_path {
let handle = app.handle().clone();
start_watcher(handle, path);
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
get_config,
save_config,
add_workspace,
set_current_workspace,
remove_workspace,
rename_workspace,
init_workspace,
get_lists,
create_list,
delete_list,
list_tasks,
create_task,
update_task,
delete_task,
toggle_task,
reorder_task,
move_task,
rename_list,
set_group_by_due_date,
get_group_by_due_date,
set_webdav_config,
set_workspace_theme,
add_webdav_workspace,
store_credentials,
load_credentials,
test_webdav_connection,
sync_workspace,
watch_workspace,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}