From 607d18a7e20956683b76955fc5e232c0cdb3a04b Mon Sep 17 00:00:00 2001 From: Stefan Hacker Date: Sun, 12 Apr 2026 01:02:21 +0200 Subject: [PATCH] feat: Lokaler Datei-Browser mit Offline-Markierung + Kontextmenue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Datei-Browser im Client: - Zeigt lokalen Sync-Ordner mit allen Dateien an - Ordner navigierbar mit Breadcrumb - Status pro Datei: ☁ Cloud (Platzhalter) / 📄 Offline (echte Datei) - Badges: blaues "Cloud" oder gruenes "Offline" - Cloud-Dateien zeigen Originalgroesse aus .cloud-Metadaten - Aktualisiert sich automatisch nach jedem Sync Rechtsklick-Kontextmenue: - .cloud Datei: "Oeffnen (herunterladen)" + "Offline verfuegbar machen" - Echte Datei: "Nicht mehr offline (Platzhalter)" - Doppelklick auf Ordner = navigieren - Doppelklick auf .cloud = herunterladen + oeffnen Rust-Backend: - browse_sync_folder: Listet lokale Dateien mit Status auf (is_cloud, is_offline, cloud_size aus JSON-Metadaten) - Sortierung: Ordner zuerst, dann alphabetisch Co-Authored-By: Claude Opus 4.6 (1M context) --- clients/desktop/src-tauri/src/lib.rs | 80 ++++++++++++++++ clients/desktop/src/App.vue | 136 ++++++++++++++++++++++++++- 2 files changed, 214 insertions(+), 2 deletions(-) diff --git a/clients/desktop/src-tauri/src/lib.rs b/clients/desktop/src-tauri/src/lib.rs index 6850371..9ea94bc 100644 --- a/clients/desktop/src-tauri/src/lib.rs +++ b/clients/desktop/src-tauri/src/lib.rs @@ -216,6 +216,85 @@ async fn unlock_file_cmd(state: State<'_, AppState>, file_id: i64) -> Result, // original size from .cloud metadata +} + +#[tauri::command] +fn browse_sync_folder(state: State<'_, AppState>, sub_path: Option) -> Result, String> { + let paths = state.sync_paths.lock().unwrap(); + if paths.is_empty() { + return Err("Keine Sync-Pfade konfiguriert".to_string()); + } + + // If sub_path given, use it directly; otherwise use first sync path + let base_dir = if let Some(ref sp) = sub_path { + PathBuf::from(sp) + } else { + PathBuf::from(&paths[0].local_dir) + }; + + if !base_dir.exists() { + return Ok(Vec::new()); + } + + let mut entries = Vec::new(); + let dir = std::fs::read_dir(&base_dir).map_err(|e| e.to_string())?; + + for entry in dir.flatten() { + 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(); + 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()); + } + } + } + + // A real (non-.cloud) file = offline available + let is_offline = !is_cloud && !is_folder; + + entries.push(LocalFileEntry { + name: display_name, + path: path.to_string_lossy().to_string(), + is_folder, + is_cloud, + is_offline, + size, + cloud_size, + }); + } + + // 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())) + }); + + Ok(entries) +} + // --- Offline-Markierung --- #[tauri::command] @@ -477,6 +556,7 @@ pub fn run() { get_status, lock_file_cmd, unlock_file_cmd, + browse_sync_folder, mark_offline, unmark_offline, ]) diff --git a/clients/desktop/src/App.vue b/clients/desktop/src/App.vue index 16a6bf3..b8542b4 100644 --- a/clients/desktop/src/App.vue +++ b/clients/desktop/src/App.vue @@ -28,6 +28,78 @@ const newPathServerId = ref(null); const newPathMode = ref("virtual"); const serverFolders = ref([]); +// Local file browser +const localFiles = ref([]); +const localBreadcrumb = ref([]); +const contextMenu = ref({ show: false, x: 0, y: 0, file: null }); + +async function loadLocalFiles(subPath = null) { + try { + localFiles.value = await invoke("browse_sync_folder", { subPath }); + if (subPath) { + // Build breadcrumb + const sp = syncPaths.value[0]; + if (sp) { + const rel = subPath.replace(sp.local_dir, "").replace(/^[/\\]/, ""); + const parts = rel.split(/[/\\]/).filter(Boolean); + localBreadcrumb.value = [{ name: "Sync", path: sp.local_dir }]; + let current = sp.local_dir; + for (const p of parts) { + current += (current.endsWith("/") || current.endsWith("\\") ? "" : "/") + p; + localBreadcrumb.value.push({ name: p, path: current }); + } + } + } else { + localBreadcrumb.value = []; + } + } catch { localFiles.value = []; } +} + +function openLocalFolder(file) { + if (file.is_folder) loadLocalFiles(file.path); +} + +function showContextMenu(e, file) { + e.preventDefault(); + contextMenu.value = { show: true, x: e.clientX, y: e.clientY, file }; +} + +function hideContextMenu() { + contextMenu.value = { show: false, x: 0, y: 0, file: null }; +} + +async function doMarkOffline(file) { + hideContextMenu(); + try { + const result = await invoke("mark_offline", { cloudPath: file.path }); + syncLog.value = [`[${ts()}] ${result}`, ...syncLog.value].slice(0, 200); + await loadLocalFiles(null); + } catch (err) { + syncLog.value = [`[${ts()}] Fehler: ${err}`, ...syncLog.value].slice(0, 200); + } +} + +async function doUnmarkOffline(file) { + hideContextMenu(); + try { + const result = await invoke("unmark_offline", { cloudPath: file.path }); + syncLog.value = [`[${ts()}] ${result}`, ...syncLog.value].slice(0, 200); + await loadLocalFiles(null); + } catch (err) { + syncLog.value = [`[${ts()}] Fehler: ${err}`, ...syncLog.value].slice(0, 200); + } +} + +async function doOpenCloudFile(file) { + hideContextMenu(); + try { + const realPath = await invoke("open_cloud_file", { cloudPath: file.path }); + syncLog.value = [`[${ts()}] Geoeffnet: ${realPath}`, ...syncLog.value].slice(0, 200); + } catch (err) { + syncLog.value = [`[${ts()}] Fehler: ${err}`, ...syncLog.value].slice(0, 200); + } +} + let unlistenStatus, unlistenLog, unlistenError, unlistenFileChange, unlistenTrigger, unlistenCloudOpen; async function handleLogin() { @@ -124,6 +196,7 @@ async function startSync() { syncStatus.value = "Synchronisiert"; autoSyncActive.value = true; await loadFileTree(); + await loadLocalFiles(null); } catch (err) { syncStatus.value = `Fehler: ${err}`; } finally { syncing.value = false; } } @@ -153,7 +226,7 @@ onMounted(async () => { unlistenStatus = await listen("sync-status", e => { syncing.value = e.payload === "syncing"; syncStatus.value = e.payload === "syncing" ? "Synchronisiere..." : "Synchronisiert"; - if (e.payload === "synced") loadFileTree(); + if (e.payload === "synced") { loadFileTree(); loadLocalFiles(null); } }); unlistenLog = await listen("sync-log", e => { syncLog.value = [...e.payload.map(m => `[${ts()}] ${m}`), ...syncLog.value].slice(0, 200); @@ -279,6 +352,50 @@ onUnmounted(() => { unlistenStatus?.(); unlistenLog?.(); unlistenError?.(); unli + +
+
+

Lokale Dateien

+ +
+ +
+ + {{ b.name }} + / + +
+ +
+
+ {{ f.is_folder ? '📁' : (f.is_cloud ? '☁' : '📄') }} + {{ f.name }} + Cloud + Offline + {{ formatSize(f.cloud_size || f.size) }} +
+
Ordner ist leer
+
+
+ + +
+
+ 📥 Oeffnen (herunterladen) +
+
+ 💾 Offline verfuegbar machen +
+
+ ☁ Nicht mehr offline (Platzhalter) +
+
Abbrechen
+
+
@@ -373,5 +490,20 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;f .empty{text-align:center;color:#999;padding:1rem;font-size:.85rem} .log-list{max-height:150px;overflow-y:auto;font-family:monospace;font-size:.78rem} .log-item{padding:.2rem 0;border-bottom:1px solid #f8f8f8;color:#555}.log-item.change{color:#1565c0} -@media(prefers-color-scheme:dark){body{color:#e0e0e0;background:#1a1a1a}.login-card,.section{background:#2a2a2a}.toolbar{background:#2a2a2a;border-color:#3a3a3a}.field input,.field select{background:#333;border-color:#444;color:#e0e0e0}.status-badge{background:#1b5e20;color:#a5d6a7}.status-badge.syncing{background:#e65100;color:#ffcc80}.add-path-form{background:#333;border-color:#444}.mode-option{border-color:#444}.mode-option.active{border-color:#4a90d9;background:#1a3a5c}.sync-path-card{border-color:#3a3a3a}.tree-item{border-color:#333}.log-item{border-color:#333;color:#aaa}.log-item.change{color:#64b5f6}} +.local-breadcrumb{font-size:.85rem;margin-bottom:.5rem;color:#666} +.local-breadcrumb a{color:#4a90d9;cursor:pointer;text-decoration:none} +.local-breadcrumb a:hover{text-decoration:underline} +.local-file-list{max-height:300px;overflow-y:auto} +.local-file-item{display:flex;align-items:center;gap:.5rem;padding:.35rem .25rem;border-bottom:1px solid #f5f5f5;font-size:.85rem;cursor:default;user-select:none} +.local-file-item:hover{background:#f8f8f8} +.lf-icon{flex-shrink:0;font-size:1rem} +.lf-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.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-size{font-size:.75rem;color:#999;flex-shrink:0} +.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} +.cm-item{padding:.5rem .75rem;cursor:pointer;font-size:.85rem} +.cm-item:hover{background:#f0f0f0} +@media(prefers-color-scheme:dark){body{color:#e0e0e0;background:#1a1a1a}.login-card,.section{background:#2a2a2a}.toolbar{background:#2a2a2a;border-color:#3a3a3a}.field input,.field select{background:#333;border-color:#444;color:#e0e0e0}.status-badge{background:#1b5e20;color:#a5d6a7}.status-badge.syncing{background:#e65100;color:#ffcc80}.add-path-form{background:#333;border-color:#444}.mode-option{border-color:#444}.mode-option.active{border-color:#4a90d9;background:#1a3a5c}.sync-path-card{border-color:#3a3a3a}.tree-item{border-color:#333}.log-item{border-color:#333;color:#aaa}.log-item.change{color:#64b5f6}.local-file-item{border-color:#333}.local-file-item:hover{background:#333}.context-menu{background:#2a2a2a;border-color:#444}.cm-item:hover{background:#3a3a3a}}