Compare commits
2 Commits
adaa19a1ef
...
81574c8991
| Author | SHA1 | Date | |
|---|---|---|---|
| 81574c8991 | |||
| 607d18a7e2 |
@@ -216,6 +216,85 @@ async fn unlock_file_cmd(state: State<'_, AppState>, file_id: i64) -> Result<Str
|
||||
Ok("Datei entsperrt".to_string())
|
||||
}
|
||||
|
||||
// --- Local File Browser ---
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct LocalFileEntry {
|
||||
name: String,
|
||||
path: String,
|
||||
is_folder: bool,
|
||||
is_cloud: bool, // .cloud placeholder
|
||||
is_offline: bool, // real file (offline available)
|
||||
size: i64,
|
||||
cloud_size: Option<i64>, // original size from .cloud metadata
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn browse_sync_folder(state: State<'_, AppState>, sub_path: Option<String>) -> Result<Vec<LocalFileEntry>, 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::<serde_json::Value>(&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,
|
||||
])
|
||||
|
||||
@@ -67,6 +67,8 @@ impl SyncEngine {
|
||||
match sp.mode {
|
||||
SyncMode::Virtual => {
|
||||
self.sync_virtual(&entries, &local_dir, &sp.server_path, &mut log).await;
|
||||
// Also upload new local files (not on server yet)
|
||||
self.sync_upload_new(&entries, &local_dir, sp.server_folder_id, &mut log).await;
|
||||
}
|
||||
SyncMode::Full => {
|
||||
self.sync_full_download(&entries, &local_dir, &mut log).await;
|
||||
@@ -147,6 +149,72 @@ impl SyncEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload new local files that don't exist on server yet (for both Virtual + Full mode)
|
||||
async fn sync_upload_new(&self, server_entries: &[FileEntry], local_dir: &Path,
|
||||
parent_id: Option<i64>, log: &mut Vec<String>) {
|
||||
let server_names: std::collections::HashSet<String> = server_entries.iter()
|
||||
.map(|e| e.name.clone()).collect();
|
||||
|
||||
let entries = match std::fs::read_dir(local_dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
let path = entry.path();
|
||||
|
||||
// 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() {
|
||||
// New folder: create on server + recurse
|
||||
if !server_names.contains(&name) {
|
||||
match self.api.create_folder(&name, parent_id).await {
|
||||
Ok(folder) => {
|
||||
log.push(format!("Ordner erstellt: {}", name));
|
||||
Box::pin(self.sync_upload_new(&[], &path, Some(folder.id), log)).await;
|
||||
}
|
||||
Err(e) => log.push(format!("Ordner-Fehler {}: {}", name, e)),
|
||||
}
|
||||
} else {
|
||||
// Existing folder: recurse into it
|
||||
let sub = server_entries.iter().find(|e| e.name == name);
|
||||
let children = sub.and_then(|e| e.children.as_ref())
|
||||
.map(|c| c.as_slice()).unwrap_or(&[]);
|
||||
let sub_id = sub.map(|e| e.id);
|
||||
Box::pin(self.sync_upload_new(children, &path, sub_id, log)).await;
|
||||
}
|
||||
} else {
|
||||
// New file: upload
|
||||
if !server_names.contains(&name) {
|
||||
match self.api.upload_file(&path, parent_id).await {
|
||||
Ok(_) => log.push(format!("Hochgeladen: {}", name)),
|
||||
Err(e) => log.push(format!("Upload-Fehler {}: {}", name, e)),
|
||||
}
|
||||
} else {
|
||||
// Existing file: check if changed (checksum compare)
|
||||
if let Some(se) = server_entries.iter().find(|e| e.name == name) {
|
||||
if !se.locked.unwrap_or(false) {
|
||||
let local_hash = compute_file_hash(&path);
|
||||
if local_hash != se.checksum.as_deref().unwrap_or("") {
|
||||
match self.api.upload_file(&path, parent_id).await {
|
||||
Ok(_) => log.push(format!("Aktualisiert: {}", name)),
|
||||
Err(e) => log.push(format!("Upload-Fehler {}: {}", name, e)),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.push(format!("Zurueckgehalten (gesperrt): {}", name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Full sync: download all files from server
|
||||
async fn sync_full_download(&self, entries: &[FileEntry], local_dir: &Path,
|
||||
log: &mut Vec<String>) {
|
||||
|
||||
+134
-2
@@ -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
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Local File Browser -->
|
||||
<div v-if="autoSyncActive" class="section" @click="hideContextMenu">
|
||||
<div class="section-header">
|
||||
<h3>Lokale Dateien</h3>
|
||||
<button @click="loadLocalFiles(null)" class="btn-small">↻</button>
|
||||
</div>
|
||||
|
||||
<div v-if="localBreadcrumb.length" class="local-breadcrumb">
|
||||
<span v-for="(b, i) in localBreadcrumb" :key="i">
|
||||
<a @click="loadLocalFiles(b.path)">{{ b.name }}</a>
|
||||
<span v-if="i < localBreadcrumb.length - 1"> / </span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="local-file-list">
|
||||
<div v-for="f in localFiles" :key="f.path"
|
||||
class="local-file-item"
|
||||
@dblclick="f.is_folder ? openLocalFolder(f) : (f.is_cloud ? doOpenCloudFile(f) : null)"
|
||||
@contextmenu="showContextMenu($event, f)">
|
||||
<span class="lf-icon">{{ f.is_folder ? '📁' : (f.is_cloud ? '☁' : '📄') }}</span>
|
||||
<span class="lf-name">{{ f.name }}</span>
|
||||
<span v-if="f.is_cloud" class="lf-badge cloud">Cloud</span>
|
||||
<span v-else-if="f.is_offline" class="lf-badge offline">Offline</span>
|
||||
<span class="lf-size">{{ formatSize(f.cloud_size || f.size) }}</span>
|
||||
</div>
|
||||
<div v-if="!localFiles.length" class="empty">Ordner ist leer</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Context Menu -->
|
||||
<div v-if="contextMenu.show" class="context-menu"
|
||||
:style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }">
|
||||
<div v-if="contextMenu.file?.is_cloud" class="cm-item" @click="doOpenCloudFile(contextMenu.file)">
|
||||
📥 Oeffnen (herunterladen)
|
||||
</div>
|
||||
<div v-if="contextMenu.file?.is_cloud" class="cm-item" @click="doMarkOffline(contextMenu.file)">
|
||||
💾 Offline verfuegbar machen
|
||||
</div>
|
||||
<div v-if="contextMenu.file?.is_offline" class="cm-item" @click="doUnmarkOffline(contextMenu.file)">
|
||||
☁ Nicht mehr offline (Platzhalter)
|
||||
</div>
|
||||
<div class="cm-item" @click="hideContextMenu">Abbrechen</div>
|
||||
</div>
|
||||
|
||||
<!-- File Tree -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
@@ -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}}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user