feat: add WorkspaceMode (local/webdav) and per-workspace theme to config

Introduces WorkspaceMode enum with local and webdav variants, plus a
theme field on WorkspaceConfig. Adds set_workspace_theme and
add_webdav_workspace Tauri commands. WebDAV workspaces auto-manage
local files in app data dir.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tristan Michael 2026-04-03 03:58:01 -07:00
parent 4483a6450f
commit a60b1a997b
3 changed files with 82 additions and 14 deletions

View file

@ -11,7 +11,7 @@ use tauri::{Emitter, Manager, State};
use uuid::Uuid; use uuid::Uuid;
use onyx_core::{ use onyx_core::{
config::{AppConfig, WorkspaceConfig}, config::{AppConfig, WorkspaceConfig, WorkspaceMode},
models::{Task, TaskList, TaskStatus}, models::{Task, TaskList, TaskStatus},
repository::TaskRepository, repository::TaskRepository,
sync::{self, SyncMode, SyncResult as CoreSyncResult}, sync::{self, SyncMode, SyncResult as CoreSyncResult},
@ -31,6 +31,7 @@ static LAST_WRITE: Mutex<Option<Instant>> = Mutex::new(None);
struct AppState { struct AppState {
config: AppConfig, config: AppConfig,
config_path: PathBuf, config_path: PathBuf,
app_data_dir: PathBuf,
repo: Option<TaskRepository>, repo: Option<TaskRepository>,
} }
@ -417,6 +418,53 @@ fn set_webdav_config(
.map_err(|e| e.to_string()) .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] #[tauri::command]
fn store_credentials( fn store_credentials(
domain: String, domain: String,
@ -545,23 +593,18 @@ pub fn run() {
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_os::init())
.setup(|app| { .setup(|app| {
// Resolve config path: Tauri's app_data_dir on Android, directories crate on desktop // 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 = { let config_path = {
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
{ { app_data_dir.join("config.json") }
use tauri::Manager;
app.path().app_data_dir()
.map_err(|e| format!("Failed to get app data dir: {}", e))?
.join("config.json")
}
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
{ { AppConfig::get_config_path() }
AppConfig::get_config_path()
}
}; };
let config = AppConfig::load_from_file(&config_path).unwrap_or_default(); let config = AppConfig::load_from_file(&config_path).unwrap_or_default();
let workspace_path = config.get_current_workspace().ok().map(|(_, ws)| ws.path.clone()); let workspace_path = config.get_current_workspace().ok().map(|(_, ws)| ws.path.clone());
app.manage(Mutex::new(AppState { config, config_path, repo: None })); app.manage(Mutex::new(AppState { config, config_path, app_data_dir, repo: None }));
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
if let Some(path) = workspace_path { if let Some(path) = workspace_path {
@ -591,6 +634,8 @@ pub fn run() {
set_group_by_due_date, set_group_by_due_date,
get_group_by_due_date, get_group_by_due_date,
set_webdav_config, set_webdav_config,
set_workspace_theme,
add_webdav_workspace,
store_credentials, store_credentials,
load_credentials, load_credentials,
test_webdav_connection, test_webdav_connection,

View file

@ -19,10 +19,14 @@ export interface TaskList {
group_by_due_date: boolean; group_by_due_date: boolean;
} }
export type WorkspaceMode = "local" | "webdav";
export interface WorkspaceConfig { export interface WorkspaceConfig {
path: string; path: string;
mode: WorkspaceMode;
webdav_url: string | null; webdav_url: string | null;
last_sync: string | null; last_sync: string | null;
theme: string | null;
} }
export interface AppConfig { export interface AppConfig {

View file

@ -3,18 +3,35 @@ use std::path::PathBuf;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::error::{Error, Result}; use crate::error::{Error, Result};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum WorkspaceMode {
Local,
Webdav,
}
impl Default for WorkspaceMode {
fn default() -> Self {
Self::Local
}
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceConfig { pub struct WorkspaceConfig {
pub path: PathBuf, pub path: PathBuf,
#[serde(default)]
pub mode: WorkspaceMode,
#[serde(skip_serializing_if = "Option::is_none", default)] #[serde(skip_serializing_if = "Option::is_none", default)]
pub webdav_url: Option<String>, pub webdav_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)] #[serde(skip_serializing_if = "Option::is_none", default)]
pub last_sync: Option<chrono::DateTime<chrono::Utc>>, pub last_sync: Option<chrono::DateTime<chrono::Utc>>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub theme: Option<String>,
} }
impl WorkspaceConfig { impl WorkspaceConfig {
pub fn new(path: PathBuf) -> Self { pub fn new(path: PathBuf) -> Self {
Self { path, webdav_url: None, last_sync: None } Self { path, mode: WorkspaceMode::Local, webdav_url: None, last_sync: None, theme: None }
} }
} }
@ -229,7 +246,7 @@ mod tests {
let temp_dir = TempDir::new().unwrap(); let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.json"); let config_path = temp_dir.path().join("config.json");
// Write old-format JSON without webdav_url or last_sync fields // Write old-format JSON without webdav_url, last_sync, mode, or theme fields
let old_json = r#"{ let old_json = r#"{
"workspaces": { "workspaces": {
"personal": { "path": "/home/user/tasks" } "personal": { "path": "/home/user/tasks" }
@ -243,5 +260,7 @@ mod tests {
assert_eq!(ws.path, PathBuf::from("/home/user/tasks")); assert_eq!(ws.path, PathBuf::from("/home/user/tasks"));
assert!(ws.webdav_url.is_none()); assert!(ws.webdav_url.is_none());
assert!(ws.last_sync.is_none()); assert!(ws.last_sync.is_none());
assert_eq!(ws.mode, WorkspaceMode::Local);
assert!(ws.theme.is_none());
} }
} }