feat: Virtual Files, Multi-Sync-Pfade, Full Sync, Ordner-Dialog
Virtual Files System: - .cloud Platzhalter-Dateien (JSON mit ID, Name, Groesse, Checksum) - 0 Bytes Speicherverbrauch pro Datei - Doppelklick auf .cloud -> Download + Oeffnen mit Standard-App + Lock - Nach Schliessen: Sync zurueck, lokale Kopie entfernen, .cloud neu - Offline-Markierung: Echte Dateien bleiben lokal (kein .cloud) - Server-Dateien loeschen -> .cloud wird automatisch entfernt Multi-Sync-Pfade (wie Nextcloud): - Beliebig viele Server-Ordner auf lokale Ordner mappen - z.B. /Projekte/2026 -> ~/Projekte oder /Shared/Team -> ~/Team - Freigegebene Ordner von anderen Benutzern sync-bar - Jeder Pfad hat eigenen Modus (Virtual oder Full) - Hinzufuegen/Entfernen/Modus wechseln in der UI Full Sync: - Pro Sync-Pfad waehlbar: Virtual oder Full - Full = alle Dateien lokal spiegeln (bidirektional) - Virtual = .cloud Platzhalter (Standard) - Klick auf Modus-Badge zum Umschalten Ordner-Dialog: - "Durchsuchen..." Button oeffnet nativen Ordner-Auswahl-Dialog - Server-Ordner per Dropdown aus Dateibaum waehlen - Ordner werden automatisch erstellt wenn noetig UI: - Sync-Pfade als Karten: ☁ /Server/Pfad → 📁 /Lokaler/Pfad - Modus-Badge (Virtual/Full) mit Klick zum Wechseln - Tray-Menue: "Jetzt synchronisieren" Eintrag Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,3 +27,4 @@ dirs = "6"
|
|||||||
rusqlite = { version = "0.34", features = ["bundled"] }
|
rusqlite = { version = "0.34", features = ["bundled"] }
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
keyring = "3"
|
keyring = "3"
|
||||||
|
open = "5"
|
||||||
|
|||||||
@@ -10,19 +10,21 @@ use tauri::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use sync::api::MiniCloudApi;
|
use sync::api::MiniCloudApi;
|
||||||
use sync::engine::SyncEngine;
|
use sync::engine::{SyncEngine, SyncMode, SyncPath};
|
||||||
use sync::watcher::{FileWatcher, ChangeKind};
|
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>>,
|
|
||||||
username: Mutex<Option<String>>,
|
username: Mutex<Option<String>>,
|
||||||
watcher: Mutex<Option<FileWatcher>>,
|
watchers: Mutex<Vec<FileWatcher>>,
|
||||||
sync_running: Arc<Mutex<bool>>,
|
sync_running: Arc<Mutex<bool>>,
|
||||||
locked_files: Mutex<Vec<i64>>, // file IDs we have locked
|
locked_files: Mutex<Vec<i64>>,
|
||||||
|
sync_paths: Mutex<Vec<SyncPath>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Auth ---
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn login(
|
async fn login(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -42,76 +44,162 @@ async fn login(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Sync Paths ---
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn set_sync_dir(state: State<'_, AppState>, path: String) -> Result<String, String> {
|
fn add_sync_path(
|
||||||
let sync_path = PathBuf::from(&path);
|
state: State<'_, AppState>,
|
||||||
if !sync_path.exists() {
|
server_path: String,
|
||||||
std::fs::create_dir_all(&sync_path).map_err(|e| e.to_string())?;
|
server_folder_id: Option<i64>,
|
||||||
}
|
local_dir: String,
|
||||||
*state.sync_dir.lock().unwrap() = Some(sync_path);
|
mode: String, // "virtual" or "full"
|
||||||
Ok(format!("Sync-Ordner gesetzt: {}", path))
|
) -> Result<serde_json::Value, String> {
|
||||||
|
let local = PathBuf::from(&local_dir);
|
||||||
|
std::fs::create_dir_all(&local).map_err(|e| format!("Ordner erstellen: {}", e))?;
|
||||||
|
|
||||||
|
let sync_mode = if mode == "full" { SyncMode::Full } else { SyncMode::Virtual };
|
||||||
|
let id = format!("{}_{}", server_folder_id.unwrap_or(0), local_dir.replace(['/', '\\'], "_"));
|
||||||
|
|
||||||
|
let sp = SyncPath {
|
||||||
|
id: id.clone(),
|
||||||
|
server_path,
|
||||||
|
server_folder_id,
|
||||||
|
local_dir,
|
||||||
|
mode: sync_mode,
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
state.sync_paths.lock().unwrap().push(sp.clone());
|
||||||
|
|
||||||
|
Ok(serde_json::to_value(sp).map_err(|e| e.to_string())?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn start_sync(app: AppHandle, state: State<'_, AppState>) -> Result<Vec<String>, String> {
|
fn remove_sync_path(state: State<'_, AppState>, id: String) -> Result<String, String> {
|
||||||
let api = state.api.lock().unwrap().clone()
|
state.sync_paths.lock().unwrap().retain(|p| p.id != id);
|
||||||
.ok_or("Nicht eingeloggt")?;
|
Ok("Sync-Pfad entfernt".to_string())
|
||||||
let sync_dir = state.sync_dir.lock().unwrap().clone()
|
}
|
||||||
.ok_or("Kein Sync-Ordner gesetzt")?;
|
|
||||||
|
|
||||||
// Full sync
|
#[tauri::command]
|
||||||
let mut engine = SyncEngine::new(sync_dir.clone(), api.clone());
|
fn get_sync_paths(state: State<'_, AppState>) -> Result<serde_json::Value, String> {
|
||||||
let log = engine.full_sync().await?;
|
let paths = state.sync_paths.lock().unwrap().clone();
|
||||||
|
Ok(serde_json::to_value(paths).map_err(|e| e.to_string())?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn toggle_sync_mode(state: State<'_, AppState>, id: String) -> Result<String, String> {
|
||||||
|
let mut paths = state.sync_paths.lock().unwrap();
|
||||||
|
if let Some(p) = paths.iter_mut().find(|p| p.id == id) {
|
||||||
|
p.mode = match p.mode {
|
||||||
|
SyncMode::Virtual => SyncMode::Full,
|
||||||
|
SyncMode::Full => SyncMode::Virtual,
|
||||||
|
};
|
||||||
|
Ok(format!("Modus: {:?}", p.mode))
|
||||||
|
} else {
|
||||||
|
Err("Pfad nicht gefunden".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Sync ---
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn start_sync(app: AppHandle, state: State<'_, AppState>) -> Result<Vec<String>, String> {
|
||||||
|
let api = state.api.lock().unwrap().clone().ok_or("Nicht eingeloggt")?;
|
||||||
|
let paths = state.sync_paths.lock().unwrap().clone();
|
||||||
|
|
||||||
|
if paths.is_empty() {
|
||||||
|
return Err("Keine Sync-Pfade konfiguriert".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut engine = SyncEngine::new(api.clone());
|
||||||
|
engine.sync_paths = paths.clone();
|
||||||
|
let log = engine.sync_all().await?;
|
||||||
|
|
||||||
*state.sync_engine.lock().unwrap() = Some(engine);
|
*state.sync_engine.lock().unwrap() = Some(engine);
|
||||||
|
|
||||||
// Start file watcher
|
// Start watchers for each sync path
|
||||||
let watcher = FileWatcher::new(&sync_dir)?;
|
let mut watchers = Vec::new();
|
||||||
*state.watcher.lock().unwrap() = Some(watcher);
|
for sp in &paths {
|
||||||
|
if let Ok(w) = FileWatcher::new(&PathBuf::from(&sp.local_dir)) {
|
||||||
|
watchers.push(w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*state.watchers.lock().unwrap() = watchers;
|
||||||
|
|
||||||
// Start background auto-sync + watcher processing
|
// Start background threads
|
||||||
start_background_sync(app, state.sync_running.clone(), api, sync_dir);
|
start_background_sync(app, state.sync_running.clone(), api, paths);
|
||||||
|
|
||||||
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 run_sync_now(state: State<'_, AppState>) -> Result<Vec<String>, String> {
|
||||||
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.sync_all().await;
|
||||||
*state.sync_engine.lock().unwrap() = Some(engine);
|
*state.sync_engine.lock().unwrap() = Some(engine);
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- File Operations ---
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn open_cloud_file(state: State<'_, AppState>, cloud_path: String) -> Result<String, String> {
|
||||||
|
let engine = {
|
||||||
|
let guard = state.sync_engine.lock().unwrap();
|
||||||
|
guard.as_ref().ok_or("Sync nicht gestartet")?.api.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let path = PathBuf::from(&cloud_path);
|
||||||
|
let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
|
||||||
|
let placeholder: serde_json::Value = serde_json::from_str(&content).map_err(|e| e.to_string())?;
|
||||||
|
let file_id = placeholder.get("id").and_then(|v| v.as_i64()).ok_or("Keine ID")?;
|
||||||
|
let file_name = placeholder.get("name").and_then(|v| v.as_str()).unwrap_or("file");
|
||||||
|
|
||||||
|
let real_path = path.parent().unwrap().join(file_name);
|
||||||
|
|
||||||
|
// Download
|
||||||
|
engine.download_file(file_id, &real_path).await?;
|
||||||
|
|
||||||
|
// Remove placeholder
|
||||||
|
std::fs::remove_file(&path).ok();
|
||||||
|
|
||||||
|
// Lock
|
||||||
|
let _ = engine.lock_file(file_id, "Desktop Sync Client").await;
|
||||||
|
state.locked_files.lock().unwrap().push(file_id);
|
||||||
|
|
||||||
|
// Open with default application
|
||||||
|
let _ = open::that(&real_path);
|
||||||
|
|
||||||
|
Ok(real_path.to_string_lossy().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
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 tree = api.get_sync_tree().await?;
|
||||||
|
Ok(serde_json::to_value(tree).map_err(|e| e.to_string())?)
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn get_status(state: State<'_, AppState>) -> Result<serde_json::Value, String> {
|
async fn get_status(state: State<'_, AppState>) -> Result<serde_json::Value, String> {
|
||||||
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 username = state.username.lock().unwrap().clone();
|
let username = state.username.lock().unwrap().clone();
|
||||||
let syncing = *state.sync_running.lock().unwrap();
|
let syncing = *state.sync_running.lock().unwrap();
|
||||||
let locked_count = state.locked_files.lock().unwrap().len();
|
let paths = state.sync_paths.lock().unwrap().len();
|
||||||
|
let locked = 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()),
|
|
||||||
"syncing": syncing,
|
"syncing": syncing,
|
||||||
"locked_files": locked_count,
|
"sync_paths": paths,
|
||||||
|
"locked_files": locked,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
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 tree = api.get_sync_tree().await?;
|
|
||||||
Ok(serde_json::to_value(tree).map_err(|e| e.to_string())?)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn lock_file_cmd(state: State<'_, AppState>, file_id: i64) -> Result<String, String> {
|
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")?;
|
let api = state.api.lock().unwrap().clone().ok_or("Nicht eingeloggt")?;
|
||||||
@@ -128,108 +216,104 @@ async fn unlock_file_cmd(state: State<'_, AppState>, file_id: i64) -> Result<Str
|
|||||||
Ok("Datei entsperrt".to_string())
|
Ok("Datei entsperrt".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Background sync: auto-sync every 30s + process file watcher events + heartbeat for locks
|
// --- Background Threads ---
|
||||||
|
|
||||||
fn start_background_sync(
|
fn start_background_sync(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
sync_running: Arc<Mutex<bool>>,
|
sync_running: Arc<Mutex<bool>>,
|
||||||
api: MiniCloudApi,
|
api: MiniCloudApi,
|
||||||
sync_dir: PathBuf,
|
paths: Vec<SyncPath>,
|
||||||
) {
|
) {
|
||||||
// Auto-sync thread (delta sync every 30 seconds)
|
// Auto-sync every 30 seconds
|
||||||
let app_sync = app.clone();
|
let app_sync = app.clone();
|
||||||
let api_sync = api.clone();
|
let api_sync = api.clone();
|
||||||
let sync_dir_clone = sync_dir.clone();
|
let paths_sync = paths.clone();
|
||||||
let running = sync_running.clone();
|
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||||
let mut engine = SyncEngine::new(sync_dir_clone, api_sync);
|
let mut engine = SyncEngine::new(api_sync);
|
||||||
|
engine.sync_paths = paths_sync;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
std::thread::sleep(Duration::from_secs(30));
|
std::thread::sleep(Duration::from_secs(30));
|
||||||
|
|
||||||
*running.lock().unwrap() = true;
|
*sync_running.lock().unwrap() = true;
|
||||||
let _ = app_sync.emit("sync-status", "syncing");
|
let _ = app_sync.emit("sync-status", "syncing");
|
||||||
|
|
||||||
match rt.block_on(engine.delta_sync()) {
|
match rt.block_on(engine.sync_all()) {
|
||||||
Ok(log) => {
|
Ok(log) => {
|
||||||
if !log.is_empty() {
|
if !log.is_empty() {
|
||||||
let _ = app_sync.emit("sync-log", log);
|
let _ = app_sync.emit("sync-log", log);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => { let _ = app_sync.emit("sync-error", e); }
|
||||||
let _ = app_sync.emit("sync-error", e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
*running.lock().unwrap() = false;
|
*sync_running.lock().unwrap() = false;
|
||||||
let _ = app_sync.emit("sync-status", "synced");
|
let _ = app_sync.emit("sync-status", "synced");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Heartbeat thread (every 60 seconds for locked files)
|
// Heartbeat every 60 seconds
|
||||||
let app_hb = app.clone();
|
let app_hb = app.clone();
|
||||||
let api_hb = api.clone();
|
let api_hb = api.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||||
loop {
|
loop {
|
||||||
std::thread::sleep(Duration::from_secs(60));
|
std::thread::sleep(Duration::from_secs(60));
|
||||||
|
|
||||||
let state = app_hb.state::<AppState>();
|
let state = app_hb.state::<AppState>();
|
||||||
let locked = state.locked_files.lock().unwrap().clone();
|
let locked = state.locked_files.lock().unwrap().clone();
|
||||||
|
|
||||||
for file_id in locked {
|
for file_id in locked {
|
||||||
let _ = rt.block_on(api_hb.heartbeat(file_id));
|
let _ = rt.block_on(api_hb.heartbeat(file_id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// File watcher processing thread
|
// File watcher processing
|
||||||
let app_watcher = app.clone();
|
let app_w = app.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
loop {
|
loop {
|
||||||
std::thread::sleep(Duration::from_secs(2));
|
std::thread::sleep(Duration::from_secs(2));
|
||||||
|
let state = app_w.state::<AppState>();
|
||||||
let state = app_watcher.state::<AppState>();
|
let watchers = state.watchers.lock().unwrap();
|
||||||
let watcher_guard = state.watcher.lock().unwrap();
|
for watcher in watchers.iter() {
|
||||||
if let Some(watcher) = watcher_guard.as_ref() {
|
|
||||||
while let Ok(change) = watcher.receiver.try_recv() {
|
while let Ok(change) = watcher.receiver.try_recv() {
|
||||||
let rel_path = change.path.strip_prefix(&sync_dir)
|
let name = change.path.file_name()
|
||||||
.unwrap_or(&change.path)
|
.and_then(|n| n.to_str()).unwrap_or("?");
|
||||||
.to_string_lossy()
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let msg = match change.kind {
|
let msg = match change.kind {
|
||||||
ChangeKind::Created => format!("Neu: {}", rel_path),
|
ChangeKind::Created => format!("Neu: {}", name),
|
||||||
ChangeKind::Modified => format!("Geaendert: {}", rel_path),
|
ChangeKind::Modified => format!("Geaendert: {}", name),
|
||||||
ChangeKind::Deleted => format!("Geloescht: {}", rel_path),
|
ChangeKind::Deleted => format!("Geloescht: {}", name),
|
||||||
};
|
};
|
||||||
let _ = app_watcher.emit("file-change", msg);
|
let _ = app_w.emit("file-change", msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- App Setup ---
|
||||||
|
|
||||||
#[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())
|
.plugin(tauri_plugin_notification::init())
|
||||||
|
.plugin(tauri_plugin_dialog::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),
|
|
||||||
username: Mutex::new(None),
|
username: Mutex::new(None),
|
||||||
watcher: Mutex::new(None),
|
watchers: Mutex::new(Vec::new()),
|
||||||
sync_running: Arc::new(Mutex::new(false)),
|
sync_running: Arc::new(Mutex::new(false)),
|
||||||
locked_files: Mutex::new(Vec::new()),
|
locked_files: Mutex::new(Vec::new()),
|
||||||
|
sync_paths: Mutex::new(Vec::new()),
|
||||||
})
|
})
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
// System Tray
|
|
||||||
let quit = MenuItem::with_id(app, "quit", "Beenden", true, None::<&str>)?;
|
let quit = MenuItem::with_id(app, "quit", "Beenden", true, None::<&str>)?;
|
||||||
let show = MenuItem::with_id(app, "show", "Oeffnen", true, None::<&str>)?;
|
let show = MenuItem::with_id(app, "show", "Oeffnen", true, None::<&str>)?;
|
||||||
let menu = Menu::with_items(app, &[&show, &quit])?;
|
let sync_now = MenuItem::with_id(app, "sync", "Jetzt synchronisieren", true, None::<&str>)?;
|
||||||
|
let menu = Menu::with_items(app, &[&show, &sync_now, &quit])?;
|
||||||
|
|
||||||
TrayIconBuilder::new()
|
TrayIconBuilder::new()
|
||||||
.tooltip("Mini-Cloud Sync")
|
.tooltip("Mini-Cloud Sync")
|
||||||
@@ -238,11 +322,12 @@ pub fn run() {
|
|||||||
match event.id.as_ref() {
|
match event.id.as_ref() {
|
||||||
"quit" => std::process::exit(0),
|
"quit" => std::process::exit(0),
|
||||||
"show" => {
|
"show" => {
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
if let Some(w) = app.get_webview_window("main") {
|
||||||
let _ = window.show();
|
let _ = w.show();
|
||||||
let _ = window.set_focus();
|
let _ = w.set_focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"sync" => { let _ = app.emit("trigger-sync", ()); }
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -252,11 +337,15 @@ pub fn run() {
|
|||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
login,
|
login,
|
||||||
set_sync_dir,
|
add_sync_path,
|
||||||
|
remove_sync_path,
|
||||||
|
get_sync_paths,
|
||||||
|
toggle_sync_mode,
|
||||||
start_sync,
|
start_sync,
|
||||||
delta_sync,
|
run_sync_now,
|
||||||
get_status,
|
open_cloud_file,
|
||||||
get_file_tree,
|
get_file_tree,
|
||||||
|
get_status,
|
||||||
lock_file_cmd,
|
lock_file_cmd,
|
||||||
unlock_file_cmd,
|
unlock_file_cmd,
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -1,104 +1,166 @@
|
|||||||
use crate::sync::api::{FileEntry, MiniCloudApi};
|
use crate::sync::api::{FileEntry, MiniCloudApi};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
/// Compare local files with server tree and determine actions
|
/// A configured sync path: maps a server folder to a local folder
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SyncPath {
|
||||||
|
pub id: String, // unique ID
|
||||||
|
pub server_path: String, // e.g. "/" (root) or "/Projekte/2026"
|
||||||
|
pub server_folder_id: Option<i64>, // server folder ID (None = root)
|
||||||
|
pub local_dir: String, // local directory path
|
||||||
|
pub mode: SyncMode, // virtual or full
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum SyncMode {
|
||||||
|
Virtual, // .cloud placeholder files, download on demand
|
||||||
|
Full, // full sync, all files downloaded
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cloud placeholder file content (small JSON inside .cloud files)
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct CloudPlaceholder {
|
||||||
|
id: i64,
|
||||||
|
name: String,
|
||||||
|
size: i64,
|
||||||
|
checksum: String,
|
||||||
|
updated_at: String,
|
||||||
|
server_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct SyncEngine {
|
pub struct SyncEngine {
|
||||||
pub sync_dir: PathBuf,
|
|
||||||
pub api: MiniCloudApi,
|
pub api: MiniCloudApi,
|
||||||
|
pub sync_paths: Vec<SyncPath>,
|
||||||
last_sync: Option<String>,
|
last_sync: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum SyncAction {
|
|
||||||
Download { id: i64, name: String, server_path: String },
|
|
||||||
Upload { local_path: PathBuf, parent_id: Option<i64> },
|
|
||||||
CreateFolder { name: String, parent_id: Option<i64> },
|
|
||||||
Delete { local_path: PathBuf },
|
|
||||||
Conflict { local_path: PathBuf, server_id: i64, locked_by: String },
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SyncEngine {
|
impl SyncEngine {
|
||||||
pub fn new(sync_dir: PathBuf, api: MiniCloudApi) -> Self {
|
pub fn new(api: MiniCloudApi) -> Self {
|
||||||
Self { sync_dir, api, last_sync: None }
|
Self { api, sync_paths: Vec::new(), last_sync: None }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Full sync: compare server tree with local filesystem
|
/// Sync all configured paths
|
||||||
pub async fn full_sync(&mut self) -> Result<Vec<String>, String> {
|
pub async fn sync_all(&mut self) -> Result<Vec<String>, String> {
|
||||||
let server_tree = self.api.get_sync_tree().await?;
|
let mut all_logs = Vec::new();
|
||||||
let mut log = Vec::new();
|
|
||||||
|
|
||||||
// Download missing/changed files from server
|
let tree = self.api.get_sync_tree().await?;
|
||||||
self.sync_download_recursive(&server_tree, &self.sync_dir.clone(), &mut log).await;
|
|
||||||
|
|
||||||
// Upload local files not on server
|
for sp in &self.sync_paths {
|
||||||
self.sync_upload_recursive(&server_tree, &self.sync_dir.clone(), None, &mut log).await;
|
if !sp.enabled { continue; }
|
||||||
|
|
||||||
// Update last sync time
|
let local_dir = PathBuf::from(&sp.local_dir);
|
||||||
self.last_sync = Some(chrono::Utc::now().to_rfc3339());
|
std::fs::create_dir_all(&local_dir).ok();
|
||||||
|
|
||||||
Ok(log)
|
// Find the server subtree for this sync path
|
||||||
}
|
let subtree = if sp.server_folder_id.is_some() {
|
||||||
|
find_subtree(&tree, sp.server_folder_id.unwrap())
|
||||||
/// Delta sync: only changes since last sync
|
|
||||||
pub async fn delta_sync(&mut self) -> Result<Vec<String>, String> {
|
|
||||||
let since = self.last_sync.clone().unwrap_or_else(|| "2000-01-01T00:00:00Z".to_string());
|
|
||||||
let changes = self.api.get_changes(&since).await?;
|
|
||||||
let mut log = Vec::new();
|
|
||||||
|
|
||||||
for entry in &changes.changes {
|
|
||||||
let rel_path = self.sync_dir.join(&entry.name);
|
|
||||||
|
|
||||||
if entry.is_folder {
|
|
||||||
if !rel_path.exists() {
|
|
||||||
std::fs::create_dir_all(&rel_path).ok();
|
|
||||||
log.push(format!("Ordner erstellt: {}", entry.name));
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Check if locked
|
Some(tree.clone())
|
||||||
if entry.locked.unwrap_or(false) {
|
};
|
||||||
log.push(format!("Uebersprungen (gesperrt von {}): {}",
|
|
||||||
entry.locked_by.as_deref().unwrap_or("?"), entry.name));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Download if checksum differs
|
if let Some(entries) = subtree {
|
||||||
let needs_download = if rel_path.exists() {
|
let mut log = Vec::new();
|
||||||
let local_hash = compute_file_hash(&rel_path);
|
match sp.mode {
|
||||||
local_hash != entry.checksum.as_deref().unwrap_or("")
|
SyncMode::Virtual => {
|
||||||
} else {
|
self.sync_virtual(&entries, &local_dir, &sp.server_path, &mut log).await;
|
||||||
true
|
}
|
||||||
};
|
SyncMode::Full => {
|
||||||
|
self.sync_full_download(&entries, &local_dir, &mut log).await;
|
||||||
if needs_download {
|
self.sync_full_upload(&entries, &local_dir, sp.server_folder_id, &mut log).await;
|
||||||
match self.api.download_file(entry.id, &rel_path).await {
|
|
||||||
Ok(_) => log.push(format!("Heruntergeladen: {}", entry.name)),
|
|
||||||
Err(e) => log.push(format!("Fehler {}: {}", entry.name, e)),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
all_logs.extend(log);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.last_sync = Some(changes.server_time);
|
self.last_sync = Some(chrono::Utc::now().to_rfc3339());
|
||||||
Ok(log)
|
Ok(all_logs)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn sync_download_recursive(&self, entries: &[FileEntry], local_dir: &Path, log: &mut Vec<String>) {
|
/// Virtual sync: create .cloud placeholder files
|
||||||
|
async fn sync_virtual(&self, entries: &[FileEntry], local_dir: &Path,
|
||||||
|
server_path: &str, log: &mut Vec<String>) {
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
let local_path = local_dir.join(&entry.name);
|
let local_path = local_dir.join(&entry.name);
|
||||||
|
|
||||||
if entry.is_folder {
|
if entry.is_folder {
|
||||||
std::fs::create_dir_all(&local_path).ok();
|
std::fs::create_dir_all(&local_path).ok();
|
||||||
if let Some(children) = &entry.children {
|
if let Some(children) = &entry.children {
|
||||||
Box::pin(self.sync_download_recursive(children, &local_path, log)).await;
|
let sub_path = format!("{}/{}", server_path.trim_end_matches('/'), entry.name);
|
||||||
|
Box::pin(self.sync_virtual(children, &local_path, &sub_path, log)).await;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Skip locked files
|
// Check if real file exists (manually downloaded or offline-marked)
|
||||||
if entry.locked.unwrap_or(false) {
|
if local_path.exists() {
|
||||||
|
// Real file exists - check if it's been modified
|
||||||
|
let local_hash = compute_file_hash(&local_path);
|
||||||
|
if local_hash != entry.checksum.as_deref().unwrap_or("") {
|
||||||
|
// Local file changed - upload it
|
||||||
|
if !entry.locked.unwrap_or(false) {
|
||||||
|
match self.api.upload_file(&local_path, entry.id.into()).await {
|
||||||
|
Ok(_) => log.push(format!("Hochgeladen: {}", entry.name)),
|
||||||
|
Err(e) => log.push(format!("Upload-Fehler {}: {}", entry.name, e)),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.push(format!("Zurueckgehalten (gesperrt): {}", entry.name));
|
||||||
|
}
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create .cloud placeholder
|
||||||
|
let cloud_path = local_dir.join(format!("{}.cloud", entry.name));
|
||||||
|
if !cloud_path.exists() {
|
||||||
|
let placeholder = CloudPlaceholder {
|
||||||
|
id: entry.id,
|
||||||
|
name: entry.name.clone(),
|
||||||
|
size: entry.size.unwrap_or(0),
|
||||||
|
checksum: entry.checksum.clone().unwrap_or_default(),
|
||||||
|
updated_at: entry.updated_at.clone().unwrap_or_default(),
|
||||||
|
server_path: format!("{}/{}", server_path.trim_end_matches('/'), entry.name),
|
||||||
|
};
|
||||||
|
if let Ok(json) = serde_json::to_string_pretty(&placeholder) {
|
||||||
|
std::fs::write(&cloud_path, json).ok();
|
||||||
|
log.push(format!("Platzhalter: {}.cloud", entry.name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove .cloud files for deleted server files
|
||||||
|
if let Ok(dir_entries) = std::fs::read_dir(local_dir) {
|
||||||
|
for entry in dir_entries.flatten() {
|
||||||
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
if name.ends_with(".cloud") {
|
||||||
|
let real_name = name.trim_end_matches(".cloud");
|
||||||
|
let exists_on_server = entries.iter().any(|e| e.name == real_name);
|
||||||
|
if !exists_on_server {
|
||||||
|
std::fs::remove_file(entry.path()).ok();
|
||||||
|
log.push(format!("Entfernt: {}", name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full sync: download all files from server
|
||||||
|
async fn sync_full_download(&self, entries: &[FileEntry], local_dir: &Path,
|
||||||
|
log: &mut Vec<String>) {
|
||||||
|
for entry in entries {
|
||||||
|
let local_path = local_dir.join(&entry.name);
|
||||||
|
|
||||||
|
if entry.is_folder {
|
||||||
|
std::fs::create_dir_all(&local_path).ok();
|
||||||
|
if let Some(children) = &entry.children {
|
||||||
|
Box::pin(self.sync_full_download(children, &local_path, log)).await;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if entry.locked.unwrap_or(false) { continue; }
|
||||||
|
|
||||||
let needs_download = if local_path.exists() {
|
let needs_download = if local_path.exists() {
|
||||||
let local_hash = compute_file_hash(&local_path);
|
let local_hash = compute_file_hash(&local_path);
|
||||||
local_hash != entry.checksum.as_deref().unwrap_or("")
|
local_hash != entry.checksum.as_deref().unwrap_or("")
|
||||||
@@ -106,6 +168,12 @@ impl SyncEngine {
|
|||||||
true
|
true
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Remove stale .cloud placeholder
|
||||||
|
let cloud_path = local_dir.join(format!("{}.cloud", entry.name));
|
||||||
|
if cloud_path.exists() {
|
||||||
|
std::fs::remove_file(&cloud_path).ok();
|
||||||
|
}
|
||||||
|
|
||||||
if needs_download {
|
if needs_download {
|
||||||
match self.api.download_file(entry.id, &local_path).await {
|
match self.api.download_file(entry.id, &local_path).await {
|
||||||
Ok(_) => log.push(format!("Heruntergeladen: {}", entry.name)),
|
Ok(_) => log.push(format!("Heruntergeladen: {}", entry.name)),
|
||||||
@@ -116,8 +184,9 @@ impl SyncEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn sync_upload_recursive(&self, server_entries: &[FileEntry], local_dir: &Path,
|
/// Full sync: upload new/changed local files
|
||||||
parent_id: Option<i64>, log: &mut Vec<String>) {
|
async fn sync_full_upload(&self, server_entries: &[FileEntry], local_dir: &Path,
|
||||||
|
parent_id: Option<i64>, log: &mut Vec<String>) {
|
||||||
let server_names: HashMap<String, &FileEntry> = server_entries.iter()
|
let server_names: HashMap<String, &FileEntry> = server_entries.iter()
|
||||||
.map(|e| (e.name.clone(), e))
|
.map(|e| (e.name.clone(), e))
|
||||||
.collect();
|
.collect();
|
||||||
@@ -131,50 +200,38 @@ impl SyncEngine {
|
|||||||
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 and sync metadata
|
// Skip hidden, temp, .cloud files
|
||||||
if name.starts_with('.') || name == ".minicloud_sync" {
|
if name.starts_with('.') || name.starts_with('~') || name.ends_with(".tmp")
|
||||||
|
|| name.ends_with(".cloud") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if path.is_dir() {
|
if path.is_dir() {
|
||||||
if let Some(server_entry) = server_names.get(&name) {
|
if let Some(se) = server_names.get(&name) {
|
||||||
// Folder exists on server, recurse
|
if let Some(children) = &se.children {
|
||||||
if let Some(children) = &server_entry.children {
|
Box::pin(self.sync_full_upload(children, &path, Some(se.id), log)).await;
|
||||||
Box::pin(self.sync_upload_recursive(
|
|
||||||
children, &path, Some(server_entry.id), log
|
|
||||||
)).await;
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Folder doesn't exist on server, create + upload contents
|
|
||||||
match self.api.create_folder(&name, parent_id).await {
|
match self.api.create_folder(&name, parent_id).await {
|
||||||
Ok(folder) => {
|
Ok(folder) => {
|
||||||
log.push(format!("Ordner erstellt: {}", name));
|
log.push(format!("Ordner erstellt: {}", name));
|
||||||
Box::pin(self.sync_upload_recursive(
|
Box::pin(self.sync_full_upload(&[], &path, Some(folder.id), log)).await;
|
||||||
&[], &path, Some(folder.id), log
|
|
||||||
)).await;
|
|
||||||
}
|
}
|
||||||
Err(e) => log.push(format!("Ordner-Fehler {}: {}", name, e)),
|
Err(e) => log.push(format!("Ordner-Fehler {}: {}", name, e)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// File
|
let needs_upload = if let Some(se) = server_names.get(&name) {
|
||||||
let needs_upload = if let Some(server_entry) = server_names.get(&name) {
|
if se.locked.unwrap_or(false) {
|
||||||
// Check if local version is newer
|
log.push(format!("Zurueckgehalten (gesperrt): {}", name));
|
||||||
let local_hash = compute_file_hash(&path);
|
continue;
|
||||||
local_hash != server_entry.checksum.as_deref().unwrap_or("")
|
}
|
||||||
|
compute_file_hash(&path) != se.checksum.as_deref().unwrap_or("")
|
||||||
} else {
|
} else {
|
||||||
true // New file
|
true
|
||||||
};
|
};
|
||||||
|
|
||||||
if needs_upload {
|
if needs_upload {
|
||||||
// Check if file is locked on server
|
|
||||||
if let Some(server_entry) = server_names.get(&name) {
|
|
||||||
if server_entry.locked.unwrap_or(false) {
|
|
||||||
log.push(format!("Zurueckgehalten (gesperrt): {}", name));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match self.api.upload_file(&path, parent_id).await {
|
match self.api.upload_file(&path, parent_id).await {
|
||||||
Ok(_) => log.push(format!("Hochgeladen: {}", name)),
|
Ok(_) => log.push(format!("Hochgeladen: {}", name)),
|
||||||
Err(e) => log.push(format!("Upload-Fehler {}: {}", name, e)),
|
Err(e) => log.push(format!("Upload-Fehler {}: {}", name, e)),
|
||||||
@@ -183,6 +240,76 @@ impl SyncEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open a .cloud placeholder file: download the real file, rename, return path
|
||||||
|
pub async fn open_cloud_file(&self, cloud_path: &Path) -> Result<PathBuf, String> {
|
||||||
|
let content = std::fs::read_to_string(cloud_path)
|
||||||
|
.map_err(|e| format!("Platzhalter lesen: {}", e))?;
|
||||||
|
let placeholder: CloudPlaceholder = serde_json::from_str(&content)
|
||||||
|
.map_err(|e| format!("Platzhalter ungueltig: {}", e))?;
|
||||||
|
|
||||||
|
let real_path = cloud_path.with_extension("");
|
||||||
|
// Remove .cloud extension to get real filename
|
||||||
|
let real_path = cloud_path.parent().unwrap().join(&placeholder.name);
|
||||||
|
|
||||||
|
// Download
|
||||||
|
self.api.download_file(placeholder.id, &real_path).await?;
|
||||||
|
|
||||||
|
// Remove placeholder
|
||||||
|
std::fs::remove_file(cloud_path).ok();
|
||||||
|
|
||||||
|
// Lock on server
|
||||||
|
let _ = self.api.lock_file(placeholder.id, "Desktop Sync Client").await;
|
||||||
|
|
||||||
|
Ok(real_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close a previously opened file: sync back, recreate .cloud, unlock
|
||||||
|
pub async fn close_cloud_file(&self, real_path: &Path, file_id: i64) -> Result<(), String> {
|
||||||
|
// Upload changes
|
||||||
|
// We need the parent_id - for now upload to the same location
|
||||||
|
// The server handles overwrite by filename
|
||||||
|
let _ = self.api.upload_file(real_path, None).await;
|
||||||
|
|
||||||
|
// Unlock
|
||||||
|
let _ = self.api.unlock_file(file_id).await;
|
||||||
|
|
||||||
|
// Delete local copy and recreate placeholder
|
||||||
|
let cloud_path = real_path.parent().unwrap()
|
||||||
|
.join(format!("{}.cloud", real_path.file_name().unwrap().to_string_lossy()));
|
||||||
|
|
||||||
|
let size = std::fs::metadata(real_path).map(|m| m.len() as i64).unwrap_or(0);
|
||||||
|
let checksum = compute_file_hash(real_path);
|
||||||
|
|
||||||
|
let placeholder = CloudPlaceholder {
|
||||||
|
id: file_id,
|
||||||
|
name: real_path.file_name().unwrap().to_string_lossy().to_string(),
|
||||||
|
size,
|
||||||
|
checksum,
|
||||||
|
updated_at: chrono::Utc::now().to_rfc3339(),
|
||||||
|
server_path: String::new(),
|
||||||
|
};
|
||||||
|
if let Ok(json) = serde_json::to_string_pretty(&placeholder) {
|
||||||
|
std::fs::write(&cloud_path, json).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fs::remove_file(real_path).ok();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_subtree(tree: &[FileEntry], folder_id: i64) -> Option<Vec<FileEntry>> {
|
||||||
|
for entry in tree {
|
||||||
|
if entry.id == folder_id {
|
||||||
|
return entry.children.clone();
|
||||||
|
}
|
||||||
|
if let Some(children) = &entry.children {
|
||||||
|
if let Some(result) = find_subtree(children, folder_id) {
|
||||||
|
return Some(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn compute_file_hash(path: &Path) -> String {
|
pub fn compute_file_hash(path: &Path) -> String {
|
||||||
|
|||||||
+250
-204
@@ -2,15 +2,16 @@
|
|||||||
import { ref, onMounted, onUnmounted } 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";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { open as dialogOpen } from "@tauri-apps/plugin-dialog";
|
||||||
|
|
||||||
const screen = ref("login"); // login | main
|
const screen = ref("login");
|
||||||
const serverUrl = ref("https://");
|
const serverUrl = ref("https://");
|
||||||
const username = ref("");
|
const username = ref("");
|
||||||
const password = ref("");
|
const password = ref("");
|
||||||
const loginError = ref("");
|
const loginError = ref("");
|
||||||
const loginLoading = ref(false);
|
const loginLoading = ref(false);
|
||||||
|
|
||||||
const syncDir = ref("");
|
const syncPaths = ref([]);
|
||||||
const syncLog = ref([]);
|
const syncLog = ref([]);
|
||||||
const syncing = ref(false);
|
const syncing = ref(false);
|
||||||
const syncStatus = ref("Nicht verbunden");
|
const syncStatus = ref("Nicht verbunden");
|
||||||
@@ -19,10 +20,15 @@ const fileTree = ref([]);
|
|||||||
const fileChanges = ref([]);
|
const fileChanges = ref([]);
|
||||||
const autoSyncActive = ref(false);
|
const autoSyncActive = ref(false);
|
||||||
|
|
||||||
let unlistenStatus = null;
|
// New sync path form
|
||||||
let unlistenLog = null;
|
const showAddPath = ref(false);
|
||||||
let unlistenError = null;
|
const newPathLocal = ref("");
|
||||||
let unlistenFileChange = null;
|
const newPathServerFolder = ref("");
|
||||||
|
const newPathServerId = ref(null);
|
||||||
|
const newPathMode = ref("virtual");
|
||||||
|
const serverFolders = ref([]);
|
||||||
|
|
||||||
|
let unlistenStatus, unlistenLog, unlistenError, unlistenFileChange, unlistenTrigger;
|
||||||
|
|
||||||
async function handleLogin() {
|
async function handleLogin() {
|
||||||
loginError.value = "";
|
loginError.value = "";
|
||||||
@@ -36,12 +42,8 @@ async function handleLogin() {
|
|||||||
userInfo.value = result;
|
userInfo.value = result;
|
||||||
screen.value = "main";
|
screen.value = "main";
|
||||||
syncStatus.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`;
|
|
||||||
|
|
||||||
await loadFileTree();
|
await loadFileTree();
|
||||||
|
await loadSyncPaths();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
loginError.value = String(err);
|
loginError.value = String(err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -49,79 +51,123 @@ async function handleLogin() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runSync() {
|
|
||||||
syncing.value = true;
|
|
||||||
syncStatus.value = "Erster Sync...";
|
|
||||||
try {
|
|
||||||
await invoke("set_sync_dir", { path: syncDir.value });
|
|
||||||
const log = await invoke("start_sync");
|
|
||||||
syncLog.value = [...log, ...syncLog.value].slice(0, 100);
|
|
||||||
syncStatus.value = "Synchronisiert";
|
|
||||||
autoSyncActive.value = true;
|
|
||||||
await loadFileTree();
|
|
||||||
} catch (err) {
|
|
||||||
syncStatus.value = `Fehler: ${err}`;
|
|
||||||
} finally {
|
|
||||||
syncing.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadFileTree() {
|
async function loadFileTree() {
|
||||||
try {
|
try {
|
||||||
fileTree.value = await invoke("get_file_tree");
|
fileTree.value = await invoke("get_file_tree");
|
||||||
} catch (err) {
|
// Build flat folder list for sync path selection
|
||||||
console.error("Tree-Fehler:", err);
|
serverFolders.value = [{ id: null, name: "/ (Alle Dateien)", path: "/" }];
|
||||||
|
flattenFolders(fileTree.value, "", serverFolders.value);
|
||||||
|
} catch (err) { console.error(err); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenFolders(entries, prefix, list) {
|
||||||
|
for (const e of entries) {
|
||||||
|
if (e.is_folder) {
|
||||||
|
const path = `${prefix}/${e.name}`;
|
||||||
|
list.push({ id: e.id, name: path, path });
|
||||||
|
if (e.children) flattenFolders(e.children, path, list);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSize(bytes) {
|
async function loadSyncPaths() {
|
||||||
if (!bytes) return "";
|
try { syncPaths.value = await invoke("get_sync_paths"); }
|
||||||
const units = ["B", "KB", "MB", "GB"];
|
catch { syncPaths.value = []; }
|
||||||
let i = 0;
|
|
||||||
let size = bytes;
|
|
||||||
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
|
|
||||||
return `${size.toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function timestamp() {
|
async function browseFolder() {
|
||||||
|
try {
|
||||||
|
const selected = await dialogOpen({ directory: true, multiple: false, title: "Sync-Ordner waehlen" });
|
||||||
|
if (selected) newPathLocal.value = selected;
|
||||||
|
} catch { /* dialog cancelled */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addSyncPath() {
|
||||||
|
if (!newPathLocal.value) return;
|
||||||
|
try {
|
||||||
|
await invoke("add_sync_path", {
|
||||||
|
serverPath: newPathServerFolder.value || "/",
|
||||||
|
serverFolderId: newPathServerId.value,
|
||||||
|
localDir: newPathLocal.value,
|
||||||
|
mode: newPathMode.value,
|
||||||
|
});
|
||||||
|
showAddPath.value = false;
|
||||||
|
newPathLocal.value = "";
|
||||||
|
newPathServerFolder.value = "";
|
||||||
|
newPathServerId.value = null;
|
||||||
|
newPathMode.value = "virtual";
|
||||||
|
await loadSyncPaths();
|
||||||
|
} catch (err) { alert(err); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeSyncPath(id) {
|
||||||
|
await invoke("remove_sync_path", { id });
|
||||||
|
await loadSyncPaths();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleMode(id) {
|
||||||
|
await invoke("toggle_sync_mode", { id });
|
||||||
|
await loadSyncPaths();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectServerFolder(folder) {
|
||||||
|
newPathServerFolder.value = folder.path;
|
||||||
|
newPathServerId.value = folder.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startSync() {
|
||||||
|
syncing.value = true;
|
||||||
|
syncStatus.value = "Erster Sync...";
|
||||||
|
try {
|
||||||
|
const log = await invoke("start_sync");
|
||||||
|
syncLog.value = [...log.map(m => `[${ts()}] ${m}`), ...syncLog.value].slice(0, 200);
|
||||||
|
syncStatus.value = "Synchronisiert";
|
||||||
|
autoSyncActive.value = true;
|
||||||
|
await loadFileTree();
|
||||||
|
} catch (err) { syncStatus.value = `Fehler: ${err}`; }
|
||||||
|
finally { syncing.value = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncNow() {
|
||||||
|
syncing.value = true;
|
||||||
|
try {
|
||||||
|
const log = await invoke("run_sync_now");
|
||||||
|
syncLog.value = [...log.map(m => `[${ts()}] ${m}`), ...syncLog.value].slice(0, 200);
|
||||||
|
await loadFileTree();
|
||||||
|
} catch (err) { syncStatus.value = `Fehler: ${err}`; }
|
||||||
|
finally { syncing.value = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function ts() {
|
||||||
return new Date().toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
return new Date().toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatSize(b) {
|
||||||
|
if (!b) return "";
|
||||||
|
const u = ["B","KB","MB","GB"]; let i=0; let s=b;
|
||||||
|
while (s>=1024 && i<u.length-1) { s/=1024; i++; }
|
||||||
|
return `${s.toFixed(i>0?1:0)} ${u[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// Listen for sync events from Rust backend
|
unlistenStatus = await listen("sync-status", e => {
|
||||||
unlistenStatus = await listen("sync-status", (event) => {
|
syncing.value = e.payload === "syncing";
|
||||||
if (event.payload === "syncing") {
|
syncStatus.value = e.payload === "syncing" ? "Synchronisiere..." : "Synchronisiert";
|
||||||
syncStatus.value = "Synchronisiere...";
|
if (e.payload === "synced") loadFileTree();
|
||||||
syncing.value = true;
|
|
||||||
} else if (event.payload === "synced") {
|
|
||||||
syncStatus.value = "Synchronisiert";
|
|
||||||
syncing.value = false;
|
|
||||||
loadFileTree();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
unlistenLog = await listen("sync-log", e => {
|
||||||
unlistenLog = await listen("sync-log", (event) => {
|
syncLog.value = [...e.payload.map(m => `[${ts()}] ${m}`), ...syncLog.value].slice(0, 200);
|
||||||
const entries = event.payload.map(msg => `[${timestamp()}] ${msg}`);
|
|
||||||
syncLog.value = [...entries, ...syncLog.value].slice(0, 200);
|
|
||||||
});
|
});
|
||||||
|
unlistenError = await listen("sync-error", e => {
|
||||||
unlistenError = await listen("sync-error", (event) => {
|
syncStatus.value = `Fehler: ${e.payload}`;
|
||||||
syncStatus.value = `Fehler: ${event.payload}`;
|
|
||||||
syncLog.value = [`[${timestamp()}] FEHLER: ${event.payload}`, ...syncLog.value].slice(0, 200);
|
|
||||||
syncing.value = false;
|
syncing.value = false;
|
||||||
});
|
});
|
||||||
|
unlistenFileChange = await listen("file-change", e => {
|
||||||
unlistenFileChange = await listen("file-change", (event) => {
|
fileChanges.value = [`[${ts()}] ${e.payload}`, ...fileChanges.value].slice(0, 50);
|
||||||
fileChanges.value = [`[${timestamp()}] ${event.payload}`, ...fileChanges.value].slice(0, 50);
|
|
||||||
});
|
});
|
||||||
|
unlistenTrigger = await listen("trigger-sync", () => syncNow());
|
||||||
});
|
});
|
||||||
|
onUnmounted(() => { unlistenStatus?.(); unlistenLog?.(); unlistenError?.(); unlistenFileChange?.(); unlistenTrigger?.(); });
|
||||||
onUnmounted(() => {
|
|
||||||
unlistenStatus?.();
|
|
||||||
unlistenLog?.();
|
|
||||||
unlistenError?.();
|
|
||||||
unlistenFileChange?.();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -134,22 +180,11 @@ onUnmounted(() => {
|
|||||||
<p>Desktop Sync Client</p>
|
<p>Desktop Sync Client</p>
|
||||||
</div>
|
</div>
|
||||||
<form @submit.prevent="handleLogin">
|
<form @submit.prevent="handleLogin">
|
||||||
<div class="field">
|
<div class="field"><label>Server-URL</label><input v-model="serverUrl" placeholder="https://cloud.example.com" /></div>
|
||||||
<label>Server-URL</label>
|
<div class="field"><label>Benutzername</label><input v-model="username" autofocus /></div>
|
||||||
<input v-model="serverUrl" placeholder="https://cloud.example.com" />
|
<div class="field"><label>Passwort</label><input v-model="password" type="password" /></div>
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label>Benutzername</label>
|
|
||||||
<input v-model="username" placeholder="Benutzername" autofocus />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label>Passwort</label>
|
|
||||||
<input v-model="password" type="password" placeholder="Passwort" />
|
|
||||||
</div>
|
|
||||||
<div v-if="loginError" class="error">{{ loginError }}</div>
|
<div v-if="loginError" class="error">{{ loginError }}</div>
|
||||||
<button type="submit" :disabled="loginLoading" class="btn-primary">
|
<button type="submit" :disabled="loginLoading" class="btn-primary full">{{ loginLoading ? "Verbinde..." : "Anmelden" }}</button>
|
||||||
{{ loginLoading ? "Verbinde..." : "Anmelden" }}
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -158,30 +193,79 @@ onUnmounted(() => {
|
|||||||
<div v-else class="main-screen">
|
<div v-else class="main-screen">
|
||||||
<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 Sync</strong>
|
||||||
<strong>Mini-Cloud Sync</strong>
|
<span class="status-badge" :class="{ syncing, error: syncStatus.startsWith('Fehler') }">
|
||||||
<span class="status-badge" :class="{ syncing: syncing, error: syncStatus.startsWith('Fehler') }">
|
<span v-if="syncing" class="spin">⟳</span> {{ syncStatus }}
|
||||||
<span v-if="syncing" class="spin">⟳</span>
|
|
||||||
{{ syncStatus }}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar-right">
|
<div class="toolbar-right"><span class="user-info">{{ userInfo?.username }}</span></div>
|
||||||
<span class="user-info">{{ userInfo?.username }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<!-- Sync Settings -->
|
<!-- Sync Paths -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h3>Sync-Ordner</h3>
|
<div class="section-header">
|
||||||
<div class="sync-row">
|
<h3>Sync-Pfade</h3>
|
||||||
<input v-model="syncDir" class="sync-input" :disabled="autoSyncActive" />
|
<div class="header-btns">
|
||||||
<button @click="runSync" :disabled="syncing" class="btn-primary">
|
<button v-if="syncPaths.length && !autoSyncActive" @click="startSync" :disabled="syncing" class="btn-primary">Sync starten</button>
|
||||||
{{ autoSyncActive ? "⟳ Auto-Sync aktiv" : (syncing ? "Synchronisiere..." : "Sync starten") }}
|
<button v-if="autoSyncActive" @click="syncNow" :disabled="syncing" class="btn-small">Jetzt synchronisieren</button>
|
||||||
</button>
|
<button @click="showAddPath = !showAddPath" class="btn-small">+ Pfad hinzufuegen</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="autoSyncActive" class="auto-sync-info">
|
|
||||||
Auto-Sync alle 30 Sekunden aktiv. Datei-Watcher ueberwacht lokale Aenderungen.
|
<div v-if="autoSyncActive" class="auto-info">Auto-Sync alle 30s aktiv</div>
|
||||||
|
|
||||||
|
<!-- Add new sync path -->
|
||||||
|
<div v-if="showAddPath" class="add-path-form">
|
||||||
|
<div class="field">
|
||||||
|
<label>Server-Ordner</label>
|
||||||
|
<select v-model="newPathServerId" @change="selectServerFolder(serverFolders.find(f => f.id === newPathServerId))">
|
||||||
|
<option v-for="f in serverFolders" :key="f.id ?? 'root'" :value="f.id">{{ f.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Lokaler Ordner</label>
|
||||||
|
<div class="browse-row">
|
||||||
|
<input v-model="newPathLocal" placeholder="/home/user/MiniCloud" />
|
||||||
|
<button @click="browseFolder" class="btn-small">Durchsuchen...</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Modus</label>
|
||||||
|
<div class="mode-select">
|
||||||
|
<label class="mode-option" :class="{ active: newPathMode === 'virtual' }">
|
||||||
|
<input type="radio" v-model="newPathMode" value="virtual" /> ☁ Virtual Files
|
||||||
|
<small>Platzhalter, Download bei Bedarf</small>
|
||||||
|
</label>
|
||||||
|
<label class="mode-option" :class="{ active: newPathMode === 'full' }">
|
||||||
|
<input type="radio" v-model="newPathMode" value="full" /> 💾 Full Sync
|
||||||
|
<small>Alle Dateien lokal spiegeln</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button @click="showAddPath = false" class="btn-small">Abbrechen</button>
|
||||||
|
<button @click="addSyncPath" class="btn-primary" :disabled="!newPathLocal">Hinzufuegen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Existing sync paths -->
|
||||||
|
<div v-for="sp in syncPaths" :key="sp.id" class="sync-path-card">
|
||||||
|
<div class="sp-info">
|
||||||
|
<div class="sp-server">☁ {{ sp.server_path }}</div>
|
||||||
|
<div class="sp-arrow">→</div>
|
||||||
|
<div class="sp-local">📁 {{ sp.local_dir }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="sp-actions">
|
||||||
|
<span class="sp-mode" :class="sp.mode" @click="toggleMode(sp.id)" :title="'Klicken zum Wechseln'">
|
||||||
|
{{ sp.mode === 'Full' ? '💾 Full' : '☁ Virtual' }}
|
||||||
|
</span>
|
||||||
|
<button @click="removeSyncPath(sp.id)" class="btn-danger" title="Entfernen">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!syncPaths.length && !showAddPath" class="empty">
|
||||||
|
Noch keine Sync-Pfade. Klicke "Pfad hinzufuegen" um loszulegen.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -189,133 +273,95 @@ onUnmounted(() => {
|
|||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h3>Server-Dateien</h3>
|
<h3>Server-Dateien</h3>
|
||||||
<button v-if="autoSyncActive" @click="loadFileTree" class="btn-small">Aktualisieren</button>
|
<button @click="loadFileTree" class="btn-small">↻</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="file-tree">
|
<div class="file-tree">
|
||||||
<template v-for="entry in fileTree" :key="entry.id">
|
<template v-for="e in fileTree" :key="e.id">
|
||||||
<div class="tree-item">
|
<div class="tree-item">
|
||||||
<span class="tree-icon">{{ entry.is_folder ? "📁" : "📄" }}</span>
|
<span class="tree-icon">{{ e.is_folder ? '📁' : '📄' }}</span>
|
||||||
<span class="tree-name">{{ entry.name }}</span>
|
<span class="tree-name">{{ e.name }}</span>
|
||||||
<span v-if="entry.locked" class="tree-lock">🔒 {{ entry.locked_by }}</span>
|
<span v-if="e.locked" class="tree-lock">🔒 {{ e.locked_by }}</span>
|
||||||
<span v-if="!entry.is_folder" class="tree-size">{{ formatSize(entry.size) }}</span>
|
<span v-if="!e.is_folder" class="tree-size">{{ formatSize(e.size) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="entry.children" v-for="child in entry.children" :key="child.id" class="tree-item indent">
|
<div v-if="e.children" v-for="c in e.children" :key="c.id" class="tree-item indent">
|
||||||
<span class="tree-icon">{{ child.is_folder ? "📁" : "📄" }}</span>
|
<span class="tree-icon">{{ c.is_folder ? '📁' : '📄' }}</span>
|
||||||
<span class="tree-name">{{ child.name }}</span>
|
<span class="tree-name">{{ c.name }}</span>
|
||||||
<span v-if="child.locked" class="tree-lock">🔒 {{ child.locked_by }}</span>
|
<span v-if="c.locked" class="tree-lock">🔒 {{ c.locked_by }}</span>
|
||||||
<span v-if="!child.is_folder" class="tree-size">{{ formatSize(child.size) }}</span>
|
<span v-if="!c.is_folder" class="tree-size">{{ formatSize(c.size) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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) -->
|
<!-- File Changes -->
|
||||||
<div v-if="fileChanges.length" class="section">
|
<div v-if="fileChanges.length" class="section">
|
||||||
<h3>Lokale Aenderungen</h3>
|
<h3>Lokale Aenderungen</h3>
|
||||||
<div class="log-list">
|
<div class="log-list"><div v-for="(m,i) in fileChanges" :key="i" class="log-item change">{{ m }}</div></div>
|
||||||
<div v-for="(msg, i) in fileChanges" :key="i" class="log-item change">{{ msg }}</div>
|
|
||||||
</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>
|
||||||
<div class="log-list">
|
<div class="log-list"><div v-for="(m,i) in syncLog" :key="i" class="log-item">{{ m }}</div></div>
|
||||||
<div v-for="(msg, i) in syncLog" :key="i" class="log-item">{{ msg }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
body {
|
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;font-size:14px;color:#1a1a1a;background:#f0f2f5}
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
.login-screen{height:100vh;display:flex;align-items:center;justify-content:center}
|
||||||
font-size: 14px; color: #1a1a1a; background: #f0f2f5;
|
.login-card{background:#fff;border-radius:12px;padding:2rem;width:360px;box-shadow:0 2px 12px rgba(0,0,0,.1)}
|
||||||
}
|
.login-header{text-align:center;margin-bottom:1.5rem}
|
||||||
.login-screen { height: 100vh; display: flex; align-items: center; justify-content: center; }
|
.logo-icon{font-size:2.5rem}.login-header h1{font-size:1.3rem;margin:.5rem 0 .25rem}.login-header p{color:#666;font-size:.85rem}
|
||||||
.login-card {
|
.field{margin-bottom:.75rem}.field label{display:block;margin-bottom:.25rem;font-weight:500;font-size:.85rem}
|
||||||
background: white; border-radius: 12px; padding: 2rem; width: 360px;
|
.field input,.field select{width:100%;padding:.5rem;border:1px solid #ddd;border-radius:6px;font-size:.9rem;background:#fafafa}
|
||||||
box-shadow: 0 2px 12px rgba(0,0,0,0.1);
|
.field input:focus,.field select:focus{border-color:#4a90d9;outline:none;background:#fff}
|
||||||
}
|
.error{color:#e53e3e;font-size:.85rem;margin-bottom:.75rem}
|
||||||
.login-header { text-align: center; margin-bottom: 1.5rem; }
|
.btn-primary{padding:.5rem 1rem;background:#4a90d9;color:#fff;border:none;border-radius:6px;font-size:.85rem;cursor:pointer;font-weight:500;white-space:nowrap}
|
||||||
.logo-icon { font-size: 2.5rem; }
|
.btn-primary:hover{background:#3a7bc8}.btn-primary:disabled{opacity:.6;cursor:not-allowed}
|
||||||
.login-header h1 { font-size: 1.3rem; margin: 0.5rem 0 0.25rem; }
|
.btn-primary.full{width:100%}
|
||||||
.login-header p { color: #666; font-size: 0.85rem; }
|
.btn-small{padding:.25rem .5rem;background:#e8e8e8;border:none;border-radius:4px;font-size:.8rem;cursor:pointer}
|
||||||
.field { margin-bottom: 0.75rem; }
|
.btn-small:hover{background:#ddd}
|
||||||
.field label { display: block; margin-bottom: 0.25rem; font-weight: 500; font-size: 0.85rem; }
|
.btn-danger{padding:.25rem .5rem;background:#fee;color:#c00;border:none;border-radius:4px;font-size:.8rem;cursor:pointer}
|
||||||
.field input {
|
.btn-danger:hover{background:#fcc}
|
||||||
width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 6px;
|
.main-screen{height:100vh;display:flex;flex-direction:column}
|
||||||
font-size: 0.9rem; background: #fafafa;
|
.toolbar{display:flex;align-items:center;justify-content:space-between;padding:.5rem 1rem;background:#fff;border-bottom:1px solid #e0e0e0}
|
||||||
}
|
.toolbar-left{display:flex;align-items:center;gap:.5rem}.logo-small{font-size:1.2rem}
|
||||||
.field input:focus { border-color: #4a90d9; outline: none; background: white; }
|
.status-badge{font-size:.8rem;padding:.2rem .5rem;border-radius:4px;background:#e8f5e9;color:#2e7d32}
|
||||||
.error { color: #e53e3e; font-size: 0.85rem; margin-bottom: 0.75rem; }
|
.status-badge.syncing{background:#fff3e0;color:#e65100}.status-badge.error{background:#ffebee;color:#c62828}
|
||||||
.btn-primary {
|
.spin{display:inline-block;animation:spin 1s linear infinite}@keyframes spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}
|
||||||
padding: 0.6rem 1rem; background: #4a90d9; color: white;
|
.user-info{font-size:.85rem;color:#666}
|
||||||
border: none; border-radius: 6px; font-size: 0.9rem; cursor: pointer; font-weight: 500;
|
.content{flex:1;overflow-y:auto;padding:1rem}
|
||||||
white-space: nowrap;
|
.section{background:#fff;border-radius:8px;padding:1rem;margin-bottom:.75rem}
|
||||||
}
|
.section h3{margin-bottom:.5rem;font-size:.95rem}
|
||||||
.btn-primary:hover { background: #3a7bc8; }
|
.section-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:.5rem}
|
||||||
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
|
.section-header h3{margin:0}.header-btns{display:flex;gap:.5rem}
|
||||||
.btn-small {
|
.auto-info{font-size:.8rem;color:#2e7d32;margin-bottom:.5rem}
|
||||||
padding: 0.25rem 0.5rem; background: #e8e8e8; border: none; border-radius: 4px;
|
.add-path-form{border:1px solid #e0e0e0;border-radius:8px;padding:1rem;margin-bottom:.75rem;background:#fafafa}
|
||||||
font-size: 0.8rem; cursor: pointer;
|
.browse-row{display:flex;gap:.5rem}
|
||||||
}
|
.browse-row input{flex:1}
|
||||||
.btn-small:hover { background: #ddd; }
|
.mode-select{display:flex;gap:.5rem}
|
||||||
|
.mode-option{flex:1;display:flex;flex-direction:column;padding:.5rem;border:2px solid #e0e0e0;border-radius:6px;cursor:pointer;font-size:.85rem}
|
||||||
.main-screen { height: 100vh; display: flex; flex-direction: column; }
|
.mode-option.active{border-color:#4a90d9;background:#f0f7ff}
|
||||||
.toolbar {
|
.mode-option input{margin-right:.25rem}
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
.mode-option small{color:#888;font-size:.75rem;margin-top:.25rem}
|
||||||
padding: 0.5rem 1rem; background: white; border-bottom: 1px solid #e0e0e0;
|
.form-actions{display:flex;justify-content:flex-end;gap:.5rem;margin-top:.75rem}
|
||||||
}
|
.sync-path-card{display:flex;align-items:center;justify-content:space-between;padding:.5rem .75rem;border:1px solid #e8e8e8;border-radius:6px;margin-bottom:.375rem;font-size:.85rem}
|
||||||
.toolbar-left { display: flex; align-items: center; gap: 0.5rem; }
|
.sp-info{display:flex;align-items:center;gap:.375rem;flex:1;min-width:0}
|
||||||
.logo-small { font-size: 1.2rem; }
|
.sp-server,.sp-local{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
.status-badge {
|
.sp-server{color:#4a90d9}.sp-arrow{color:#999;flex-shrink:0}.sp-local{color:#555}
|
||||||
font-size: 0.8rem; padding: 0.2rem 0.5rem; border-radius: 4px;
|
.sp-actions{display:flex;align-items:center;gap:.375rem;flex-shrink:0}
|
||||||
background: #e8f5e9; color: #2e7d32;
|
.sp-mode{font-size:.75rem;padding:.2rem .4rem;border-radius:4px;cursor:pointer;background:#f0f0f0}
|
||||||
}
|
.sp-mode.Full{background:#e3f2fd;color:#1565c0}.sp-mode.Virtual{background:#f3e5f5;color:#7b1fa2}
|
||||||
.status-badge.syncing { background: #fff3e0; color: #e65100; }
|
.file-tree{max-height:250px;overflow-y:auto}
|
||||||
.status-badge.error { background: #ffebee; color: #c62828; }
|
.tree-item{display:flex;align-items:center;gap:.5rem;padding:.3rem 0;border-bottom:1px solid #f5f5f5;font-size:.85rem}
|
||||||
.spin { display: inline-block; animation: spin 1s linear infinite; }
|
.tree-item.indent{padding-left:1.5rem}.tree-icon{flex-shrink:0}.tree-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
.tree-lock{font-size:.75rem;color:#e67e22;flex-shrink:0}.tree-size{font-size:.75rem;color:#999;flex-shrink:0}
|
||||||
.user-info { font-size: 0.85rem; color: #666; }
|
.empty{text-align:center;color:#999;padding:1rem;font-size:.85rem}
|
||||||
.content { flex: 1; overflow-y: auto; padding: 1rem; }
|
.log-list{max-height:150px;overflow-y:auto;font-family:monospace;font-size:.78rem}
|
||||||
.section { background: white; border-radius: 8px; padding: 1rem; margin-bottom: 0.75rem; }
|
.log-item{padding:.2rem 0;border-bottom:1px solid #f8f8f8;color:#555}.log-item.change{color:#1565c0}
|
||||||
.section h3 { margin-bottom: 0.5rem; font-size: 0.95rem; }
|
@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}}
|
||||||
.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-input {
|
|
||||||
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 {
|
|
||||||
display: flex; align-items: center; gap: 0.5rem;
|
|
||||||
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-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.tree-lock { font-size: 0.75rem; color: #e67e22; flex-shrink: 0; }
|
|
||||||
.tree-size { font-size: 0.75rem; color: #999; flex-shrink: 0; }
|
|
||||||
.empty { text-align: center; color: #999; padding: 1rem; }
|
|
||||||
.log-list { max-height: 150px; overflow-y: auto; font-family: monospace; font-size: 0.78rem; }
|
|
||||||
.log-item { padding: 0.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, .sync-input { background: #333; border-color: #444; color: #e0e0e0; }
|
|
||||||
.status-badge { background: #1b5e20; color: #a5d6a7; }
|
|
||||||
.status-badge.syncing { background: #e65100; color: #ffcc80; }
|
|
||||||
.tree-item { border-color: #333; }
|
|
||||||
.log-item { border-color: #333; color: #aaa; }
|
|
||||||
.log-item.change { color: #64b5f6; }
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user