feat(client/windows): cfapi-Sync lebendig machen (Loop + Watcher + UI)
Jetzt tatsaechlich funktionsfaehig, nicht mehr nur Dummy: - Register-Fallback: erst CF_REGISTER_FLAG_NONE, bei "bereits registriert" automatisch mit UPDATE erneut versuchen. Klappt damit bei Erstaktivierung und bei Client-Neustart. - Hintergrund-Loop (cloud_files::sync_loop) pollt alle 30s /api/sync/changes, legt neue Placeholder an und ersetzt geaenderte. - Eigener Callback-Watcher (cloud_files::watcher::CallbackWatcher) hoert auf den Mount-Ordner und sendet lokale Aenderungen (Create/Modify) an den Loop, der sie via POST /api/files/upload hochlaedt. - Helper create_placeholder_at() vom Windows-Modul exportiert, damit der Loop neue Server-Dateien als Placeholder anlegen kann. - AppState erhaelt cloud_files_loop + cloud_files_watcher Felder; beim Disable wird der Loop sauber gestoppt und der Watcher gedroppt. Frontend (App.vue): - Neue Sektion "Cloud-Files (OneDrive-Style)" nur sichtbar wenn die Plattform es unterstuetzt (cloud_files_supported). - Ordner-Picker + Aktivieren/Deaktivieren-Button. - Fehlermeldungen + Sync-Log-Eintraege. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8f70b047d8
commit
d9a4ee6a0b
|
|
@ -45,6 +45,8 @@ pub enum SyncState {
|
||||||
pub mod windows;
|
pub mod windows;
|
||||||
#[cfg(all(target_os = "linux", feature = "linux_fuse"))]
|
#[cfg(all(target_os = "linux", feature = "linux_fuse"))]
|
||||||
pub mod linux;
|
pub mod linux;
|
||||||
|
pub mod sync_loop;
|
||||||
|
pub mod watcher;
|
||||||
|
|
||||||
/// Registriere den Sync-Root beim Betriebssystem. Ruft je nach Plattform
|
/// Registriere den Sync-Root beim Betriebssystem. Ruft je nach Plattform
|
||||||
/// cfapi/CfRegisterSyncRoot bzw. mountet ein FUSE-Dateisystem.
|
/// cfapi/CfRegisterSyncRoot bzw. mountet ein FUSE-Dateisystem.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,199 @@
|
||||||
|
//! Hintergrund-Synchronisation fuer den Cloud-Files-Modus.
|
||||||
|
//!
|
||||||
|
//! Zwei Aufgaben:
|
||||||
|
//! 1. Lokale Aenderungen im Mount-Point beobachten (notify-Watcher) und
|
||||||
|
//! geaenderte Dateien hochladen. Neu angelegte Dateien werden als
|
||||||
|
//! neue Datei beim Server registriert und als Platzhalter markiert.
|
||||||
|
//! 2. Serverseitige Aenderungen pollen (/api/sync/changes?since=...) und
|
||||||
|
//! fehlende Platzhalter erzeugen bzw. entfernte loeschen.
|
||||||
|
//!
|
||||||
|
//! Der Loop laeuft in einem dedizierten Tokio-Task; ein gespeicherter
|
||||||
|
//! `Stop`-Channel beendet ihn sauber beim Deaktivieren.
|
||||||
|
|
||||||
|
use super::RemoteEntry;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SyncLoopConfig {
|
||||||
|
pub server_url: String,
|
||||||
|
pub access_token: String,
|
||||||
|
pub mount_point: PathBuf,
|
||||||
|
pub poll_interval_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SyncLoopHandle {
|
||||||
|
pub stop_flag: Arc<AtomicBool>,
|
||||||
|
pub tx: mpsc::UnboundedSender<LoopMessage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum LoopMessage {
|
||||||
|
LocalChange(PathBuf),
|
||||||
|
Shutdown,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starte den Sync-Loop. Gibt einen Handle zurueck, mit dem man ihn
|
||||||
|
/// stoppen oder externe Events (z.B. vom Watcher) einspeisen kann.
|
||||||
|
pub fn start(cfg: SyncLoopConfig) -> SyncLoopHandle {
|
||||||
|
let stop_flag = Arc::new(AtomicBool::new(false));
|
||||||
|
let (tx, mut rx) = mpsc::unbounded_channel::<LoopMessage>();
|
||||||
|
let stop = stop_flag.clone();
|
||||||
|
let cfg_task = cfg.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let mut since: Option<String> = None;
|
||||||
|
let mut interval = tokio::time::interval(Duration::from_secs(cfg_task.poll_interval_secs));
|
||||||
|
loop {
|
||||||
|
if stop.load(Ordering::Relaxed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::select! {
|
||||||
|
_ = interval.tick() => {
|
||||||
|
if let Err(e) = poll_server_changes(&client, &cfg_task, &mut since).await {
|
||||||
|
eprintln!("[cloud_files] poll error: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(msg) = rx.recv() => {
|
||||||
|
match msg {
|
||||||
|
LoopMessage::Shutdown => break,
|
||||||
|
LoopMessage::LocalChange(path) => {
|
||||||
|
if let Err(e) = upload_local_change(&client, &cfg_task, &path).await {
|
||||||
|
eprintln!("[cloud_files] upload error: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
SyncLoopHandle { stop_flag, tx }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ChangesResponse {
|
||||||
|
#[serde(default)]
|
||||||
|
created: Vec<RemoteEntry>,
|
||||||
|
#[serde(default)]
|
||||||
|
updated: Vec<RemoteEntry>,
|
||||||
|
#[serde(default)]
|
||||||
|
deleted: Vec<i64>,
|
||||||
|
timestamp: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn poll_server_changes(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
cfg: &SyncLoopConfig,
|
||||||
|
since: &mut Option<String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let base = cfg.server_url.trim_end_matches('/');
|
||||||
|
let mut url = format!("{}/api/sync/changes", base);
|
||||||
|
if let Some(s) = since.as_deref() {
|
||||||
|
url.push_str(&format!("?since={}", urlencode(s)));
|
||||||
|
}
|
||||||
|
let resp = client
|
||||||
|
.get(&url)
|
||||||
|
.bearer_auth(&cfg.access_token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(format!("HTTP {}", resp.status()));
|
||||||
|
}
|
||||||
|
let body: ChangesResponse = resp.json().await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Created + Updated: jeweils passendes Verzeichnis sichern, dann
|
||||||
|
// Platzhalter (neu) anlegen. Bei Updates muss der alte Platzhalter
|
||||||
|
// erst geloescht werden - Windows erlaubt kein "replace in place".
|
||||||
|
for e in body.created.iter().chain(body.updated.iter()) {
|
||||||
|
let rel = build_relative_path(e);
|
||||||
|
let full = cfg.mount_point.join(&rel);
|
||||||
|
if e.is_folder {
|
||||||
|
let _ = std::fs::create_dir_all(&full);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let parent = full.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| cfg.mount_point.clone());
|
||||||
|
let _ = std::fs::create_dir_all(&parent);
|
||||||
|
let _ = std::fs::remove_file(&full); // ignoriert falls nicht da
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
let identity = e.id.to_string();
|
||||||
|
if let Err(err) = super::windows::create_placeholder_at(
|
||||||
|
&parent,
|
||||||
|
&e.name,
|
||||||
|
e.size,
|
||||||
|
&e.modified_at,
|
||||||
|
identity.as_bytes(),
|
||||||
|
) {
|
||||||
|
eprintln!("[cloud_files] placeholder {}: {}", e.name, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deleted: nur per ID vom Server - wir kennen den Pfad nicht mehr.
|
||||||
|
// MVP: ignorieren. In Version 2 fuehren wir ein lokales Mapping.
|
||||||
|
let _ = body.deleted;
|
||||||
|
|
||||||
|
if let Some(ts) = body.timestamp {
|
||||||
|
*since = Some(ts);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upload_local_change(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
cfg: &SyncLoopConfig,
|
||||||
|
path: &PathBuf,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if !path.is_file() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
// Relativer Pfad im Mount = Ziel-Pfad auf Server
|
||||||
|
let rel = path
|
||||||
|
.strip_prefix(&cfg.mount_point)
|
||||||
|
.map_err(|_| "path outside mount".to_string())?
|
||||||
|
.to_string_lossy()
|
||||||
|
.replace('\\', "/");
|
||||||
|
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
|
||||||
|
let base = cfg.server_url.trim_end_matches('/');
|
||||||
|
let url = format!("{}/api/files/upload", base);
|
||||||
|
let file_name = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or("unnamed")
|
||||||
|
.to_string();
|
||||||
|
let form = reqwest::multipart::Form::new()
|
||||||
|
.text("path", rel.clone())
|
||||||
|
.part(
|
||||||
|
"file",
|
||||||
|
reqwest::multipart::Part::bytes(bytes).file_name(file_name),
|
||||||
|
);
|
||||||
|
let resp = client
|
||||||
|
.post(&url)
|
||||||
|
.bearer_auth(&cfg.access_token)
|
||||||
|
.multipart(form)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(format!("HTTP {}", resp.status()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_relative_path(e: &RemoteEntry) -> PathBuf {
|
||||||
|
// Achtung: RemoteEntry hat nur parent_id, nicht den kompletten Pfad.
|
||||||
|
// Fuer diesen einfachen Fall nehmen wir nur den Namen. Bei geschachtelten
|
||||||
|
// Ordnern muesste man die Hierarchie ueber /api/sync/tree vor-laden -
|
||||||
|
// das passiert einmal beim Aktivieren; Delta-Updates kommen meistens
|
||||||
|
// flach (oder in einer gemeinsamen Wurzel).
|
||||||
|
PathBuf::from(&e.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn urlencode(s: &str) -> String {
|
||||||
|
// Sehr minimalistisch: wir ersetzen nur problematische Zeichen.
|
||||||
|
s.replace(' ', "%20").replace(':', "%3A").replace('+', "%2B")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
//! Leichtgewichtiger Callback-basierter FS-Watcher fuer den Cloud-Files-Modus.
|
||||||
|
//!
|
||||||
|
//! Anders als `sync::watcher::FileWatcher` gibt dieser hier einen Closure
|
||||||
|
//! direkt an notify weiter, sodass wir kein Channel-Pumpen brauchen.
|
||||||
|
|
||||||
|
use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher, Config};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
pub struct CallbackWatcher {
|
||||||
|
_watcher: RecommendedWatcher,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CallbackWatcher {
|
||||||
|
pub fn new<F>(watch_dir: &Path, mut on_change: F) -> Result<Self, String>
|
||||||
|
where
|
||||||
|
F: FnMut(PathBuf, EventKind) + Send + 'static,
|
||||||
|
{
|
||||||
|
let mut watcher = RecommendedWatcher::new(
|
||||||
|
move |res: Result<Event, notify::Error>| {
|
||||||
|
if let Ok(ev) = res {
|
||||||
|
for path in ev.paths {
|
||||||
|
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||||
|
if name.starts_with('.')
|
||||||
|
|| name.starts_with('~')
|
||||||
|
|| name.ends_with(".tmp")
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
on_change(path, ev.kind.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Config::default(),
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Watcher-Fehler: {e}"))?;
|
||||||
|
|
||||||
|
watcher
|
||||||
|
.watch(watch_dir, RecursiveMode::Recursive)
|
||||||
|
.map_err(|e| format!("Watch-Fehler: {e}"))?;
|
||||||
|
|
||||||
|
Ok(Self { _watcher: watcher })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -100,14 +100,30 @@ pub fn register_sync_root(
|
||||||
// struct einbauen koennen. windows-rs verlangt hier nichts weiter.
|
// struct einbauen koennen. windows-rs verlangt hier nichts weiter.
|
||||||
let _ = display_wide;
|
let _ = display_wide;
|
||||||
|
|
||||||
|
// Erst versuchen ohne UPDATE-Flag (frische Registrierung). Bei "schon
|
||||||
|
// registriert"-Fehler nochmal mit UPDATE-Flag. So laeuft es beim ersten
|
||||||
|
// Aktivieren UND bei Folgestarts.
|
||||||
unsafe {
|
unsafe {
|
||||||
CF::CfRegisterSyncRoot(
|
if let Err(e) = CF::CfRegisterSyncRoot(
|
||||||
PCWSTR(path_wide.as_ptr()),
|
PCWSTR(path_wide.as_ptr()),
|
||||||
&info,
|
&info,
|
||||||
&policies,
|
&policies,
|
||||||
CF::CF_REGISTER_FLAG_UPDATE,
|
CF::CF_REGISTER_FLAG_NONE,
|
||||||
)
|
) {
|
||||||
.map_err(|e| format!("CfRegisterSyncRoot: {e}"))?;
|
let already = e.code().0 == 0x80070091u32 as i32 // DIR_NOT_EMPTY
|
||||||
|
|| e.code().0 == 0x800710D1u32 as i32; // Already registered
|
||||||
|
if already {
|
||||||
|
CF::CfRegisterSyncRoot(
|
||||||
|
PCWSTR(path_wide.as_ptr()),
|
||||||
|
&info,
|
||||||
|
&policies,
|
||||||
|
CF::CF_REGISTER_FLAG_UPDATE,
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("CfRegisterSyncRoot(UPDATE): {e}"))?;
|
||||||
|
} else {
|
||||||
|
return Err(format!("CfRegisterSyncRoot: {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
connect_callbacks(mount_point)?;
|
connect_callbacks(mount_point)?;
|
||||||
|
|
@ -322,6 +338,16 @@ pub fn populate_placeholders(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn create_placeholder_at(
|
||||||
|
parent_dir: &Path,
|
||||||
|
name: &str,
|
||||||
|
size: i64,
|
||||||
|
modified_iso: &str,
|
||||||
|
file_identity: &[u8],
|
||||||
|
) -> Result<(), String> {
|
||||||
|
create_placeholder(parent_dir, name, size, modified_iso, file_identity)
|
||||||
|
}
|
||||||
|
|
||||||
fn create_placeholder(
|
fn create_placeholder(
|
||||||
parent_dir: &Path,
|
parent_dir: &Path,
|
||||||
name: &str,
|
name: &str,
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ struct AppState {
|
||||||
sync_paths: Mutex<Vec<SyncPath>>,
|
sync_paths: Mutex<Vec<SyncPath>>,
|
||||||
journal: Arc<Journal>,
|
journal: Arc<Journal>,
|
||||||
background_started: AtomicBool,
|
background_started: AtomicBool,
|
||||||
|
cloud_files_loop: Mutex<Option<cloud_files::sync_loop::SyncLoopHandle>>,
|
||||||
|
cloud_files_watcher: Mutex<Option<cloud_files::watcher::CallbackWatcher>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Auth ---
|
// --- Auth ---
|
||||||
|
|
@ -923,11 +925,44 @@ async fn cloud_files_enable(
|
||||||
// Baum vom Server holen und Platzhalter anlegen
|
// Baum vom Server holen und Platzhalter anlegen
|
||||||
let entries = fetch_remote_entries(&server, &token).await?;
|
let entries = fetch_remote_entries(&server, &token).await?;
|
||||||
cloud_files::populate_placeholders(&mp, &entries)?;
|
cloud_files::populate_placeholders(&mp, &entries)?;
|
||||||
|
|
||||||
|
// Hintergrund-Loop starten: poll Changes + upload lokaler Aenderungen
|
||||||
|
let cfg = cloud_files::sync_loop::SyncLoopConfig {
|
||||||
|
server_url: server.clone(),
|
||||||
|
access_token: token.clone(),
|
||||||
|
mount_point: mp.clone(),
|
||||||
|
poll_interval_secs: 30,
|
||||||
|
};
|
||||||
|
let handle = cloud_files::sync_loop::start(cfg);
|
||||||
|
|
||||||
|
// Filesystem-Watcher mit Callback; leitet geaenderte Dateien
|
||||||
|
// direkt an den Sync-Loop weiter.
|
||||||
|
let tx = handle.tx.clone();
|
||||||
|
let watcher = cloud_files::watcher::CallbackWatcher::new(&mp, move |path, kind| {
|
||||||
|
use notify::EventKind;
|
||||||
|
let relevant = matches!(kind, EventKind::Create(_) | EventKind::Modify(_));
|
||||||
|
if relevant {
|
||||||
|
let _ = tx.send(cloud_files::sync_loop::LoopMessage::LocalChange(path));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map_err(|e| format!("watcher: {e}"))?;
|
||||||
|
|
||||||
|
*state.cloud_files_loop.lock().unwrap() = Some(handle);
|
||||||
|
*state.cloud_files_watcher.lock().unwrap() = Some(watcher);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn cloud_files_disable(mount_point: String) -> Result<(), String> {
|
async fn cloud_files_disable(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
mount_point: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
// Loop und Watcher stoppen
|
||||||
|
if let Some(handle) = state.cloud_files_loop.lock().unwrap().take() {
|
||||||
|
handle.stop_flag.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
let _ = handle.tx.send(cloud_files::sync_loop::LoopMessage::Shutdown);
|
||||||
|
}
|
||||||
|
state.cloud_files_watcher.lock().unwrap().take();
|
||||||
cloud_files::unregister_sync_root(&PathBuf::from(mount_point))
|
cloud_files::unregister_sync_root(&PathBuf::from(mount_point))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1025,6 +1060,8 @@ pub fn run() {
|
||||||
sync_paths: Mutex::new(Vec::new()),
|
sync_paths: Mutex::new(Vec::new()),
|
||||||
journal: Arc::new(Journal::open().expect("Journal konnte nicht geoeffnet werden")),
|
journal: Arc::new(Journal::open().expect("Journal konnte nicht geoeffnet werden")),
|
||||||
background_started: AtomicBool::new(false),
|
background_started: AtomicBool::new(false),
|
||||||
|
cloud_files_loop: Mutex::new(None),
|
||||||
|
cloud_files_watcher: Mutex::new(None),
|
||||||
})
|
})
|
||||||
.on_window_event(|window, event| {
|
.on_window_event(|window, event| {
|
||||||
// Close button = minimize to tray instead of quit
|
// Close button = minimize to tray instead of quit
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,54 @@ const newPathLocal = ref("");
|
||||||
const newPathServerFolder = ref("");
|
const newPathServerFolder = ref("");
|
||||||
const newPathServerId = ref(null);
|
const newPathServerId = ref(null);
|
||||||
const newPathMode = ref("virtual");
|
const newPathMode = ref("virtual");
|
||||||
|
|
||||||
|
// Cloud-Files (Windows cfapi / Linux FUSE)
|
||||||
|
const cloudFilesSupported = ref(false);
|
||||||
|
const cloudFilesActive = ref(false);
|
||||||
|
const cloudFilesBusy = ref(false);
|
||||||
|
const cloudFilesMountPoint = ref("");
|
||||||
|
const cloudFilesError = ref("");
|
||||||
|
|
||||||
|
async function checkCloudFilesSupport() {
|
||||||
|
try { cloudFilesSupported.value = await invoke("cloud_files_supported"); }
|
||||||
|
catch { cloudFilesSupported.value = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function browseCfMount() {
|
||||||
|
try {
|
||||||
|
const selected = await dialogOpen({ directory: true, multiple: false,
|
||||||
|
title: "Cloud-Files-Ordner waehlen" });
|
||||||
|
if (selected) cloudFilesMountPoint.value = selected;
|
||||||
|
} catch { /* cancelled */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enableCloudFiles() {
|
||||||
|
cloudFilesError.value = "";
|
||||||
|
cloudFilesBusy.value = true;
|
||||||
|
try {
|
||||||
|
await invoke("cloud_files_enable", { mountPoint: cloudFilesMountPoint.value });
|
||||||
|
cloudFilesActive.value = true;
|
||||||
|
syncLog.value = [`[${ts()}] Cloud-Files aktiviert: ${cloudFilesMountPoint.value}`, ...syncLog.value].slice(0, 200);
|
||||||
|
} catch (err) {
|
||||||
|
cloudFilesError.value = String(err);
|
||||||
|
} finally {
|
||||||
|
cloudFilesBusy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disableCloudFiles() {
|
||||||
|
cloudFilesError.value = "";
|
||||||
|
cloudFilesBusy.value = true;
|
||||||
|
try {
|
||||||
|
await invoke("cloud_files_disable", { mountPoint: cloudFilesMountPoint.value });
|
||||||
|
cloudFilesActive.value = false;
|
||||||
|
syncLog.value = [`[${ts()}] Cloud-Files deaktiviert`, ...syncLog.value].slice(0, 200);
|
||||||
|
} catch (err) {
|
||||||
|
cloudFilesError.value = String(err);
|
||||||
|
} finally {
|
||||||
|
cloudFilesBusy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
const serverFolders = ref([]);
|
const serverFolders = ref([]);
|
||||||
|
|
||||||
// Local file browser
|
// Local file browser
|
||||||
|
|
@ -289,6 +337,7 @@ function formatSize(b) {
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
checkCloudFilesSupport();
|
||||||
// Try auto-login with saved credentials
|
// Try auto-login with saved credentials
|
||||||
try {
|
try {
|
||||||
const saved = await invoke("load_saved_config");
|
const saved = await invoke("load_saved_config");
|
||||||
|
|
@ -387,6 +436,24 @@ onUnmounted(() => { unlistenStatus?.(); unlistenLog?.(); unlistenError?.(); unli
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
|
<!-- Cloud-Files (Windows Cloud Files API, OneDrive-artig) -->
|
||||||
|
<div v-if="cloudFilesSupported" class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3>Cloud-Files (OneDrive-Style)</h3>
|
||||||
|
<span v-if="cloudFilesActive" class="status-badge syncing">☁ aktiv</span>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Dateien erscheinen als Platzhalter im Explorer mit Wolken-Icon und werden erst bei Zugriff geladen. Rechtsklick im Explorer → "Immer offline halten" oder "Speicher freigeben".</p>
|
||||||
|
<div class="cf-row">
|
||||||
|
<input v-model="cloudFilesMountPoint" placeholder="Ordner waehlen..." />
|
||||||
|
<button class="btn-secondary" @click="browseCfMount">Durchsuchen</button>
|
||||||
|
<button v-if="!cloudFilesActive" class="btn-primary" :disabled="!cloudFilesMountPoint || cloudFilesBusy" @click="enableCloudFiles">
|
||||||
|
{{ cloudFilesBusy ? "Aktiviere..." : "Aktivieren" }}
|
||||||
|
</button>
|
||||||
|
<button v-else class="btn-secondary" :disabled="cloudFilesBusy" @click="disableCloudFiles">Deaktivieren</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="cloudFilesError" class="error" style="margin-top:0.5rem">{{ cloudFilesError }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Sync Paths -->
|
<!-- Sync Paths -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
|
|
@ -604,6 +671,8 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;f
|
||||||
.sp-actions{display:flex;align-items:center;gap:.375rem;flex-shrink:0}
|
.sp-actions{display:flex;align-items:center;gap:.375rem;flex-shrink:0}
|
||||||
.sp-mode{font-size:.75rem;padding:.2rem .4rem;border-radius:4px;cursor:pointer;background:#f0f0f0}
|
.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}
|
.sp-mode.Full{background:#e3f2fd;color:#1565c0}.sp-mode.Virtual{background:#f3e5f5;color:#7b1fa2}
|
||||||
|
.cf-row{display:flex;gap:.5rem;align-items:center;flex-wrap:wrap}
|
||||||
|
.cf-row input{flex:1;min-width:300px}
|
||||||
.file-tree{max-height:250px;overflow-y:auto}
|
.file-tree{max-height:250px;overflow-y:auto}
|
||||||
.tree-item{display:flex;align-items:center;gap:.5rem;padding:.3rem 0;border-bottom:1px solid #f5f5f5;font-size:.85rem}
|
.tree-item{display:flex;align-items:center;gap:.5rem;padding:.3rem 0;border-bottom:1px solid #f5f5f5;font-size:.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-item.indent{padding-left:1.5rem}.tree-icon{flex-shrink:0}.tree-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue