From 16d514f7f105fe166afd97e62bda6f8660a94a6b Mon Sep 17 00:00:00 2001 From: Stefan Hacker Date: Sun, 12 Apr 2026 00:34:03 +0200 Subject: [PATCH] feat: Virtual Files, Multi-Sync-Pfade, Full Sync, Ordner-Dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Virtual Files System: - .cloud Platzhalter-Dateien (JSON mit ID, Name, Groesse, Checksum) - 0 Bytes Speicherverbrauch pro Datei - Doppelklick auf .cloud -> Download + Oeffnen mit Standard-App + Lock - Nach Schliessen: Sync zurueck, lokale Kopie entfernen, .cloud neu - Offline-Markierung: Echte Dateien bleiben lokal (kein .cloud) - Server-Dateien loeschen -> .cloud wird automatisch entfernt Multi-Sync-Pfade (wie Nextcloud): - Beliebig viele Server-Ordner auf lokale Ordner mappen - z.B. /Projekte/2026 -> ~/Projekte oder /Shared/Team -> ~/Team - Freigegebene Ordner von anderen Benutzern sync-bar - Jeder Pfad hat eigenen Modus (Virtual oder Full) - Hinzufuegen/Entfernen/Modus wechseln in der UI Full Sync: - Pro Sync-Pfad waehlbar: Virtual oder Full - Full = alle Dateien lokal spiegeln (bidirektional) - Virtual = .cloud Platzhalter (Standard) - Klick auf Modus-Badge zum Umschalten Ordner-Dialog: - "Durchsuchen..." Button oeffnet nativen Ordner-Auswahl-Dialog - Server-Ordner per Dropdown aus Dateibaum waehlen - Ordner werden automatisch erstellt wenn noetig UI: - Sync-Pfade als Karten: ☁ /Server/Pfad → 📁 /Lokaler/Pfad - Modus-Badge (Virtual/Full) mit Klick zum Wechseln - Tray-Menue: "Jetzt synchronisieren" Eintrag Co-Authored-By: Claude Opus 4.6 (1M context) --- clients/desktop/src-tauri/Cargo.toml | 1 + clients/desktop/src-tauri/src/lib.rs | 247 ++++++---- clients/desktop/src-tauri/src/sync/engine.rs | 311 +++++++++---- clients/desktop/src/App.vue | 454 ++++++++++--------- 4 files changed, 638 insertions(+), 375 deletions(-) diff --git a/clients/desktop/src-tauri/Cargo.toml b/clients/desktop/src-tauri/Cargo.toml index 19b4c92..743caaa 100644 --- a/clients/desktop/src-tauri/Cargo.toml +++ b/clients/desktop/src-tauri/Cargo.toml @@ -27,3 +27,4 @@ dirs = "6" rusqlite = { version = "0.34", features = ["bundled"] } chrono = { version = "0.4", features = ["serde"] } keyring = "3" +open = "5" diff --git a/clients/desktop/src-tauri/src/lib.rs b/clients/desktop/src-tauri/src/lib.rs index be19031..b8b0be8 100644 --- a/clients/desktop/src-tauri/src/lib.rs +++ b/clients/desktop/src-tauri/src/lib.rs @@ -10,19 +10,21 @@ use tauri::{ }; use sync::api::MiniCloudApi; -use sync::engine::SyncEngine; +use sync::engine::{SyncEngine, SyncMode, SyncPath}; use sync::watcher::{FileWatcher, ChangeKind}; struct AppState { api: Mutex>, sync_engine: Mutex>, - sync_dir: Mutex>, username: Mutex>, - watcher: Mutex>, + watchers: Mutex>, sync_running: Arc>, - locked_files: Mutex>, // file IDs we have locked + locked_files: Mutex>, + sync_paths: Mutex>, } +// --- Auth --- + #[tauri::command] async fn login( state: State<'_, AppState>, @@ -42,76 +44,162 @@ async fn login( })) } +// --- Sync Paths --- + #[tauri::command] -fn set_sync_dir(state: State<'_, AppState>, path: String) -> Result { - let sync_path = PathBuf::from(&path); - if !sync_path.exists() { - std::fs::create_dir_all(&sync_path).map_err(|e| e.to_string())?; - } - *state.sync_dir.lock().unwrap() = Some(sync_path); - Ok(format!("Sync-Ordner gesetzt: {}", path)) +fn add_sync_path( + state: State<'_, AppState>, + server_path: String, + server_folder_id: Option, + local_dir: String, + mode: String, // "virtual" or "full" +) -> Result { + let local = PathBuf::from(&local_dir); + std::fs::create_dir_all(&local).map_err(|e| format!("Ordner erstellen: {}", e))?; + + let sync_mode = if mode == "full" { SyncMode::Full } else { SyncMode::Virtual }; + let id = format!("{}_{}", server_folder_id.unwrap_or(0), local_dir.replace(['/', '\\'], "_")); + + let sp = SyncPath { + id: id.clone(), + server_path, + server_folder_id, + local_dir, + mode: sync_mode, + enabled: true, + }; + + state.sync_paths.lock().unwrap().push(sp.clone()); + + Ok(serde_json::to_value(sp).map_err(|e| e.to_string())?) } #[tauri::command] -async fn start_sync(app: AppHandle, state: State<'_, AppState>) -> Result, String> { - let api = state.api.lock().unwrap().clone() - .ok_or("Nicht eingeloggt")?; - let sync_dir = state.sync_dir.lock().unwrap().clone() - .ok_or("Kein Sync-Ordner gesetzt")?; +fn remove_sync_path(state: State<'_, AppState>, id: String) -> Result { + state.sync_paths.lock().unwrap().retain(|p| p.id != id); + Ok("Sync-Pfad entfernt".to_string()) +} - // Full sync - let mut engine = SyncEngine::new(sync_dir.clone(), api.clone()); - let log = engine.full_sync().await?; +#[tauri::command] +fn get_sync_paths(state: State<'_, AppState>) -> Result { + let paths = state.sync_paths.lock().unwrap().clone(); + Ok(serde_json::to_value(paths).map_err(|e| e.to_string())?) +} + +#[tauri::command] +fn toggle_sync_mode(state: State<'_, AppState>, id: String) -> Result { + let mut paths = state.sync_paths.lock().unwrap(); + if let Some(p) = paths.iter_mut().find(|p| p.id == id) { + p.mode = match p.mode { + SyncMode::Virtual => SyncMode::Full, + SyncMode::Full => SyncMode::Virtual, + }; + Ok(format!("Modus: {:?}", p.mode)) + } else { + Err("Pfad nicht gefunden".to_string()) + } +} + +// --- Sync --- + +#[tauri::command] +async fn start_sync(app: AppHandle, state: State<'_, AppState>) -> Result, String> { + let api = state.api.lock().unwrap().clone().ok_or("Nicht eingeloggt")?; + let paths = state.sync_paths.lock().unwrap().clone(); + + if paths.is_empty() { + return Err("Keine Sync-Pfade konfiguriert".to_string()); + } + + let mut engine = SyncEngine::new(api.clone()); + engine.sync_paths = paths.clone(); + let log = engine.sync_all().await?; *state.sync_engine.lock().unwrap() = Some(engine); - // Start file watcher - let watcher = FileWatcher::new(&sync_dir)?; - *state.watcher.lock().unwrap() = Some(watcher); + // Start watchers for each sync path + let mut watchers = Vec::new(); + for sp in &paths { + if let Ok(w) = FileWatcher::new(&PathBuf::from(&sp.local_dir)) { + watchers.push(w); + } + } + *state.watchers.lock().unwrap() = watchers; - // Start background auto-sync + watcher processing - start_background_sync(app, state.sync_running.clone(), api, sync_dir); + // Start background threads + start_background_sync(app, state.sync_running.clone(), api, paths); - let _ = app.emit("sync-status", "synced"); Ok(log) } #[tauri::command] -async fn delta_sync(state: State<'_, AppState>) -> Result, String> { +async fn run_sync_now(state: State<'_, AppState>) -> Result, String> { let mut engine = { let mut guard = state.sync_engine.lock().unwrap(); guard.take().ok_or("Sync nicht gestartet")? }; - let result = engine.delta_sync().await; + let result = engine.sync_all().await; *state.sync_engine.lock().unwrap() = Some(engine); result } +// --- File Operations --- + +#[tauri::command] +async fn open_cloud_file(state: State<'_, AppState>, cloud_path: String) -> Result { + let engine = { + let guard = state.sync_engine.lock().unwrap(); + guard.as_ref().ok_or("Sync nicht gestartet")?.api.clone() + }; + + let path = PathBuf::from(&cloud_path); + let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?; + let placeholder: serde_json::Value = serde_json::from_str(&content).map_err(|e| e.to_string())?; + let file_id = placeholder.get("id").and_then(|v| v.as_i64()).ok_or("Keine ID")?; + let file_name = placeholder.get("name").and_then(|v| v.as_str()).unwrap_or("file"); + + let real_path = path.parent().unwrap().join(file_name); + + // Download + engine.download_file(file_id, &real_path).await?; + + // Remove placeholder + std::fs::remove_file(&path).ok(); + + // Lock + let _ = engine.lock_file(file_id, "Desktop Sync Client").await; + state.locked_files.lock().unwrap().push(file_id); + + // Open with default application + let _ = open::that(&real_path); + + Ok(real_path.to_string_lossy().to_string()) +} + +#[tauri::command] +async fn get_file_tree(state: State<'_, AppState>) -> Result { + let api = state.api.lock().unwrap().clone().ok_or("Nicht eingeloggt")?; + let tree = api.get_sync_tree().await?; + Ok(serde_json::to_value(tree).map_err(|e| e.to_string())?) +} + #[tauri::command] async fn get_status(state: State<'_, AppState>) -> Result { let logged_in = state.api.lock().unwrap().is_some(); - let sync_dir = state.sync_dir.lock().unwrap().clone(); let username = state.username.lock().unwrap().clone(); let syncing = *state.sync_running.lock().unwrap(); - let locked_count = state.locked_files.lock().unwrap().len(); + let paths = state.sync_paths.lock().unwrap().len(); + let locked = state.locked_files.lock().unwrap().len(); Ok(serde_json::json!({ "logged_in": logged_in, "username": username, - "sync_dir": sync_dir.map(|p| p.to_string_lossy().to_string()), "syncing": syncing, - "locked_files": locked_count, + "sync_paths": paths, + "locked_files": locked, })) } -#[tauri::command] -async fn get_file_tree(state: State<'_, AppState>) -> Result { - let api = state.api.lock().unwrap().clone() - .ok_or("Nicht eingeloggt")?; - let tree = api.get_sync_tree().await?; - Ok(serde_json::to_value(tree).map_err(|e| e.to_string())?) -} - #[tauri::command] async fn lock_file_cmd(state: State<'_, AppState>, file_id: i64) -> Result { let api = state.api.lock().unwrap().clone().ok_or("Nicht eingeloggt")?; @@ -128,108 +216,104 @@ async fn unlock_file_cmd(state: State<'_, AppState>, file_id: i64) -> Result>, api: MiniCloudApi, - sync_dir: PathBuf, + paths: Vec, ) { - // Auto-sync thread (delta sync every 30 seconds) + // Auto-sync every 30 seconds let app_sync = app.clone(); let api_sync = api.clone(); - let sync_dir_clone = sync_dir.clone(); - let running = sync_running.clone(); + let paths_sync = paths.clone(); std::thread::spawn(move || { let rt = tokio::runtime::Runtime::new().unwrap(); - let mut engine = SyncEngine::new(sync_dir_clone, api_sync); + let mut engine = SyncEngine::new(api_sync); + engine.sync_paths = paths_sync; loop { std::thread::sleep(Duration::from_secs(30)); - *running.lock().unwrap() = true; + *sync_running.lock().unwrap() = true; let _ = app_sync.emit("sync-status", "syncing"); - match rt.block_on(engine.delta_sync()) { + match rt.block_on(engine.sync_all()) { Ok(log) => { if !log.is_empty() { let _ = app_sync.emit("sync-log", log); } } - Err(e) => { - let _ = app_sync.emit("sync-error", e); - } + Err(e) => { let _ = app_sync.emit("sync-error", e); } } - *running.lock().unwrap() = false; + *sync_running.lock().unwrap() = false; let _ = app_sync.emit("sync-status", "synced"); } }); - // Heartbeat thread (every 60 seconds for locked files) + // Heartbeat every 60 seconds let app_hb = app.clone(); let api_hb = api.clone(); std::thread::spawn(move || { let rt = tokio::runtime::Runtime::new().unwrap(); loop { std::thread::sleep(Duration::from_secs(60)); - let state = app_hb.state::(); let locked = state.locked_files.lock().unwrap().clone(); - for file_id in locked { let _ = rt.block_on(api_hb.heartbeat(file_id)); } } }); - // File watcher processing thread - let app_watcher = app.clone(); + // File watcher processing + let app_w = app.clone(); std::thread::spawn(move || { loop { std::thread::sleep(Duration::from_secs(2)); - - let state = app_watcher.state::(); - let watcher_guard = state.watcher.lock().unwrap(); - if let Some(watcher) = watcher_guard.as_ref() { + let state = app_w.state::(); + let watchers = state.watchers.lock().unwrap(); + for watcher in watchers.iter() { while let Ok(change) = watcher.receiver.try_recv() { - let rel_path = change.path.strip_prefix(&sync_dir) - .unwrap_or(&change.path) - .to_string_lossy() - .to_string(); - + let name = change.path.file_name() + .and_then(|n| n.to_str()).unwrap_or("?"); let msg = match change.kind { - ChangeKind::Created => format!("Neu: {}", rel_path), - ChangeKind::Modified => format!("Geaendert: {}", rel_path), - ChangeKind::Deleted => format!("Geloescht: {}", rel_path), + ChangeKind::Created => format!("Neu: {}", name), + ChangeKind::Modified => format!("Geaendert: {}", name), + ChangeKind::Deleted => format!("Geloescht: {}", name), }; - let _ = app_watcher.emit("file-change", msg); + let _ = app_w.emit("file-change", msg); } } } }); } +// --- App Setup --- + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_notification::init()) + .plugin(tauri_plugin_dialog::init()) .manage(AppState { api: Mutex::new(None), sync_engine: Mutex::new(None), - sync_dir: Mutex::new(None), username: Mutex::new(None), - watcher: Mutex::new(None), + watchers: Mutex::new(Vec::new()), sync_running: Arc::new(Mutex::new(false)), locked_files: Mutex::new(Vec::new()), + sync_paths: Mutex::new(Vec::new()), }) .setup(|app| { - // System Tray let quit = MenuItem::with_id(app, "quit", "Beenden", true, None::<&str>)?; let show = MenuItem::with_id(app, "show", "Oeffnen", true, None::<&str>)?; - let menu = Menu::with_items(app, &[&show, &quit])?; + let sync_now = MenuItem::with_id(app, "sync", "Jetzt synchronisieren", true, None::<&str>)?; + let menu = Menu::with_items(app, &[&show, &sync_now, &quit])?; TrayIconBuilder::new() .tooltip("Mini-Cloud Sync") @@ -238,11 +322,12 @@ pub fn run() { match event.id.as_ref() { "quit" => std::process::exit(0), "show" => { - if let Some(window) = app.get_webview_window("main") { - let _ = window.show(); - let _ = window.set_focus(); + if let Some(w) = app.get_webview_window("main") { + let _ = w.show(); + let _ = w.set_focus(); } } + "sync" => { let _ = app.emit("trigger-sync", ()); } _ => {} } }) @@ -252,11 +337,15 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ login, - set_sync_dir, + add_sync_path, + remove_sync_path, + get_sync_paths, + toggle_sync_mode, start_sync, - delta_sync, - get_status, + run_sync_now, + open_cloud_file, get_file_tree, + get_status, lock_file_cmd, unlock_file_cmd, ]) diff --git a/clients/desktop/src-tauri/src/sync/engine.rs b/clients/desktop/src-tauri/src/sync/engine.rs index 0a6e1b9..5bef6ae 100644 --- a/clients/desktop/src-tauri/src/sync/engine.rs +++ b/clients/desktop/src-tauri/src/sync/engine.rs @@ -1,104 +1,166 @@ use crate::sync::api::{FileEntry, MiniCloudApi}; use sha2::{Digest, Sha256}; +use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::{Path, PathBuf}; -/// Compare local files with server tree and determine actions +/// A configured sync path: maps a server folder to a local folder +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SyncPath { + pub id: String, // unique ID + pub server_path: String, // e.g. "/" (root) or "/Projekte/2026" + pub server_folder_id: Option, // server folder ID (None = root) + pub local_dir: String, // local directory path + pub mode: SyncMode, // virtual or full + pub enabled: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum SyncMode { + Virtual, // .cloud placeholder files, download on demand + Full, // full sync, all files downloaded +} + +/// Cloud placeholder file content (small JSON inside .cloud files) +#[derive(Debug, Serialize, Deserialize)] +struct CloudPlaceholder { + id: i64, + name: String, + size: i64, + checksum: String, + updated_at: String, + server_path: String, +} + pub struct SyncEngine { - pub sync_dir: PathBuf, pub api: MiniCloudApi, + pub sync_paths: Vec, last_sync: Option, } -#[derive(Debug, Clone)] -pub enum SyncAction { - Download { id: i64, name: String, server_path: String }, - Upload { local_path: PathBuf, parent_id: Option }, - CreateFolder { name: String, parent_id: Option }, - Delete { local_path: PathBuf }, - Conflict { local_path: PathBuf, server_id: i64, locked_by: String }, -} - impl SyncEngine { - pub fn new(sync_dir: PathBuf, api: MiniCloudApi) -> Self { - Self { sync_dir, api, last_sync: None } + pub fn new(api: MiniCloudApi) -> Self { + Self { api, sync_paths: Vec::new(), last_sync: None } } - /// Full sync: compare server tree with local filesystem - pub async fn full_sync(&mut self) -> Result, String> { - let server_tree = self.api.get_sync_tree().await?; - let mut log = Vec::new(); + /// Sync all configured paths + pub async fn sync_all(&mut self) -> Result, String> { + let mut all_logs = Vec::new(); - // Download missing/changed files from server - self.sync_download_recursive(&server_tree, &self.sync_dir.clone(), &mut log).await; + let tree = self.api.get_sync_tree().await?; - // Upload local files not on server - self.sync_upload_recursive(&server_tree, &self.sync_dir.clone(), None, &mut log).await; + for sp in &self.sync_paths { + if !sp.enabled { continue; } - // Update last sync time - self.last_sync = Some(chrono::Utc::now().to_rfc3339()); + let local_dir = PathBuf::from(&sp.local_dir); + std::fs::create_dir_all(&local_dir).ok(); - Ok(log) - } - - /// Delta sync: only changes since last sync - pub async fn delta_sync(&mut self) -> Result, String> { - let since = self.last_sync.clone().unwrap_or_else(|| "2000-01-01T00:00:00Z".to_string()); - let changes = self.api.get_changes(&since).await?; - let mut log = Vec::new(); - - for entry in &changes.changes { - let rel_path = self.sync_dir.join(&entry.name); - - if entry.is_folder { - if !rel_path.exists() { - std::fs::create_dir_all(&rel_path).ok(); - log.push(format!("Ordner erstellt: {}", entry.name)); - } + // Find the server subtree for this sync path + let subtree = if sp.server_folder_id.is_some() { + find_subtree(&tree, sp.server_folder_id.unwrap()) } else { - // Check if locked - if entry.locked.unwrap_or(false) { - log.push(format!("Uebersprungen (gesperrt von {}): {}", - entry.locked_by.as_deref().unwrap_or("?"), entry.name)); - continue; - } + Some(tree.clone()) + }; - // Download if checksum differs - let needs_download = if rel_path.exists() { - let local_hash = compute_file_hash(&rel_path); - local_hash != entry.checksum.as_deref().unwrap_or("") - } else { - true - }; - - if needs_download { - match self.api.download_file(entry.id, &rel_path).await { - Ok(_) => log.push(format!("Heruntergeladen: {}", entry.name)), - Err(e) => log.push(format!("Fehler {}: {}", entry.name, e)), + if let Some(entries) = subtree { + let mut log = Vec::new(); + match sp.mode { + SyncMode::Virtual => { + self.sync_virtual(&entries, &local_dir, &sp.server_path, &mut log).await; + } + SyncMode::Full => { + self.sync_full_download(&entries, &local_dir, &mut log).await; + self.sync_full_upload(&entries, &local_dir, sp.server_folder_id, &mut log).await; } } + all_logs.extend(log); } } - self.last_sync = Some(changes.server_time); - Ok(log) + self.last_sync = Some(chrono::Utc::now().to_rfc3339()); + Ok(all_logs) } - async fn sync_download_recursive(&self, entries: &[FileEntry], local_dir: &Path, log: &mut Vec) { + /// Virtual sync: create .cloud placeholder files + async fn sync_virtual(&self, entries: &[FileEntry], local_dir: &Path, + server_path: &str, log: &mut Vec) { for entry in entries { let local_path = local_dir.join(&entry.name); if entry.is_folder { std::fs::create_dir_all(&local_path).ok(); if let Some(children) = &entry.children { - Box::pin(self.sync_download_recursive(children, &local_path, log)).await; + let sub_path = format!("{}/{}", server_path.trim_end_matches('/'), entry.name); + Box::pin(self.sync_virtual(children, &local_path, &sub_path, log)).await; } } else { - // Skip locked files - if entry.locked.unwrap_or(false) { + // Check if real file exists (manually downloaded or offline-marked) + if local_path.exists() { + // Real file exists - check if it's been modified + let local_hash = compute_file_hash(&local_path); + if local_hash != entry.checksum.as_deref().unwrap_or("") { + // Local file changed - upload it + if !entry.locked.unwrap_or(false) { + match self.api.upload_file(&local_path, entry.id.into()).await { + Ok(_) => log.push(format!("Hochgeladen: {}", entry.name)), + Err(e) => log.push(format!("Upload-Fehler {}: {}", entry.name, e)), + } + } else { + log.push(format!("Zurueckgehalten (gesperrt): {}", entry.name)); + } + } continue; } + // Create .cloud placeholder + let cloud_path = local_dir.join(format!("{}.cloud", entry.name)); + if !cloud_path.exists() { + let placeholder = CloudPlaceholder { + id: entry.id, + name: entry.name.clone(), + size: entry.size.unwrap_or(0), + checksum: entry.checksum.clone().unwrap_or_default(), + updated_at: entry.updated_at.clone().unwrap_or_default(), + server_path: format!("{}/{}", server_path.trim_end_matches('/'), entry.name), + }; + if let Ok(json) = serde_json::to_string_pretty(&placeholder) { + std::fs::write(&cloud_path, json).ok(); + log.push(format!("Platzhalter: {}.cloud", entry.name)); + } + } + } + } + + // Remove .cloud files for deleted server files + if let Ok(dir_entries) = std::fs::read_dir(local_dir) { + for entry in dir_entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if name.ends_with(".cloud") { + let real_name = name.trim_end_matches(".cloud"); + let exists_on_server = entries.iter().any(|e| e.name == real_name); + if !exists_on_server { + std::fs::remove_file(entry.path()).ok(); + log.push(format!("Entfernt: {}", name)); + } + } + } + } + } + + /// Full sync: download all files from server + async fn sync_full_download(&self, entries: &[FileEntry], local_dir: &Path, + log: &mut Vec) { + for entry in entries { + let local_path = local_dir.join(&entry.name); + + if entry.is_folder { + std::fs::create_dir_all(&local_path).ok(); + if let Some(children) = &entry.children { + Box::pin(self.sync_full_download(children, &local_path, log)).await; + } + } else { + if entry.locked.unwrap_or(false) { continue; } + let needs_download = if local_path.exists() { let local_hash = compute_file_hash(&local_path); local_hash != entry.checksum.as_deref().unwrap_or("") @@ -106,6 +168,12 @@ impl SyncEngine { true }; + // Remove stale .cloud placeholder + let cloud_path = local_dir.join(format!("{}.cloud", entry.name)); + if cloud_path.exists() { + std::fs::remove_file(&cloud_path).ok(); + } + if needs_download { match self.api.download_file(entry.id, &local_path).await { Ok(_) => log.push(format!("Heruntergeladen: {}", entry.name)), @@ -116,8 +184,9 @@ impl SyncEngine { } } - async fn sync_upload_recursive(&self, server_entries: &[FileEntry], local_dir: &Path, - parent_id: Option, log: &mut Vec) { + /// Full sync: upload new/changed local files + async fn sync_full_upload(&self, server_entries: &[FileEntry], local_dir: &Path, + parent_id: Option, log: &mut Vec) { let server_names: HashMap = server_entries.iter() .map(|e| (e.name.clone(), e)) .collect(); @@ -131,50 +200,38 @@ impl SyncEngine { let name = entry.file_name().to_string_lossy().to_string(); let path = entry.path(); - // Skip hidden files and sync metadata - if name.starts_with('.') || name == ".minicloud_sync" { + // Skip hidden, temp, .cloud files + if name.starts_with('.') || name.starts_with('~') || name.ends_with(".tmp") + || name.ends_with(".cloud") { continue; } if path.is_dir() { - if let Some(server_entry) = server_names.get(&name) { - // Folder exists on server, recurse - if let Some(children) = &server_entry.children { - Box::pin(self.sync_upload_recursive( - children, &path, Some(server_entry.id), log - )).await; + if let Some(se) = server_names.get(&name) { + if let Some(children) = &se.children { + Box::pin(self.sync_full_upload(children, &path, Some(se.id), log)).await; } } else { - // Folder doesn't exist on server, create + upload contents match self.api.create_folder(&name, parent_id).await { Ok(folder) => { log.push(format!("Ordner erstellt: {}", name)); - Box::pin(self.sync_upload_recursive( - &[], &path, Some(folder.id), log - )).await; + Box::pin(self.sync_full_upload(&[], &path, Some(folder.id), log)).await; } Err(e) => log.push(format!("Ordner-Fehler {}: {}", name, e)), } } } else { - // File - let needs_upload = if let Some(server_entry) = server_names.get(&name) { - // Check if local version is newer - let local_hash = compute_file_hash(&path); - local_hash != server_entry.checksum.as_deref().unwrap_or("") + let needs_upload = if let Some(se) = server_names.get(&name) { + if se.locked.unwrap_or(false) { + log.push(format!("Zurueckgehalten (gesperrt): {}", name)); + continue; + } + compute_file_hash(&path) != se.checksum.as_deref().unwrap_or("") } else { - true // New file + true }; if needs_upload { - // Check if file is locked on server - if let Some(server_entry) = server_names.get(&name) { - if server_entry.locked.unwrap_or(false) { - log.push(format!("Zurueckgehalten (gesperrt): {}", name)); - continue; - } - } - match self.api.upload_file(&path, parent_id).await { Ok(_) => log.push(format!("Hochgeladen: {}", name)), Err(e) => log.push(format!("Upload-Fehler {}: {}", name, e)), @@ -183,6 +240,76 @@ impl SyncEngine { } } } + + /// Open a .cloud placeholder file: download the real file, rename, return path + pub async fn open_cloud_file(&self, cloud_path: &Path) -> Result { + let content = std::fs::read_to_string(cloud_path) + .map_err(|e| format!("Platzhalter lesen: {}", e))?; + let placeholder: CloudPlaceholder = serde_json::from_str(&content) + .map_err(|e| format!("Platzhalter ungueltig: {}", e))?; + + let real_path = cloud_path.with_extension(""); + // Remove .cloud extension to get real filename + let real_path = cloud_path.parent().unwrap().join(&placeholder.name); + + // Download + self.api.download_file(placeholder.id, &real_path).await?; + + // Remove placeholder + std::fs::remove_file(cloud_path).ok(); + + // Lock on server + let _ = self.api.lock_file(placeholder.id, "Desktop Sync Client").await; + + Ok(real_path) + } + + /// Close a previously opened file: sync back, recreate .cloud, unlock + pub async fn close_cloud_file(&self, real_path: &Path, file_id: i64) -> Result<(), String> { + // Upload changes + // We need the parent_id - for now upload to the same location + // The server handles overwrite by filename + let _ = self.api.upload_file(real_path, None).await; + + // Unlock + let _ = self.api.unlock_file(file_id).await; + + // Delete local copy and recreate placeholder + let cloud_path = real_path.parent().unwrap() + .join(format!("{}.cloud", real_path.file_name().unwrap().to_string_lossy())); + + let size = std::fs::metadata(real_path).map(|m| m.len() as i64).unwrap_or(0); + let checksum = compute_file_hash(real_path); + + let placeholder = CloudPlaceholder { + id: file_id, + name: real_path.file_name().unwrap().to_string_lossy().to_string(), + size, + checksum, + updated_at: chrono::Utc::now().to_rfc3339(), + server_path: String::new(), + }; + if let Ok(json) = serde_json::to_string_pretty(&placeholder) { + std::fs::write(&cloud_path, json).ok(); + } + + std::fs::remove_file(real_path).ok(); + Ok(()) + } +} + +fn find_subtree(tree: &[FileEntry], folder_id: i64) -> Option> { + for entry in tree { + if entry.id == folder_id { + return entry.children.clone(); + } + if let Some(children) = &entry.children { + if let Some(result) = find_subtree(children, folder_id) { + return Some(result); + } + } + } + None } pub fn compute_file_hash(path: &Path) -> String { diff --git a/clients/desktop/src/App.vue b/clients/desktop/src/App.vue index 3176735..bfa3062 100644 --- a/clients/desktop/src/App.vue +++ b/clients/desktop/src/App.vue @@ -2,15 +2,16 @@ import { ref, onMounted, onUnmounted } from "vue"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; +import { open as dialogOpen } from "@tauri-apps/plugin-dialog"; -const screen = ref("login"); // login | main +const screen = ref("login"); const serverUrl = ref("https://"); const username = ref(""); const password = ref(""); const loginError = ref(""); const loginLoading = ref(false); -const syncDir = ref(""); +const syncPaths = ref([]); const syncLog = ref([]); const syncing = ref(false); const syncStatus = ref("Nicht verbunden"); @@ -19,10 +20,15 @@ const fileTree = ref([]); const fileChanges = ref([]); const autoSyncActive = ref(false); -let unlistenStatus = null; -let unlistenLog = null; -let unlistenError = null; -let unlistenFileChange = null; +// New sync path form +const showAddPath = ref(false); +const newPathLocal = ref(""); +const newPathServerFolder = ref(""); +const newPathServerId = ref(null); +const newPathMode = ref("virtual"); +const serverFolders = ref([]); + +let unlistenStatus, unlistenLog, unlistenError, unlistenFileChange, unlistenTrigger; async function handleLogin() { loginError.value = ""; @@ -36,12 +42,8 @@ async function handleLogin() { userInfo.value = result; screen.value = "main"; syncStatus.value = `Verbunden als ${result.username}`; - - // Default sync dir - const home = navigator.platform.includes("Win") ? "C:\\Users\\" + result.username : "/home/" + result.username; - syncDir.value = `${home}/MiniCloud`; - await loadFileTree(); + await loadSyncPaths(); } catch (err) { loginError.value = String(err); } finally { @@ -49,79 +51,123 @@ async function handleLogin() { } } -async function runSync() { - syncing.value = true; - syncStatus.value = "Erster Sync..."; - try { - await invoke("set_sync_dir", { path: syncDir.value }); - const log = await invoke("start_sync"); - syncLog.value = [...log, ...syncLog.value].slice(0, 100); - syncStatus.value = "Synchronisiert"; - autoSyncActive.value = true; - await loadFileTree(); - } catch (err) { - syncStatus.value = `Fehler: ${err}`; - } finally { - syncing.value = false; - } -} - async function loadFileTree() { try { fileTree.value = await invoke("get_file_tree"); - } catch (err) { - console.error("Tree-Fehler:", err); + // Build flat folder list for sync path selection + serverFolders.value = [{ id: null, name: "/ (Alle Dateien)", path: "/" }]; + flattenFolders(fileTree.value, "", serverFolders.value); + } catch (err) { console.error(err); } +} + +function flattenFolders(entries, prefix, list) { + for (const e of entries) { + if (e.is_folder) { + const path = `${prefix}/${e.name}`; + list.push({ id: e.id, name: path, path }); + if (e.children) flattenFolders(e.children, path, list); + } } } -function formatSize(bytes) { - if (!bytes) return ""; - const units = ["B", "KB", "MB", "GB"]; - let i = 0; - let size = bytes; - while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; } - return `${size.toFixed(i > 0 ? 1 : 0)} ${units[i]}`; +async function loadSyncPaths() { + try { syncPaths.value = await invoke("get_sync_paths"); } + catch { syncPaths.value = []; } } -function timestamp() { +async function browseFolder() { + try { + const selected = await dialogOpen({ directory: true, multiple: false, title: "Sync-Ordner waehlen" }); + if (selected) newPathLocal.value = selected; + } catch { /* dialog cancelled */ } +} + +async function addSyncPath() { + if (!newPathLocal.value) return; + try { + await invoke("add_sync_path", { + serverPath: newPathServerFolder.value || "/", + serverFolderId: newPathServerId.value, + localDir: newPathLocal.value, + mode: newPathMode.value, + }); + showAddPath.value = false; + newPathLocal.value = ""; + newPathServerFolder.value = ""; + newPathServerId.value = null; + newPathMode.value = "virtual"; + await loadSyncPaths(); + } catch (err) { alert(err); } +} + +async function removeSyncPath(id) { + await invoke("remove_sync_path", { id }); + await loadSyncPaths(); +} + +async function toggleMode(id) { + await invoke("toggle_sync_mode", { id }); + await loadSyncPaths(); +} + +function selectServerFolder(folder) { + newPathServerFolder.value = folder.path; + newPathServerId.value = folder.id; +} + +async function startSync() { + syncing.value = true; + syncStatus.value = "Erster Sync..."; + try { + const log = await invoke("start_sync"); + syncLog.value = [...log.map(m => `[${ts()}] ${m}`), ...syncLog.value].slice(0, 200); + syncStatus.value = "Synchronisiert"; + autoSyncActive.value = true; + await loadFileTree(); + } catch (err) { syncStatus.value = `Fehler: ${err}`; } + finally { syncing.value = false; } +} + +async function syncNow() { + syncing.value = true; + try { + const log = await invoke("run_sync_now"); + syncLog.value = [...log.map(m => `[${ts()}] ${m}`), ...syncLog.value].slice(0, 200); + await loadFileTree(); + } catch (err) { syncStatus.value = `Fehler: ${err}`; } + finally { syncing.value = false; } +} + +function ts() { return new Date().toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit", second: "2-digit" }); } +function formatSize(b) { + if (!b) return ""; + const u = ["B","KB","MB","GB"]; let i=0; let s=b; + while (s>=1024 && i0?1:0)} ${u[i]}`; +} + onMounted(async () => { - // Listen for sync events from Rust backend - unlistenStatus = await listen("sync-status", (event) => { - if (event.payload === "syncing") { - syncStatus.value = "Synchronisiere..."; - syncing.value = true; - } else if (event.payload === "synced") { - syncStatus.value = "Synchronisiert"; - syncing.value = false; - loadFileTree(); - } + unlistenStatus = await listen("sync-status", e => { + syncing.value = e.payload === "syncing"; + syncStatus.value = e.payload === "syncing" ? "Synchronisiere..." : "Synchronisiert"; + if (e.payload === "synced") loadFileTree(); }); - - unlistenLog = await listen("sync-log", (event) => { - const entries = event.payload.map(msg => `[${timestamp()}] ${msg}`); - syncLog.value = [...entries, ...syncLog.value].slice(0, 200); + unlistenLog = await listen("sync-log", e => { + syncLog.value = [...e.payload.map(m => `[${ts()}] ${m}`), ...syncLog.value].slice(0, 200); }); - - unlistenError = await listen("sync-error", (event) => { - syncStatus.value = `Fehler: ${event.payload}`; - syncLog.value = [`[${timestamp()}] FEHLER: ${event.payload}`, ...syncLog.value].slice(0, 200); + unlistenError = await listen("sync-error", e => { + syncStatus.value = `Fehler: ${e.payload}`; syncing.value = false; }); - - unlistenFileChange = await listen("file-change", (event) => { - fileChanges.value = [`[${timestamp()}] ${event.payload}`, ...fileChanges.value].slice(0, 50); + unlistenFileChange = await listen("file-change", e => { + fileChanges.value = [`[${ts()}] ${e.payload}`, ...fileChanges.value].slice(0, 50); }); + unlistenTrigger = await listen("trigger-sync", () => syncNow()); }); - -onUnmounted(() => { - unlistenStatus?.(); - unlistenLog?.(); - unlistenError?.(); - unlistenFileChange?.(); -}); +onUnmounted(() => { unlistenStatus?.(); unlistenLog?.(); unlistenError?.(); unlistenFileChange?.(); unlistenTrigger?.(); });