feat: Virtual Files, Multi-Sync-Pfade, Full Sync, Ordner-Dialog

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) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker
2026-04-12 00:34:03 +02:00
parent 4662286959
commit 16d514f7f1
4 changed files with 638 additions and 375 deletions
+168 -79
View File
@@ -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<Option<MiniCloudApi>>,
sync_engine: Mutex<Option<SyncEngine>>,
sync_dir: Mutex<Option<PathBuf>>,
username: Mutex<Option<String>>,
watcher: Mutex<Option<FileWatcher>>,
watchers: Mutex<Vec<FileWatcher>>,
sync_running: Arc<Mutex<bool>>,
locked_files: Mutex<Vec<i64>>, // file IDs we have locked
locked_files: Mutex<Vec<i64>>,
sync_paths: Mutex<Vec<SyncPath>>,
}
// --- 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<String, String> {
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<i64>,
local_dir: String,
mode: String, // "virtual" or "full"
) -> Result<serde_json::Value, String> {
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<Vec<String>, 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<String, String> {
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<serde_json::Value, String> {
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<String, String> {
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<Vec<String>, 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<Vec<String>, String> {
async fn run_sync_now(state: State<'_, AppState>) -> Result<Vec<String>, 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<String, String> {
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<serde_json::Value, String> {
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<serde_json::Value, String> {
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<serde_json::Value, String> {
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<String, String> {
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<Str
Ok("Datei entsperrt".to_string())
}
/// Background sync: auto-sync every 30s + process file watcher events + heartbeat for locks
// --- Background Threads ---
fn start_background_sync(
app: AppHandle,
sync_running: Arc<Mutex<bool>>,
api: MiniCloudApi,
sync_dir: PathBuf,
paths: Vec<SyncPath>,
) {
// 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::<AppState>();
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::<AppState>();
let watcher_guard = state.watcher.lock().unwrap();
if let Some(watcher) = watcher_guard.as_ref() {
let state = app_w.state::<AppState>();
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,
])
+219 -92
View File
@@ -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<i64>, // 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<SyncPath>,
last_sync: Option<String>,
}
#[derive(Debug, Clone)]
pub enum SyncAction {
Download { id: i64, name: String, server_path: String },
Upload { local_path: PathBuf, parent_id: Option<i64> },
CreateFolder { name: String, parent_id: Option<i64> },
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<Vec<String>, 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<Vec<String>, 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<Vec<String>, 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<String>) {
/// Virtual sync: create .cloud placeholder files
async fn sync_virtual(&self, entries: &[FileEntry], local_dir: &Path,
server_path: &str, log: &mut Vec<String>) {
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<String>) {
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<i64>, log: &mut Vec<String>) {
/// Full sync: upload new/changed local files
async fn sync_full_upload(&self, server_entries: &[FileEntry], local_dir: &Path,
parent_id: Option<i64>, log: &mut Vec<String>) {
let server_names: HashMap<String, &FileEntry> = 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<PathBuf, String> {
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<Vec<FileEntry>> {
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 {