feat: Lock-Badge + smartes Kontextmenue in lokaler Client-Ansicht
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) <noreply@anthropic.com>
This commit is contained in:
parent
5afb87c9cd
commit
23563622f8
|
|
@ -373,6 +373,15 @@ async fn unlock_file_cmd(state: State<'_, AppState>, file_id: i64) -> Result<Str
|
||||||
Ok("Datei entsperrt".to_string())
|
Ok("Datei entsperrt".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")?;
|
||||||
|
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]
|
#[tauri::command]
|
||||||
async fn get_file_tree(state: State<'_, AppState>) -> Result<serde_json::Value, String> {
|
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 api = state.api.lock().unwrap().clone().ok_or("Nicht eingeloggt")?;
|
||||||
|
|
@ -409,11 +418,33 @@ struct LocalFileEntry {
|
||||||
is_offline: bool, // real file (offline available)
|
is_offline: bool, // real file (offline available)
|
||||||
size: i64,
|
size: i64,
|
||||||
cloud_size: Option<i64>, // original size from .cloud metadata
|
cloud_size: Option<i64>, // original size from .cloud metadata
|
||||||
|
file_id: Option<i64>,
|
||||||
|
locked: bool,
|
||||||
|
locked_by: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_locks(
|
||||||
|
entries: &[sync::api::FileEntry],
|
||||||
|
out: &mut std::collections::HashMap<i64, (bool, Option<String>)>,
|
||||||
|
) {
|
||||||
|
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]
|
#[tauri::command]
|
||||||
fn browse_sync_folder(state: State<'_, AppState>, sub_path: Option<String>) -> Result<Vec<LocalFileEntry>, String> {
|
async fn browse_sync_folder(state: State<'_, AppState>, sub_path: Option<String>) -> Result<Vec<LocalFileEntry>, String> {
|
||||||
let paths = state.sync_paths.lock().unwrap();
|
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() {
|
if paths.is_empty() {
|
||||||
return Err("Keine Sync-Pfade konfiguriert".to_string());
|
return Err("Keine Sync-Pfade konfiguriert".to_string());
|
||||||
}
|
}
|
||||||
|
|
@ -429,6 +460,27 @@ fn browse_sync_folder(state: State<'_, AppState>, sub_path: Option<String>) -> R
|
||||||
return Ok(Vec::new());
|
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<i64, (bool, Option<String>)> = 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 mut entries = Vec::new();
|
||||||
let dir = std::fs::read_dir(&base_dir).map_err(|e| e.to_string())?;
|
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<String>) -> R
|
||||||
let name = entry.file_name().to_string_lossy().to_string();
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
|
|
||||||
// Skip hidden files
|
|
||||||
if name.starts_with('.') || name.starts_with('~') { continue; }
|
if name.starts_with('.') || name.starts_with('~') { continue; }
|
||||||
|
|
||||||
let is_folder = path.is_dir();
|
let is_folder = path.is_dir();
|
||||||
let is_cloud = name.ends_with(".cloud");
|
let is_cloud = name.ends_with(".cloud");
|
||||||
let size = std::fs::metadata(&path).map(|m| m.len() as i64).unwrap_or(0);
|
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 cloud_size = None;
|
||||||
let mut display_name = name.clone();
|
let mut display_name = name.clone();
|
||||||
|
let mut file_id: Option<i64> = None;
|
||||||
|
|
||||||
if is_cloud {
|
if is_cloud {
|
||||||
display_name = name.trim_end_matches(".cloud").to_string();
|
display_name = name.trim_end_matches(".cloud").to_string();
|
||||||
if let Ok(content) = std::fs::read_to_string(&path) {
|
if let Ok(content) = std::fs::read_to_string(&path) {
|
||||||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
|
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||||
cloud_size = json.get("size").and_then(|v| v.as_i64());
|
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;
|
let is_offline = !is_cloud && !is_folder;
|
||||||
|
|
||||||
entries.push(LocalFileEntry {
|
entries.push(LocalFileEntry {
|
||||||
|
|
@ -466,10 +536,12 @@ fn browse_sync_folder(state: State<'_, AppState>, sub_path: Option<String>) -> R
|
||||||
is_offline,
|
is_offline,
|
||||||
size,
|
size,
|
||||||
cloud_size,
|
cloud_size,
|
||||||
|
file_id,
|
||||||
|
locked,
|
||||||
|
locked_by,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort: folders first, then by name
|
|
||||||
entries.sort_by(|a, b| {
|
entries.sort_by(|a, b| {
|
||||||
b.is_folder.cmp(&a.is_folder).then(a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
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_file_tree,
|
||||||
get_status,
|
get_status,
|
||||||
unlock_file_cmd,
|
unlock_file_cmd,
|
||||||
|
lock_file_cmd,
|
||||||
browse_sync_folder,
|
browse_sync_folder,
|
||||||
mark_offline,
|
mark_offline,
|
||||||
unmark_offline,
|
unmark_offline,
|
||||||
|
|
|
||||||
|
|
@ -86,20 +86,34 @@ async function doMarkOffline(file) {
|
||||||
|
|
||||||
async function doUnlockFile(file) {
|
async function doUnlockFile(file) {
|
||||||
hideContextMenu();
|
hideContextMenu();
|
||||||
// Find file ID from server tree
|
const fileId = file.file_id ?? findFileInTree(fileTree.value, file.name)?.id;
|
||||||
const serverFile = findFileInTree(fileTree.value, file.name);
|
if (!fileId) {
|
||||||
if (!serverFile) {
|
|
||||||
syncLog.value = [`[${ts()}] Fehler: Datei nicht auf Server gefunden`, ...syncLog.value];
|
syncLog.value = [`[${ts()}] Fehler: Datei nicht auf Server gefunden`, ...syncLog.value];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
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);
|
syncLog.value = [`[${ts()}] Entsperrt: ${file.name}`, ...syncLog.value].slice(0, 200);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
syncLog.value = [`[${ts()}] Fehler: ${err}`, ...syncLog.value].slice(0, 200);
|
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) {
|
function findFileInTree(entries, name) {
|
||||||
for (const e of entries) {
|
for (const e of entries) {
|
||||||
if (e.name === name) return e;
|
if (e.name === name) return e;
|
||||||
|
|
@ -463,6 +477,7 @@ onUnmounted(() => { unlistenStatus?.(); unlistenLog?.(); unlistenError?.(); unli
|
||||||
<span class="lf-name">{{ f.name }}</span>
|
<span class="lf-name">{{ f.name }}</span>
|
||||||
<span v-if="f.is_cloud" class="lf-badge cloud">Cloud</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 v-else-if="f.is_offline" class="lf-badge offline">Offline</span>
|
||||||
|
<span v-if="f.locked" class="lf-badge locked" :title="'Ausgecheckt von ' + f.locked_by">🔒 {{ f.locked_by }}</span>
|
||||||
<span class="lf-size">{{ formatSize(f.cloud_size || f.size) }}</span>
|
<span class="lf-size">{{ formatSize(f.cloud_size || f.size) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!localFiles.length" class="empty">Ordner ist leer</div>
|
<div v-if="!localFiles.length" class="empty">Ordner ist leer</div>
|
||||||
|
|
@ -481,12 +496,15 @@ onUnmounted(() => { unlistenStatus?.(); unlistenLog?.(); unlistenError?.(); unli
|
||||||
<div v-if="contextMenu.file?.is_offline" class="cm-item" @click="doOpenOfflineFile(contextMenu.file)">
|
<div v-if="contextMenu.file?.is_offline" class="cm-item" @click="doOpenOfflineFile(contextMenu.file)">
|
||||||
📂 Oeffnen (auschecken)
|
📂 Oeffnen (auschecken)
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="contextMenu.file?.is_offline && !contextMenu.file?.locked" class="cm-item" @click="doLockOnly(contextMenu.file)">
|
||||||
|
🔒 Auschecken (sperren)
|
||||||
|
</div>
|
||||||
|
<div v-if="contextMenu.file?.is_offline && contextMenu.file?.locked" class="cm-item" @click="doUnlockFile(contextMenu.file)">
|
||||||
|
🔓 Entsperren (einchecken)
|
||||||
|
</div>
|
||||||
<div v-if="contextMenu.file?.is_offline" class="cm-item" @click="doUnmarkOffline(contextMenu.file)">
|
<div v-if="contextMenu.file?.is_offline" class="cm-item" @click="doUnmarkOffline(contextMenu.file)">
|
||||||
☁ Nicht mehr offline (Platzhalter)
|
☁ Nicht mehr offline (Platzhalter)
|
||||||
</div>
|
</div>
|
||||||
<div v-if="contextMenu.file?.is_offline" class="cm-item" @click="doUnlockFile(contextMenu.file)">
|
|
||||||
🔓 Entsperren (Freigeben fuer andere)
|
|
||||||
</div>
|
|
||||||
<div class="cm-item" @click="hideContextMenu">Abbrechen</div>
|
<div class="cm-item" @click="hideContextMenu">Abbrechen</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -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{font-size:.65rem;padding:.1rem .3rem;border-radius:3px;flex-shrink:0}
|
||||||
.lf-badge.cloud{background:#e3f2fd;color:#1565c0}
|
.lf-badge.cloud{background:#e3f2fd;color:#1565c0}
|
||||||
.lf-badge.offline{background:#e8f5e9;color:#2e7d32}
|
.lf-badge.offline{background:#e8f5e9;color:#2e7d32}
|
||||||
|
.lf-badge.locked{background:#fff3e0;color:#e65100}
|
||||||
.lf-size{font-size:.75rem;color:#999;flex-shrink:0}
|
.lf-size{font-size:.75rem;color:#999;flex-shrink:0}
|
||||||
.checkbox-row{display:flex;align-items:center;gap:.5rem;font-size:.85rem;cursor:pointer}
|
.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}
|
.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}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue