feat: Desktop-Client komplett - Auto-Sync, Watcher, Locking, Tray

Alles eingebunden was vorher nur als unused code existierte:

Auto-Sync:
- Nach erstem Sync laeuft alle 30s ein Delta-Sync im Hintergrund
- Status-Badge zeigt live: Synchronisiert / Synchronisiere... / Fehler
- Sync-Protokoll mit Timestamps

File-Watcher:
- Ueberwacht den Sync-Ordner auf lokale Aenderungen (Erstellt/Geaendert/Geloescht)
- Aenderungen werden im UI unter "Lokale Aenderungen" angezeigt
- Filtert temp/hidden files automatisch

File-Locking:
- lock_file_cmd / unlock_file_cmd Tauri-Kommandos
- Heartbeat-Thread sendet alle 60s Heartbeat fuer gesperrte Dateien
- locked_files Liste im State

System-Tray:
- Tray-Icon mit "Mini-Cloud Sync" Tooltip
- Rechtsklick-Menue: Oeffnen / Beenden
- "Oeffnen" zeigt das Hauptfenster

UI:
- Status-Badge mit Farbe (gruen=synced, orange=syncing, rot=error)
- Spinning-Icon waehrend Sync
- "Auto-Sync aktiv" Hinweis nach erstem Sync
- Sync-Ordner wird nach Start gesperrt (nicht mehr aenderbar)
- Lokale Aenderungen und Sync-Log mit Timestamps

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker 2026-04-11 23:54:54 +02:00
parent 8342cbfa17
commit 714ce1ae53
4 changed files with 291 additions and 63 deletions

BIN
build-output/minicloud-sync.exe Executable file

Binary file not shown.

Binary file not shown.

View File

