From 23563622f8fc413b54f5e631d4dba8fc23ce86e4 Mon Sep 17 00:00:00 2001 From: Stefan Hacker Date: Sun, 12 Apr 2026 11:32:01 +0200 Subject: [PATCH] feat: Lock-Badge + smartes Kontextmenue in lokaler Client-Ansicht MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die lokale Dateiliste im Client zeigt jetzt pro Datei ein 🔒-Badge mit Nutzername wenn ausgecheckt (wie Server-Ansicht + Web-GUI). browse_sync_folder zieht den Server-Tree bei jedem Aufruf und korreliert via Journal-Lookup (oder .cloud-Metadaten) die lokale Datei mit dem File-Lock-Status. Rechtsklick-Menue reagiert jetzt auf den Lock-Status: - Frei -> "Auschecken (sperren)" - Eigener/fremder -> "Entsperren (einchecken)" Neuer Tauri-Command lock_file_cmd fuer reines Sperren ohne Oeffnen. Co-Authored-By: Claude Opus 4.6 (1M context) --- clients/desktop/src-tauri/src/lib.rs | 85 ++++++++++++++++++++++++++-- clients/desktop/src/App.vue | 33 ++++++++--- 2 files changed, 105 insertions(+), 13 deletions(-) diff --git a/clients/desktop/src-tauri/src/lib.rs b/clients/desktop/src-tauri/src/lib.rs index 9ee67ab..d6f3e75 100644 --- a/clients/desktop/src-tauri/src/lib.rs +++ b/clients/desktop/src-tauri/src/lib.rs @@ -373,6 +373,15 @@ async fn unlock_file_cmd(state: State<'_, AppState>, file_id: i64) -> Result, file_id: i64) -> Result { + let api = state.api.lock().unwrap().clone().ok_or("Nicht eingeloggt")?; + api.lock_file(file_id, "Desktop Sync Client").await?; + let mut locked = state.locked_files.lock().unwrap(); + if !locked.contains(&file_id) { locked.push(file_id); } + Ok("Datei ausgecheckt".to_string()) +} + #[tauri::command] async fn get_file_tree(state: State<'_, AppState>) -> Result { let api = state.api.lock().unwrap().clone().ok_or("Nicht eingeloggt")?; @@ -409,11 +418,33 @@ struct LocalFileEntry { is_offline: bool, // real file (offline available) size: i64, cloud_size: Option, // original size from .cloud metadata + file_id: Option, + locked: bool, + locked_by: Option, +} + +fn collect_locks( + entries: &[sync::api::FileEntry], + out: &mut std::collections::HashMap)>, +) { + for e in entries { + if e.locked.unwrap_or(false) { + out.insert(e.id, (true, e.locked_by.clone())); + } + if let Some(children) = &e.children { + collect_locks(children, out); + } + } } #[tauri::command] -fn browse_sync_folder(state: State<'_, AppState>, sub_path: Option) -> Result, String> { - let paths = state.sync_paths.lock().unwrap(); +async fn browse_sync_folder(state: State<'_, AppState>, sub_path: Option) -> Result, String> { + let (paths, api_opt, journal) = { + let p = state.sync_paths.lock().unwrap().clone(); + let a = state.api.lock().unwrap().clone(); + (p, a, state.journal.clone()) + }; + if paths.is_empty() { return Err("Keine Sync-Pfade konfiguriert".to_string()); } @@ -429,6 +460,27 @@ fn browse_sync_folder(state: State<'_, AppState>, sub_path: Option) -> R return Ok(Vec::new()); } + // Figure out which sync path this base_dir belongs to so we can compute + // relative paths for the journal lookup. + let sync_path = paths.iter().find(|sp| { + base_dir.starts_with(&sp.local_dir) || PathBuf::from(&sp.local_dir) == base_dir + }).cloned(); + + // Fetch server tree once so we know which files are locked. If the + // server is unreachable we simply show no lock badges. + let locks: std::collections::HashMap)> = if let Some(api) = api_opt { + match api.get_sync_tree().await { + Ok(tree) => { + let mut map = std::collections::HashMap::new(); + collect_locks(&tree, &mut map); + map + } + Err(_) => std::collections::HashMap::new(), + } + } else { + std::collections::HashMap::new() + }; + let mut entries = Vec::new(); let dir = std::fs::read_dir(&base_dir).map_err(|e| e.to_string())?; @@ -436,26 +488,44 @@ fn browse_sync_folder(state: State<'_, AppState>, sub_path: Option) -> R let name = entry.file_name().to_string_lossy().to_string(); let path = entry.path(); - // Skip hidden files if name.starts_with('.') || name.starts_with('~') { continue; } let is_folder = path.is_dir(); let is_cloud = name.ends_with(".cloud"); let size = std::fs::metadata(&path).map(|m| m.len() as i64).unwrap_or(0); - // For .cloud files, read the original size from JSON let mut cloud_size = None; let mut display_name = name.clone(); + let mut file_id: Option = None; + if is_cloud { display_name = name.trim_end_matches(".cloud").to_string(); if let Ok(content) = std::fs::read_to_string(&path) { if let Ok(json) = serde_json::from_str::(&content) { cloud_size = json.get("size").and_then(|v| v.as_i64()); + file_id = json.get("id").and_then(|v| v.as_i64()); } } } - // A real (non-.cloud) file = offline available + // For offline files / folders: look up file_id via journal + if file_id.is_none() && !is_folder { + if let Some(sp) = &sync_path { + if let Ok(rel) = path.strip_prefix(&sp.local_dir) { + let rel_str = rel.to_string_lossy().replace('\\', "/"); + if let Some(je) = journal.get(&sp.id, &rel_str) { + file_id = je.file_id; + } + } + } + } + + let (locked, locked_by) = file_id + .and_then(|id| locks.get(&id)) + .cloned() + .map(|(b, by)| (b, by)) + .unwrap_or((false, None)); + let is_offline = !is_cloud && !is_folder; entries.push(LocalFileEntry { @@ -466,10 +536,12 @@ fn browse_sync_folder(state: State<'_, AppState>, sub_path: Option) -> R is_offline, size, cloud_size, + file_id, + locked, + locked_by, }); } - // Sort: folders first, then by name entries.sort_by(|a, b| { b.is_folder.cmp(&a.is_folder).then(a.name.to_lowercase().cmp(&b.name.to_lowercase())) }); @@ -940,6 +1012,7 @@ pub fn run() { get_file_tree, get_status, unlock_file_cmd, + lock_file_cmd, browse_sync_folder, mark_offline, unmark_offline, diff --git a/clients/desktop/src/App.vue b/clients/desktop/src/App.vue index 224d942..e175bbf 100644 --- a/clients/desktop/src/App.vue +++ b/clients/desktop/src/App.vue @@ -86,20 +86,34 @@ async function doMarkOffline(file) { async function doUnlockFile(file) { hideContextMenu(); - // Find file ID from server tree - const serverFile = findFileInTree(fileTree.value, file.name); - if (!serverFile) { + const fileId = file.file_id ?? findFileInTree(fileTree.value, file.name)?.id; + if (!fileId) { syncLog.value = [`[${ts()}] Fehler: Datei nicht auf Server gefunden`, ...syncLog.value]; return; } try { - await invoke("unlock_file_cmd", { fileId: serverFile.id }); + await invoke("unlock_file_cmd", { fileId }); syncLog.value = [`[${ts()}] Entsperrt: ${file.name}`, ...syncLog.value].slice(0, 200); } catch (err) { syncLog.value = [`[${ts()}] Fehler: ${err}`, ...syncLog.value].slice(0, 200); } } +async function doLockOnly(file) { + hideContextMenu(); + const fileId = file.file_id ?? findFileInTree(fileTree.value, file.name)?.id; + if (!fileId) { + syncLog.value = [`[${ts()}] Fehler: Datei nicht auf Server gefunden`, ...syncLog.value]; + return; + } + try { + await invoke("lock_file_cmd", { fileId }); + syncLog.value = [`[${ts()}] Ausgecheckt: ${file.name}`, ...syncLog.value].slice(0, 200); + } catch (err) { + syncLog.value = [`[${ts()}] Fehler: ${err}`, ...syncLog.value].slice(0, 200); + } +} + function findFileInTree(entries, name) { for (const e of entries) { if (e.name === name) return e; @@ -463,6 +477,7 @@ onUnmounted(() => { unlistenStatus?.(); unlistenLog?.(); unlistenError?.(); unli {{ f.name }} Cloud Offline + 🔒 {{ f.locked_by }} {{ formatSize(f.cloud_size || f.size) }}
Ordner ist leer
@@ -481,12 +496,15 @@ onUnmounted(() => { unlistenStatus?.(); unlistenLog?.(); unlistenError?.(); unli
📂 Oeffnen (auschecken)
+
+ 🔒 Auschecken (sperren) +
+
+ 🔓 Entsperren (einchecken) +
☁ Nicht mehr offline (Platzhalter)
-
- 🔓 Entsperren (Freigeben fuer andere) -
Abbrechen
@@ -604,6 +622,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;f .lf-badge{font-size:.65rem;padding:.1rem .3rem;border-radius:3px;flex-shrink:0} .lf-badge.cloud{background:#e3f2fd;color:#1565c0} .lf-badge.offline{background:#e8f5e9;color:#2e7d32} +.lf-badge.locked{background:#fff3e0;color:#e65100} .lf-size{font-size:.75rem;color:#999;flex-shrink:0} .checkbox-row{display:flex;align-items:center;gap:.5rem;font-size:.85rem;cursor:pointer} .context-menu{position:fixed;background:#fff;border:1px solid #ddd;border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,.15);z-index:9999;min-width:200px;padding:.25rem 0}