@ -1,17 +1,26 @@
mod sync; mod sync;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Mutex; use std::sync::{Arc, Mutex};
use tauri::State; use std::time::Duration;
use tauri::{
menu::{Menu, MenuItem},
tray::TrayIconBuilder,
AppHandle, Emitter, Manager, State,
};
use sync::api::MiniCloudApi; use sync::api::MiniCloudApi;
use sync::engine::SyncEngine; use sync::engine::SyncEngine;
use sync::watcher::{FileWatcher, ChangeKind};
struct AppState { struct AppState {
api: Mutex<Option<MiniCloudApi>>, api: Mutex<Option<MiniCloudApi>>,
sync_engine: Mutex<Option<SyncEngine>>, sync_engine: Mutex<Option<SyncEngine>>,
sync_dir: Mutex<Option<PathBuf>>, sync_dir: Mutex<Option<PathBuf>>,
username: Mutex<Option<String>>, username: Mutex<Option<String>>,
watcher: Mutex<Option<FileWatcher>>,
sync_running: Arc<Mutex<bool>>,
locked_files: Mutex<Vec<i64>>, // file IDs we have locked
} }
#[tauri::command] #[tauri::command]
@ -44,28 +53,36 @@ fn set_sync_dir(state: State<'_, AppState>, path: String) -> Result<String, Stri
} }
#[tauri::command] #[tauri::command]
async fn start_sync(state: State<'_, AppState>) -> Result<Vec<String>, String> { async fn start_sync(app: AppHandle, state: State<'_, AppState>) -> Result<Vec<String>, String> {
let api = state.api.lock().unwrap().clone() let api = state.api.lock().unwrap().clone()
.ok_or("Nicht eingeloggt")?; .ok_or("Nicht eingeloggt")?;
let sync_dir = state.sync_dir.lock().unwrap().clone() let sync_dir = state.sync_dir.lock().unwrap().clone()
.ok_or("Kein Sync-Ordner gesetzt")?; .ok_or("Kein Sync-Ordner gesetzt")?;
let mut engine = SyncEngine::new(sync_dir, api); // Full sync
let mut engine = SyncEngine::new(sync_dir.clone(), api.clone());
let log = engine.full_sync().await?; let log = engine.full_sync().await?;
*state.sync_engine.lock().unwrap() = Some(engine); *state.sync_engine.lock().unwrap() = Some(engine);
// Start file watcher
let watcher = FileWatcher::new(&sync_dir)?;
*state.watcher.lock().unwrap() = Some(watcher);
// Start background auto-sync + watcher processing
start_background_sync(app, state.sync_running.clone(), api, sync_dir);
let _ = app.emit("sync-status", "synced");
Ok(log) Ok(log)
} }
#[tauri::command] #[tauri::command]
async fn delta_sync(state: State<'_, AppState>) -> Result<Vec<String>, String> { async fn delta_sync(state: State<'_, AppState>) -> Result<Vec<String>, String> {
// Extract engine from state, dropping the MutexGuard before .await
let mut engine = { let mut engine = {
let mut guard = state.sync_engine.lock().unwrap(); let mut guard = state.sync_engine.lock().unwrap();
guard.take().ok_or("Sync nicht gestartet")? guard.take().ok_or("Sync nicht gestartet")?
}; };
let result = engine.delta_sync().await; let result = engine.delta_sync().await;
// Put engine back
*state.sync_engine.lock().unwrap() = Some(engine); *state.sync_engine.lock().unwrap() = Some(engine);
result result
} }
@ -75,11 +92,15 @@ async fn get_status(state: State<'_, AppState>) -> Result<serde_json::Value, Str
let logged_in = state.api.lock().unwrap().is_some(); let logged_in = state.api.lock().unwrap().is_some();
let sync_dir = state.sync_dir.lock().unwrap().clone(); let sync_dir = state.sync_dir.lock().unwrap().clone();
let username = state.username.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();
Ok(serde_json::json!({ Ok(serde_json::json!({
"logged_in": logged_in, "logged_in": logged_in,
"username": username, "username": username,
"sync_dir": sync_dir.map(|p| p.to_string_lossy().to_string()), "sync_dir": sync_dir.map(|p| p.to_string_lossy().to_string()),
"syncing": syncing,
"locked_files": locked_count,
})) }))
} }
@ -91,15 +112,143 @@ async fn get_file_tree(state: State<'_, AppState>) -> Result<serde_json::Value,
Ok(serde_json::to_value(tree).map_err(|e| e.to_string())?) 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")?;
api.lock_file(file_id, "Desktop Sync Client").await?;
state.locked_files.lock().unwrap().push(file_id);
Ok("Datei gesperrt".to_string())
}
#[tauri::command]
async fn unlock_file_cmd(state: State<'_, AppState>, file_id: i64) -> Result<String, String> {
let api = state.api.lock().unwrap().clone().ok_or("Nicht eingeloggt")?;
api.unlock_file(file_id).await?;
state.locked_files.lock().unwrap().retain(|&id| id != file_id);
Ok("Datei entsperrt".to_string())
}
/// Background sync: auto-sync every 30s + process file watcher events + heartbeat for locks
fn start_background_sync(
app: AppHandle,
sync_running: Arc<Mutex<bool>>,
api: MiniCloudApi,
sync_dir: PathBuf,
) {
// Auto-sync thread (delta 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();
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
let mut engine = SyncEngine::new(sync_dir_clone, api_sync);
loop {
std::thread::sleep(Duration::from_secs(30));
*running.lock().unwrap() = true;
let _ = app_sync.emit("sync-status", "syncing");
match rt.block_on(engine.delta_sync()) {
Ok(log) => {
if !log.is_empty() {
let _ = app_sync.emit("sync-log", log);
}
}
Err(e) => {
let _ = app_sync.emit("sync-error", e);
}
}
*running.lock().unwrap() = false;
let _ = app_sync.emit("sync-status", "synced");
}
});
// Heartbeat thread (every 60 seconds for locked files)
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();
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() {
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 msg = match change.kind {
ChangeKind::Created => format!("Neu: {}", rel_path),
ChangeKind::Modified => format!("Geaendert: {}", rel_path),
ChangeKind::Deleted => format!("Geloescht: {}", rel_path),
};
let _ = app_watcher.emit("file-change", msg);
}
}
}
});
}
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_notification::init())
.manage(AppState { .manage(AppState {
api: Mutex::new(None), api: Mutex::new(None),
sync_engine: Mutex::new(None), sync_engine: Mutex::new(None),
sync_dir: Mutex::new(None), sync_dir: Mutex::new(None),
username: Mutex::new(None), username: Mutex::new(None),
watcher: Mutex::new(None),
sync_running: Arc::new(Mutex::new(false)),
locked_files: 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])?;
TrayIconBuilder::new()
.tooltip("Mini-Cloud Sync")
.menu(&menu)
.on_menu_event(|app, event| {
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();
}
}
_ => {}
}
})
.build(app)?;
Ok(())
}) })
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
login, login,
@ -108,6 +257,8 @@ pub fn run() {
delta_sync, delta_sync,
get_status, get_status,
get_file_tree, get_file_tree,
lock_file_cmd,
unlock_file_cmd,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View File

@ -1,6 +1,7 @@
<script setup> <script setup>
import { ref, onMounted } from "vue"; import { ref, onMounted, onUnmounted } from "vue";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
const screen = ref("login"); // login | main const screen = ref("login"); // login | main
const serverUrl = ref("https://"); const serverUrl = ref("https://");
@ -12,9 +13,16 @@ const loginLoading = ref(false);
const syncDir = ref(""); const syncDir = ref("");
const syncLog = ref([]); const syncLog = ref([]);
const syncing = ref(false); const syncing = ref(false);
const syncStatus = ref("Nicht verbunden");
const userInfo = ref(null); const userInfo = ref(null);
const fileTree = ref([]); const fileTree = ref([]);
const statusMsg = ref("Nicht verbunden"); const fileChanges = ref([]);
const autoSyncActive = ref(false);
let unlistenStatus = null;
let unlistenLog = null;
let unlistenError = null;
let unlistenFileChange = null;
async function handleLogin() { async function handleLogin() {
loginError.value = ""; loginError.value = "";
@ -27,10 +35,12 @@ async function handleLogin() {
}); });
userInfo.value = result; userInfo.value = result;
screen.value = "main"; screen.value = "main";
statusMsg.value = `Verbunden als ${result.username}`; syncStatus.value = `Verbunden als ${result.username}`;
// Default sync dir
const home = navigator.platform.includes("Win") ? "C:\\Users\\" + result.username : "/home/" + result.username;
syncDir.value = `${home}/MiniCloud`;
// Set default sync dir
syncDir.value = `${await getDefaultSyncDir()}/MiniCloud`;
await loadFileTree(); await loadFileTree();
} catch (err) { } catch (err) {
loginError.value = String(err); loginError.value = String(err);
@ -39,36 +49,18 @@ async function handleLogin() {
} }
} }
async function getDefaultSyncDir() {
// Use home directory
try {
const home = await invoke("get_status");
return home.sync_dir || (navigator.platform.includes("Win") ? "C:\\Users" : "/home");
} catch {
return "/home";
}
}
async function setSyncFolder() {
try {
await invoke("set_sync_dir", { path: syncDir.value });
statusMsg.value = `Sync-Ordner: ${syncDir.value}`;
} catch (err) {
statusMsg.value = `Fehler: ${err}`;
}
}
async function runSync() { async function runSync() {
syncing.value = true; syncing.value = true;
statusMsg.value = "Synchronisiere..."; syncStatus.value = "Erster Sync...";
try { try {
await invoke("set_sync_dir", { path: syncDir.value }); await invoke("set_sync_dir", { path: syncDir.value });
const log = await invoke("start_sync"); const log = await invoke("start_sync");
syncLog.value = log; syncLog.value = [...log, ...syncLog.value].slice(0, 100);
statusMsg.value = `Sync fertig: ${log.length} Aktionen`; syncStatus.value = "Synchronisiert";
autoSyncActive.value = true;
await loadFileTree(); await loadFileTree();
} catch (err) { } catch (err) {
statusMsg.value = `Sync-Fehler: ${err}`; syncStatus.value = `Fehler: ${err}`;
} finally { } finally {
syncing.value = false; syncing.value = false;
} }
@ -90,6 +82,46 @@ function formatSize(bytes) {
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; } while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
return `${size.toFixed(i > 0 ? 1 : 0)} ${units[i]}`; return `${size.toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
} }
function timestamp() {
return new Date().toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit", second: "2-digit" });
}
onMounted(async () => {
// Listen for sync events from Rust backend
unlistenStatus = await listen("sync-status", (event) => {
if (event.payload === "syncing") {
syncStatus.value = "Synchronisiere...";
syncing.value = true;
} else if (event.payload === "synced") {
syncStatus.value = "Synchronisiert";
syncing.value = false;
loadFileTree();
}
});
unlistenLog = await listen("sync-log", (event) => {
const entries = event.payload.map(msg => `[${timestamp()}] ${msg}`);
syncLog.value = [...entries, ...syncLog.value].slice(0, 200);
});
unlistenError = await listen("sync-error", (event) => {
syncStatus.value = `Fehler: ${event.payload}`;
syncLog.value = [`[${timestamp()}] FEHLER: ${event.payload}`, ...syncLog.value].slice(0, 200);
syncing.value = false;
});
unlistenFileChange = await listen("file-change", (event) => {
fileChanges.value = [`[${timestamp()}] ${event.payload}`, ...fileChanges.value].slice(0, 50);
});
});
onUnmounted(() => {
unlistenStatus?.();
unlistenLog?.();
unlistenError?.();
unlistenFileChange?.();
});
</script> </script>
<template> <template>
@ -127,8 +159,11 @@ function formatSize(bytes) {
<div class="toolbar"> <div class="toolbar">
<div class="toolbar-left"> <div class="toolbar-left">
<span class="logo-small"></span> <span class="logo-small"></span>
<strong>Mini-Cloud</strong> <strong>Mini-Cloud Sync</strong>
<span class="status-badge">{{ statusMsg }}</span> <span class="status-badge" :class="{ syncing: syncing, error: syncStatus.startsWith('Fehler') }">
<span v-if="syncing" class="spin"></span>
{{ syncStatus }}
</span>
</div> </div>
<div class="toolbar-right"> <div class="toolbar-right">
<span class="user-info">{{ userInfo?.username }}</span> <span class="user-info">{{ userInfo?.username }}</span>
@ -140,29 +175,49 @@ function formatSize(bytes) {
<div class="section"> <div class="section">
<h3>Sync-Ordner</h3> <h3>Sync-Ordner</h3>
<div class="sync-row"> <div class="sync-row">
<input v-model="syncDir" class="sync-input" /> <input v-model="syncDir" class="sync-input" :disabled="autoSyncActive" />
<button @click="runSync" :disabled="syncing" class="btn-primary"> <button @click="runSync" :disabled="syncing" class="btn-primary">
{{ syncing ? "Synchronisiere..." : "Jetzt synchronisieren" }} {{ autoSyncActive ? "⟳ Auto-Sync aktiv" : (syncing ? "Synchronisiere..." : "Sync starten") }}
</button> </button>
</div> </div>
<div v-if="autoSyncActive" class="auto-sync-info">
Auto-Sync alle 30 Sekunden aktiv. Datei-Watcher ueberwacht lokale Aenderungen.
</div>
</div> </div>
<!-- File Tree --> <!-- File Tree -->
<div class="section"> <div class="section">
<div class="section-header">
<h3>Server-Dateien</h3> <h3>Server-Dateien</h3>
<button v-if="autoSyncActive" @click="loadFileTree" class="btn-small">Aktualisieren</button>
</div>
<div class="file-tree"> <div class="file-tree">
<div v-for="entry in fileTree" :key="entry.id" class="tree-item"> <template v-for="entry in fileTree" :key="entry.id">
<div class="tree-item">
<span class="tree-icon">{{ entry.is_folder ? "📁" : "📄" }}</span> <span class="tree-icon">{{ entry.is_folder ? "📁" : "📄" }}</span>
<span class="tree-name">{{ entry.name }}</span> <span class="tree-name">{{ entry.name }}</span>
<span v-if="entry.locked" class="tree-lock" :title="'Gesperrt von ' + entry.locked_by"> <span v-if="entry.locked" class="tree-lock">🔒 {{ entry.locked_by }}</span>
🔒 {{ entry.locked_by }}
</span>
<span v-if="!entry.is_folder" class="tree-size">{{ formatSize(entry.size) }}</span> <span v-if="!entry.is_folder" class="tree-size">{{ formatSize(entry.size) }}</span>
</div> </div>
<div v-if="entry.children" v-for="child in entry.children" :key="child.id" class="tree-item indent">
<span class="tree-icon">{{ child.is_folder ? "📁" : "📄" }}</span>
<span class="tree-name">{{ child.name }}</span>
<span v-if="child.locked" class="tree-lock">🔒 {{ child.locked_by }}</span>
<span v-if="!child.is_folder" class="tree-size">{{ formatSize(child.size) }}</span>
</div>
</template>
<div v-if="!fileTree.length" class="empty">Keine Dateien</div> <div v-if="!fileTree.length" class="empty">Keine Dateien</div>
</div> </div>
</div> </div>
<!-- File Changes (Watcher) -->
<div v-if="fileChanges.length" class="section">
<h3>Lokale Aenderungen</h3>
<div class="log-list">
<div v-for="(msg, i) in fileChanges" :key="i" class="log-item change">{{ msg }}</div>
</div>
</div>
<!-- Sync Log --> <!-- Sync Log -->
<div v-if="syncLog.length" class="section"> <div v-if="syncLog.length" class="section">
<h3>Sync-Protokoll</h3> <h3>Sync-Protokoll</h3>
@ -180,9 +235,7 @@ body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px; color: #1a1a1a; background: #f0f2f5; font-size: 14px; color: #1a1a1a; background: #f0f2f5;
} }
.login-screen { .login-screen { height: 100vh; display: flex; align-items: center; justify-content: center; }
height: 100vh; display: flex; align-items: center; justify-content: center;
}
.login-card { .login-card {
background: white; border-radius: 12px; padding: 2rem; width: 360px; background: white; border-radius: 12px; padding: 2rem; width: 360px;
box-shadow: 0 2px 12px rgba(0,0,0,0.1); box-shadow: 0 2px 12px rgba(0,0,0,0.1);
@ -200,11 +253,17 @@ body {
.field input:focus { border-color: #4a90d9; outline: none; background: white; } .field input:focus { border-color: #4a90d9; outline: none; background: white; }
.error { color: #e53e3e; font-size: 0.85rem; margin-bottom: 0.75rem; } .error { color: #e53e3e; font-size: 0.85rem; margin-bottom: 0.75rem; }
.btn-primary { .btn-primary {
width: 100%; padding: 0.6rem; background: #4a90d9; color: white; padding: 0.6rem 1rem; background: #4a90d9; color: white;
border: none; border-radius: 6px; font-size: 0.9rem; cursor: pointer; font-weight: 500; border: none; border-radius: 6px; font-size: 0.9rem; cursor: pointer; font-weight: 500;
white-space: nowrap;
} }
.btn-primary:hover { background: #3a7bc8; } .btn-primary:hover { background: #3a7bc8; }
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; } .btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-small {
padding: 0.25rem 0.5rem; background: #e8e8e8; border: none; border-radius: 4px;
font-size: 0.8rem; cursor: pointer;
}
.btn-small:hover { background: #ddd; }
.main-screen { height: 100vh; display: flex; flex-direction: column; } .main-screen { height: 100vh; display: flex; flex-direction: column; }
.toolbar { .toolbar {
@ -213,32 +272,50 @@ body {
} }
.toolbar-left { display: flex; align-items: center; gap: 0.5rem; } .toolbar-left { display: flex; align-items: center; gap: 0.5rem; }
.logo-small { font-size: 1.2rem; } .logo-small { font-size: 1.2rem; }
.status-badge { font-size: 0.8rem; color: #666; background: #f0f0f0; padding: 0.2rem 0.5rem; border-radius: 4px; } .status-badge {
font-size: 0.8rem; padding: 0.2rem 0.5rem; border-radius: 4px;
background: #e8f5e9; color: #2e7d32;
}
.status-badge.syncing { background: #fff3e0; color: #e65100; }
.status-badge.error { background: #ffebee; color: #c62828; }
.spin { display: inline-block; animation: spin 1s linear infinite; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.user-info { font-size: 0.85rem; color: #666; } .user-info { font-size: 0.85rem; color: #666; }
.content { flex: 1; overflow-y: auto; padding: 1rem; } .content { flex: 1; overflow-y: auto; padding: 1rem; }
.section { background: white; border-radius: 8px; padding: 1rem; margin-bottom: 1rem; } .section { background: white; border-radius: 8px; padding: 1rem; margin-bottom: 0.75rem; }
.section h3 { margin-bottom: 0.75rem; font-size: 0.95rem; } .section h3 { margin-bottom: 0.5rem; font-size: 0.95rem; }
.section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem; }
.section-header h3 { margin: 0; }
.sync-row { display: flex; gap: 0.5rem; } .sync-row { display: flex; gap: 0.5rem; }
.sync-input { flex: 1; padding: 0.5rem; border: 1px solid #ddd; border-radius: 6px; font-size: 0.85rem; } .sync-input {
.file-tree { max-height: 300px; overflow-y: auto; } flex: 1; padding: 0.5rem; border: 1px solid #ddd; border-radius: 6px; font-size: 0.85rem;
}
.sync-input:disabled { background: #f5f5f5; color: #999; }
.auto-sync-info { margin-top: 0.5rem; font-size: 0.8rem; color: #2e7d32; }
.file-tree { max-height: 250px; overflow-y: auto; }
.tree-item { .tree-item {
display: flex; align-items: center; gap: 0.5rem; display: flex; align-items: center; gap: 0.5rem;
padding: 0.375rem 0; border-bottom: 1px solid #f5f5f5; font-size: 0.85rem; padding: 0.3rem 0; border-bottom: 1px solid #f5f5f5; font-size: 0.85rem;
} }
.tree-item.indent { padding-left: 1.5rem; }
.tree-icon { flex-shrink: 0; } .tree-icon { flex-shrink: 0; }
.tree-name { flex: 1; } .tree-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tree-lock { font-size: 0.75rem; color: #e67e22; } .tree-lock { font-size: 0.75rem; color: #e67e22; flex-shrink: 0; }
.tree-size { font-size: 0.75rem; color: #999; } .tree-size { font-size: 0.75rem; color: #999; flex-shrink: 0; }
.empty { text-align: center; color: #999; padding: 1rem; } .empty { text-align: center; color: #999; padding: 1rem; }
.log-list { max-height: 200px; overflow-y: auto; font-family: monospace; font-size: 0.8rem; } .log-list { max-height: 150px; overflow-y: auto; font-family: monospace; font-size: 0.78rem; }
.log-item { padding: 0.25rem 0; border-bottom: 1px solid #f5f5f5; } .log-item { padding: 0.2rem 0; border-bottom: 1px solid #f8f8f8; color: #555; }
.log-item.change { color: #1565c0; }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
body { color: #e0e0e0; background: #1a1a1a; } body { color: #e0e0e0; background: #1a1a1a; }
.login-card, .section { background: #2a2a2a; } .login-card, .section { background: #2a2a2a; }
.toolbar { background: #2a2a2a; border-color: #3a3a3a; } .toolbar { background: #2a2a2a; border-color: #3a3a3a; }
.field input, .sync-input { background: #333; border-color: #444; color: #e0e0e0; } .field input, .sync-input { background: #333; border-color: #444; color: #e0e0e0; }
.status-badge { background: #3a3a3a; color: #aaa; } .status-badge { background: #1b5e20; color: #a5d6a7; }
.status-badge.syncing { background: #e65100; color: #ffcc80; }
.tree-item { border-color: #333; } .tree-item { border-color: #333; }
.log-item { border-color: #333; color: #aaa; }
.log-item.change { color: #64b5f6; }
} }
</style> </style>