完成webdav同步功能

This commit is contained in:
2026-07-10 23:57:23 +08:00
parent de5bd2fa01
commit 6a66c7867a
6 changed files with 2866 additions and 25 deletions

1352
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -27,4 +27,5 @@ serde_json = "1"
time = ">=0.3.0, <0.3.47"
notify = "8.2.0"
regex = "1"
opendal = { version = "0.57.0", features = ["services-s3", "services-webdav"] }
tokio = { version = "1", features = ["rt-multi-thread", "time"] }

View File

@@ -1,14 +1,26 @@
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
use notify::{RecursiveMode, Watcher};
use opendal::services::{S3, Webdav};
use opendal::{EntryMode, ErrorKind, Operator};
use std::collections::{BTreeMap, BTreeSet};
use std::future::{Future, IntoFuture};
use std::fs;
use std::path::PathBuf;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tauri::menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder};
use tauri::{Emitter, Manager};
use tauri_plugin_dialog::DialogExt;
use base64::Engine;
use regex::Regex;
const SYNC_CHECK_TIMEOUT: Duration = Duration::from_secs(15);
const SYNC_TEST_STEP_TIMEOUT: Duration = Duration::from_secs(15);
const SYNC_REMOTE_LIST_TIMEOUT: Duration = Duration::from_secs(120);
const SYNC_REMOTE_FILE_TIMEOUT: Duration = Duration::from_secs(180);
const SYNC_TEST_PAYLOAD: &[u8] = b"yurou sync connection probe\n";
#[derive(Debug, Clone, serde::Serialize)]
struct DirEntry {
name: String,
@@ -455,6 +467,845 @@ fn walk_dir(
Ok(())
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct SyncConfig {
provider: SyncProvider,
webdav: WebdavSyncConfig,
s3: S3SyncConfig,
auto_sync_on_startup: bool,
auto_sync_after_save: bool,
last_sync_at: Option<String>,
}
impl Default for SyncConfig {
fn default() -> Self {
Self {
provider: SyncProvider::None,
webdav: WebdavSyncConfig::default(),
s3: S3SyncConfig::default(),
auto_sync_on_startup: false,
auto_sync_after_save: false,
last_sync_at: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
enum SyncProvider {
None,
Webdav,
S3,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WebdavSyncConfig {
endpoint: String,
username: String,
password: String,
remote_dir: String,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct S3SyncConfig {
endpoint: String,
region: String,
bucket: String,
access_key_id: String,
secret_access_key: String,
remote_prefix: String,
path_style: bool,
use_https: bool,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct SyncHistory {
provider_key: String,
files: BTreeMap<String, SyncHistoryEntry>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct SyncHistoryEntry {
local_modified_ms: Option<i128>,
remote_modified_ms: Option<i128>,
size: u64,
}
#[derive(Debug, Clone)]
struct SyncFileInfo {
modified_ms: Option<i128>,
size: u64,
}
#[derive(Debug, Clone, Copy, Default, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct SyncStats {
uploaded: usize,
downloaded: usize,
deleted_local: usize,
deleted_remote: usize,
skipped: usize,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct SyncResult {
message: String,
last_sync_at: String,
stats: SyncStats,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct SyncConnectionTestResult {
message: String,
details: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SyncAction {
Upload,
Download,
DeleteLocal,
DeleteRemote,
Skip,
}
fn tauri_app_config_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
app.path()
.app_config_dir()
.map_err(|e| format!("无法获取配置目录: {}", e))
}
fn sync_config_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
Ok(tauri_app_config_dir(app)?.join("sync-config.json"))
}
fn sync_history_path(app: &tauri::AppHandle, provider_key: &str) -> Result<PathBuf, String> {
Ok(tauri_app_config_dir(app)?.join(format!("sync-history-{}.json", stable_hash(provider_key))))
}
fn stable_hash(value: &str) -> u64 {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
value.hash(&mut hasher);
hasher.finish()
}
fn load_sync_config_from_disk(app: &tauri::AppHandle) -> Result<SyncConfig, String> {
let path = sync_config_path(app)?;
if !path.exists() {
return Ok(SyncConfig::default());
}
let raw = fs::read_to_string(&path).map_err(|e| format!("无法读取同步配置: {}", e))?;
serde_json::from_str(&raw).map_err(|e| format!("同步配置格式无效: {}", e))
}
fn save_sync_config_to_disk(app: &tauri::AppHandle, config: &SyncConfig) -> Result<(), String> {
let path = sync_config_path(app)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("无法创建配置目录: {}", e))?;
}
let raw = serde_json::to_string_pretty(config).map_err(|e| format!("无法序列化同步配置: {}", e))?;
fs::write(&path, raw).map_err(|e| format!("无法保存同步配置: {}", e))
}
fn load_sync_history(app: &tauri::AppHandle, provider_key: &str) -> Result<SyncHistory, String> {
let path = sync_history_path(app, provider_key)?;
if !path.exists() {
return Ok(SyncHistory {
provider_key: provider_key.to_string(),
files: BTreeMap::new(),
});
}
let raw = fs::read_to_string(&path).map_err(|e| format!("无法读取同步历史: {}", e))?;
let mut history: SyncHistory =
serde_json::from_str(&raw).map_err(|e| format!("同步历史格式无效: {}", e))?;
history.provider_key = provider_key.to_string();
Ok(history)
}
fn save_sync_history(app: &tauri::AppHandle, history: &SyncHistory) -> Result<(), String> {
let path = sync_history_path(app, &history.provider_key)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("无法创建同步历史目录: {}", e))?;
}
let raw = serde_json::to_string_pretty(history).map_err(|e| format!("无法序列化同步历史: {}", e))?;
fs::write(&path, raw).map_err(|e| format!("无法保存同步历史: {}", e))
}
fn normalize_remote_root(path: &str) -> String {
let trimmed = path.trim().replace('\\', "/");
let trimmed = trimmed.trim_matches('/');
if trimmed.is_empty() {
"/".to_string()
} else {
format!("/{}", trimmed)
}
}
fn normalize_s3_prefix(prefix: &str) -> String {
prefix
.trim()
.replace('\\', "/")
.trim_matches('/')
.to_string()
}
fn normalize_remote_file_path(path: &str) -> String {
path.trim_start_matches('/').replace('\\', "/")
}
fn normalize_relative_path(path: &Path) -> String {
path.components()
.filter_map(|component| match component {
std::path::Component::Normal(part) => Some(part.to_string_lossy().to_string()),
_ => None,
})
.collect::<Vec<_>>()
.join("/")
}
fn provider_key(config: &SyncConfig) -> Result<String, String> {
match config.provider {
SyncProvider::None => Err("请先选择同步方式".to_string()),
SyncProvider::Webdav => {
let endpoint = config.webdav.endpoint.trim();
if endpoint.is_empty() {
return Err("请填写 WebDAV 服务器地址".to_string());
}
Ok(format!(
"webdav:{}:{}",
endpoint.trim_end_matches('/'),
normalize_remote_root(&config.webdav.remote_dir)
))
}
SyncProvider::S3 => {
let bucket = config.s3.bucket.trim();
if bucket.is_empty() {
return Err("请填写 S3 Bucket".to_string());
}
Ok(format!(
"s3:{}:{}:{}",
config.s3.endpoint.trim().trim_end_matches('/'),
bucket,
normalize_s3_prefix(&config.s3.remote_prefix)
))
}
}
}
fn build_sync_operator(config: &SyncConfig) -> Result<Operator, String> {
match config.provider {
SyncProvider::None => Err("请先选择同步方式".to_string()),
SyncProvider::Webdav => {
let endpoint = config.webdav.endpoint.trim();
if endpoint.is_empty() {
return Err("请填写 WebDAV 服务器地址".to_string());
}
let mut builder = Webdav::default()
.endpoint(endpoint)
.root(&normalize_remote_root(&config.webdav.remote_dir));
if !config.webdav.username.trim().is_empty() {
builder = builder.username(config.webdav.username.trim());
}
if !config.webdav.password.is_empty() {
builder = builder.password(&config.webdav.password);
}
Operator::new(builder)
.map(|builder| builder.finish())
.map_err(|e| format!("无法初始化 WebDAV 同步: {}", e))
}
SyncProvider::S3 => {
let bucket = config.s3.bucket.trim();
if bucket.is_empty() {
return Err("请填写 S3 Bucket".to_string());
}
let mut endpoint = config.s3.endpoint.trim().trim_end_matches('/').to_string();
if !endpoint.is_empty() && !endpoint.contains("://") {
endpoint = if config.s3.use_https {
format!("https://{}", endpoint)
} else {
format!("http://{}", endpoint)
};
}
let mut builder = S3::default()
.bucket(bucket)
.root(&normalize_s3_prefix(&config.s3.remote_prefix))
.disable_config_load();
if !endpoint.is_empty() {
builder = builder.endpoint(&endpoint);
}
if !config.s3.region.trim().is_empty() {
builder = builder.region(config.s3.region.trim());
}
if !config.s3.access_key_id.trim().is_empty() {
builder = builder.access_key_id(config.s3.access_key_id.trim());
}
if !config.s3.secret_access_key.is_empty() {
builder = builder.secret_access_key(&config.s3.secret_access_key);
}
if !config.s3.path_style {
builder = builder.enable_virtual_host_style();
}
Operator::new(builder)
.map(|builder| builder.finish())
.map_err(|e| format!("无法初始化 S3 同步: {}", e))
}
}
}
async fn opendal_timeout<T, F>(step: &str, timeout: Duration, future: F) -> Result<T, String>
where
F: Future<Output = Result<T, opendal::Error>>,
{
match tokio::time::timeout(timeout, future).await {
Ok(Ok(value)) => Ok(value),
Ok(Err(error)) => Err(format!("{}失败: {}", step, describe_opendal_error(&error))),
Err(_) => Err(format!("{}超时({} 秒)", step, timeout.as_secs())),
}
}
async fn test_step<T, F>(
details: &mut Vec<String>,
step: &str,
success: &str,
future: F,
) -> Result<T, String>
where
F: Future<Output = Result<T, opendal::Error>>,
{
let start = Instant::now();
match tokio::time::timeout(SYNC_TEST_STEP_TIMEOUT, future).await {
Ok(Ok(value)) => {
details.push(format!(
"{}: {},耗时 {}ms",
step,
success,
start.elapsed().as_millis()
));
Ok(value)
}
Ok(Err(error)) => Err(format_sync_test_failure(
step,
describe_opendal_error(&error),
details,
)),
Err(_) => Err(format_sync_test_failure(
step,
format!("请求超时({} 秒),请检查服务器地址、网络或远端目录响应速度", SYNC_TEST_STEP_TIMEOUT.as_secs()),
details,
)),
}
}
fn describe_opendal_error(error: &opendal::Error) -> String {
let hint = match error.kind() {
ErrorKind::ConfigInvalid => "配置无效请检查服务器地址、Bucket、Region 或远端路径",
ErrorKind::PermissionDenied => "权限被拒绝,通常表示账号/密码/Access Key 不正确,或该账号没有远端目录权限",
ErrorKind::NotFound => "远端路径不存在,请检查 WebDAV 远端目录、S3 Bucket 或前缀",
ErrorKind::Unsupported => "服务不支持当前操作,请确认 WebDAV/S3 兼容能力",
ErrorKind::RateLimited => "服务限流,请稍后重试",
ErrorKind::IsADirectory => "目标是目录,不是文件",
ErrorKind::NotADirectory => "目标不是目录",
ErrorKind::AlreadyExists => "目标已存在",
ErrorKind::ConditionNotMatch => "服务端条件校验未通过",
ErrorKind::RangeNotSatisfied => "读取范围无效",
ErrorKind::IsSameFile => "源和目标是同一个文件",
ErrorKind::Unexpected => "服务返回了非预期错误,请检查原始错误信息",
_ => "服务返回了未知错误",
};
format!("{};原始错误: {}", hint, error)
}
fn format_sync_test_failure(step: &str, reason: String, details: &[String]) -> String {
let mut lines = vec![format!("连接测试失败,失败步骤:{}", step), reason];
if !details.is_empty() {
lines.push("已通过步骤:".to_string());
lines.extend(details.iter().map(|detail| format!("- {}", detail)));
}
lines.join("\n")
}
fn sync_target_label(config: &SyncConfig) -> String {
match config.provider {
SyncProvider::None => "未选择同步方式".to_string(),
SyncProvider::Webdav => format!(
"WebDAV {}{}",
config.webdav.endpoint.trim(),
normalize_remote_root(&config.webdav.remote_dir)
),
SyncProvider::S3 => format!(
"S3 bucket={} prefix={}",
config.s3.bucket.trim(),
normalize_s3_prefix(&config.s3.remote_prefix)
),
}
}
fn sync_probe_file_path() -> String {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or_default();
format!("_yurou-sync-probe-{}-{}.tmp", std::process::id(), millis)
}
fn remote_parent_dirs(relative: &str) -> Vec<String> {
let parts: Vec<&str> = relative.split('/').filter(|part| !part.is_empty()).collect();
if parts.len() <= 1 {
return Vec::new();
}
let mut dirs = Vec::new();
let mut current = String::new();
for part in parts.iter().take(parts.len() - 1) {
current.push_str(part);
current.push('/');
dirs.push(current.clone());
}
dirs
}
async fn ensure_remote_parent_dirs(op: &Operator, relative: &str) -> Result<(), String> {
for dir in remote_parent_dirs(relative) {
match tokio::time::timeout(SYNC_REMOTE_FILE_TIMEOUT, op.create_dir(&dir)).await {
Ok(Ok(())) => {}
Ok(Err(error)) if error.kind() == ErrorKind::AlreadyExists => {}
Ok(Err(error)) => {
return Err(format!(
"创建远端目录 {}失败: {}",
dir,
describe_opendal_error(&error)
));
}
Err(_) => {
return Err(format!(
"创建远端目录 {}超时({} 秒)",
dir,
SYNC_REMOTE_FILE_TIMEOUT.as_secs()
));
}
}
}
Ok(())
}
async fn run_sync_connection_test(config: &SyncConfig) -> Result<SyncConnectionTestResult, String> {
let mut details = Vec::new();
let op = build_sync_operator(config)?;
details.push(format!("配置解析通过,目标:{}", sync_target_label(config)));
test_step(&mut details, "服务检查", "服务器可达", op.check()).await?;
test_step(
&mut details,
"目录读取",
"账号/密码有效,远端目录可读取",
op.list_with("/").limit(1).into_future(),
)
.await?;
let probe_path = sync_probe_file_path();
test_step(
&mut details,
"写入探针",
"远端目录可写",
op.write(&probe_path, SYNC_TEST_PAYLOAD.to_vec()),
)
.await?;
let read_result = test_step(
&mut details,
"读取探针",
"写入内容可读回",
op.read(&probe_path),
)
.await;
let read_bytes = match read_result {
Ok(bytes) => bytes.to_vec(),
Err(error) => {
let _ = opendal_timeout("清理测试文件", SYNC_TEST_STEP_TIMEOUT, op.delete(&probe_path)).await;
return Err(error);
}
};
if read_bytes != SYNC_TEST_PAYLOAD {
let _ = opendal_timeout("清理测试文件", SYNC_TEST_STEP_TIMEOUT, op.delete(&probe_path)).await;
return Err(format_sync_test_failure(
"读取探针",
"读回内容与写入内容不一致,请检查 WebDAV/S3 服务是否有缓存或代理改写".to_string(),
&details,
));
}
test_step(
&mut details,
"删除探针",
"远端目录可删除",
op.delete(&probe_path),
)
.await?;
Ok(SyncConnectionTestResult {
message: "连接可用,账号/密码正确,远端目录具备读取、写入和删除权限".to_string(),
details,
})
}
fn system_time_ms(time: SystemTime) -> Option<i128> {
match time.duration_since(UNIX_EPOCH) {
Ok(duration) => Some(duration.as_millis() as i128),
Err(error) => Some(-(error.duration().as_millis() as i128)),
}
}
fn now_string() -> String {
opendal::raw::Timestamp::now().to_string()
}
fn remote_modified_ms(meta: &opendal::Metadata) -> Option<i128> {
meta.last_modified()
.and_then(|value| {
let system_time: SystemTime = value.into();
system_time_ms(system_time)
})
}
fn scan_local_files(root: &Path) -> Result<BTreeMap<String, SyncFileInfo>, String> {
if !root.is_dir() {
return Err("工作目录不存在或不是文件夹".to_string());
}
let mut files = BTreeMap::new();
scan_local_files_inner(root, root, &mut files)?;
Ok(files)
}
fn scan_local_files_inner(
root: &Path,
current: &Path,
files: &mut BTreeMap<String, SyncFileInfo>,
) -> Result<(), String> {
for entry in fs::read_dir(current).map_err(|e| format!("无法读取工作目录: {}", e))? {
let entry = entry.map_err(|e| format!("读取工作目录条目失败: {}", e))?;
let path = entry.path();
let file_type = entry
.file_type()
.map_err(|e| format!("无法读取文件类型: {}", e))?;
if file_type.is_dir() {
scan_local_files_inner(root, &path, files)?;
} else if file_type.is_file() {
let metadata = entry
.metadata()
.map_err(|e| format!("无法读取文件元数据: {}", e))?;
let relative = path
.strip_prefix(root)
.map(normalize_relative_path)
.map_err(|e| format!("无法计算相对路径: {}", e))?;
files.insert(
relative.clone(),
SyncFileInfo {
modified_ms: metadata.modified().ok().and_then(system_time_ms),
size: metadata.len(),
},
);
}
}
Ok(())
}
async fn scan_remote_files(op: &Operator) -> Result<BTreeMap<String, SyncFileInfo>, String> {
let entries = opendal_timeout(
"读取远端文件列表",
SYNC_REMOTE_LIST_TIMEOUT,
op.list_with("/").recursive(true).into_future(),
)
.await?;
let mut files = BTreeMap::new();
for entry in entries {
let (path, listed_meta) = entry.into_parts();
if path == "/" || path.ends_with('/') || listed_meta.mode() == EntryMode::DIR {
continue;
}
let meta = match tokio::time::timeout(SYNC_REMOTE_FILE_TIMEOUT, op.stat(&path)).await {
Ok(Ok(meta)) => meta,
Ok(Err(error)) if error.kind() == ErrorKind::NotFound => continue,
Ok(Err(error)) => {
return Err(format!(
"读取远端文件元数据 {}失败: {}",
path,
describe_opendal_error(&error)
));
}
Err(_) => {
return Err(format!(
"读取远端文件元数据 {}超时({} 秒)",
path,
SYNC_REMOTE_FILE_TIMEOUT.as_secs()
));
}
};
if !meta.is_file() {
continue;
}
let relative = normalize_remote_file_path(&path);
files.insert(
relative.clone(),
SyncFileInfo {
modified_ms: remote_modified_ms(&meta),
size: meta.content_length(),
},
);
}
Ok(files)
}
fn choose_sync_action(
local: Option<&SyncFileInfo>,
remote: Option<&SyncFileInfo>,
history: Option<&SyncHistoryEntry>,
) -> SyncAction {
match (local, remote) {
(Some(local), Some(remote)) => {
if let Some(history) = history {
if history.local_modified_ms == local.modified_ms
&& history.remote_modified_ms == remote.modified_ms
&& history.size == local.size
&& history.size == remote.size
{
return SyncAction::Skip;
}
}
if local.size == remote.size
&& local.modified_ms.is_some()
&& remote.modified_ms.is_some()
&& local.modified_ms == remote.modified_ms
{
return SyncAction::Skip;
}
match (local.modified_ms, remote.modified_ms) {
(Some(local_ms), Some(remote_ms)) => {
if local_ms >= remote_ms {
SyncAction::Upload
} else {
SyncAction::Download
}
}
(Some(_), None) => SyncAction::Upload,
(None, Some(_)) => SyncAction::Download,
(None, None) => {
if local.size == remote.size {
SyncAction::Skip
} else {
SyncAction::Upload
}
}
}
}
(Some(local), None) => {
if let Some(history) = history {
if history.local_modified_ms == local.modified_ms && history.size == local.size {
SyncAction::DeleteLocal
} else {
SyncAction::Upload
}
} else {
SyncAction::Upload
}
}
(None, Some(remote)) => {
if let Some(history) = history {
if history.remote_modified_ms == remote.modified_ms && history.size == remote.size {
SyncAction::DeleteRemote
} else {
SyncAction::Download
}
} else {
SyncAction::Download
}
}
(None, None) => SyncAction::Skip,
}
}
fn local_path_for(root: &Path, relative: &str) -> PathBuf {
relative
.split('/')
.filter(|part| !part.is_empty())
.fold(root.to_path_buf(), |path, part| path.join(part))
}
async fn sync_workspace(
app: &tauri::AppHandle,
workspace: &Path,
config: &SyncConfig,
) -> Result<SyncResult, String> {
let op = build_sync_operator(config)?;
opendal_timeout("同步连接检查", SYNC_CHECK_TIMEOUT, op.check()).await?;
let key = provider_key(config)?;
let local_files = scan_local_files(workspace)?;
let remote_files = scan_remote_files(&op).await?;
let mut history = load_sync_history(app, &key)?;
let mut stats = SyncStats::default();
let all_paths: BTreeSet<String> = local_files
.keys()
.chain(remote_files.keys())
.chain(history.files.keys())
.cloned()
.collect();
for relative in all_paths {
let local = local_files.get(&relative);
let remote = remote_files.get(&relative);
let prior = history.files.get(&relative);
match choose_sync_action(local, remote, prior) {
SyncAction::Upload => {
let local_path = local_path_for(workspace, &relative);
let bytes = fs::read(&local_path)
.map_err(|e| format!("无法读取本地文件 {}: {}", local_path.display(), e))?;
ensure_remote_parent_dirs(&op, &relative).await?;
opendal_timeout(
&format!("上传 {}", relative),
SYNC_REMOTE_FILE_TIMEOUT,
op.write(&relative, bytes),
)
.await?;
stats.uploaded += 1;
}
SyncAction::Download => {
let local_path = local_path_for(workspace, &relative);
if let Some(parent) = local_path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("无法创建本地目录 {}: {}", parent.display(), e))?;
}
let bytes = opendal_timeout(
&format!("下载 {}", relative),
SYNC_REMOTE_FILE_TIMEOUT,
op.read(&relative),
)
.await?
.to_vec();
fs::write(&local_path, bytes)
.map_err(|e| format!("无法写入本地文件 {}: {}", local_path.display(), e))?;
stats.downloaded += 1;
}
SyncAction::DeleteLocal => {
let local_path = local_path_for(workspace, &relative);
if local_path.is_file() {
fs::remove_file(&local_path)
.map_err(|e| format!("无法删除本地文件 {}: {}", local_path.display(), e))?;
}
history.files.remove(&relative);
stats.deleted_local += 1;
continue;
}
SyncAction::DeleteRemote => {
opendal_timeout(
&format!("删除远端文件 {}", relative),
SYNC_REMOTE_FILE_TIMEOUT,
op.delete(&relative),
)
.await?;
history.files.remove(&relative);
stats.deleted_remote += 1;
continue;
}
SyncAction::Skip => {
stats.skipped += 1;
}
}
}
let refreshed_local = scan_local_files(workspace)?;
let refreshed_remote = scan_remote_files(&op).await?;
history.files.clear();
let synced_paths: BTreeSet<String> = refreshed_local
.keys()
.chain(refreshed_remote.keys())
.cloned()
.collect();
for relative in synced_paths {
let local = refreshed_local.get(&relative);
let remote = refreshed_remote.get(&relative);
let size = local
.map(|item| item.size)
.or_else(|| remote.map(|item| item.size))
.unwrap_or(0);
history.files.insert(
relative,
SyncHistoryEntry {
local_modified_ms: local.and_then(|item| item.modified_ms),
remote_modified_ms: remote.and_then(|item| item.modified_ms),
size,
},
);
}
save_sync_history(app, &history)?;
let last_sync_at = now_string();
let mut saved_config = config.clone();
saved_config.last_sync_at = Some(last_sync_at.clone());
save_sync_config_to_disk(app, &saved_config)?;
Ok(SyncResult {
message: "同步完成".to_string(),
last_sync_at,
stats,
})
}
#[tauri::command]
fn load_sync_config(app: tauri::AppHandle) -> Result<SyncConfig, String> {
load_sync_config_from_disk(&app)
}
#[tauri::command]
fn save_sync_config(app: tauri::AppHandle, config: SyncConfig) -> Result<(), String> {
save_sync_config_to_disk(&app, &config)
}
#[tauri::command]
async fn test_sync_connection(config: SyncConfig) -> Result<SyncConnectionTestResult, String> {
run_sync_connection_test(&config).await
}
#[tauri::command]
async fn sync_now(app: tauri::AppHandle, workspace: String, config: SyncConfig) -> Result<SyncResult, String> {
let workspace = PathBuf::from(workspace);
sync_workspace(&app, &workspace, &config).await
}
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
@@ -480,7 +1331,11 @@ pub fn run() {
delete_entry,
rename_entry,
save_media_file,
search_files
search_files,
load_sync_config,
save_sync_config,
test_sync_connection,
sync_now
])
.setup(|app| {
// ---- File menu ----

View File

@@ -14,6 +14,7 @@ const charCount = ref(0);
type DocumentMode = "preview" | "edit" | "mixed";
type AppTheme = "system" | "warm" | "white" | "black" | "gruvbox" | "gray";
type WorkspaceFileType = "markdown" | "image" | "video";
type SyncProvider = "none" | "webdav" | "s3";
const DEFAULT_DOCUMENT_MODE_KEY = "yurou:default-document-mode";
const APP_THEME_KEY = "yurou:app-theme";
const MARKDOWN_EXTENSIONS = new Set(["md"]);
@@ -104,6 +105,41 @@ interface WorkspaceWatchErrorPayload {
message: string;
}
interface SyncConfig {
provider: SyncProvider;
webdav: {
endpoint: string;
username: string;
password: string;
remoteDir: string;
};
s3: {
endpoint: string;
region: string;
bucket: string;
accessKeyId: string;
secretAccessKey: string;
remotePrefix: string;
pathStyle: boolean;
useHttps: boolean;
};
autoSyncOnStartup: boolean;
autoSyncAfterSave: boolean;
lastSyncAt: string | null;
}
interface SyncResult {
message: string;
lastSyncAt: string;
stats: {
uploaded: number;
downloaded: number;
deletedLocal: number;
deletedRemote: number;
skipped: number;
};
}
const folderPath = ref("");
const dirEntries = ref<DirEntry[]>([]);
const LAST_WORKSPACE_PATH_KEY = "yurou:last-workspace-path";
@@ -115,6 +151,7 @@ const WORKSPACE_REFRESH_DELAY_MS = 140;
let workspaceRefreshTimer: ReturnType<typeof setTimeout> | null = null;
let workspaceRefreshInFlight = false;
let workspaceRefreshQueued = false;
let syncInFlight = false;
// ---- editor zoom (Ctrl+scroll) ----
const ZOOM_MIN = 0.7;
@@ -172,7 +209,7 @@ const documentMode = ref<DocumentMode>(defaultDocumentMode.value);
const appTheme = ref<AppTheme>(loadAppTheme());
const showMoreMenu = ref(false);
const settingsRequest = ref(0);
const settingsInitialPage = ref<"about" | "editor">("about");
const settingsInitialPage = ref<"about" | "editor" | "sync">("about");
const markdownEditorRef = ref<{ flushPendingChanges: () => string | undefined; scrollToLine: (line: number) => void } | null>(null);
const directorySidebarRef = ref<{ activateSearch: () => void } | null>(null);
const isWindowMaximized = ref(false);
@@ -279,7 +316,7 @@ function onTitlebarDoubleClick(event: MouseEvent) {
void toggleWindowMaximize();
}
function openSettings(page: "about" | "editor" = "about") {
function openSettings(page: "about" | "editor" | "sync" = "about") {
settingsInitialPage.value = page;
settingsRequest.value += 1;
}
@@ -565,7 +602,7 @@ async function saveCurrentWorkspaceDocumentBeforeSwitch() {
const contentToSave = getLatestEditorContent();
if (!currentFilePath.value && !contentToSave.trim()) return true;
return doSave(contentToSave);
return doSave(contentToSave, { syncAfterSave: false });
}
function resetWorkspaceAndEditorState(options: { stopWatcher?: boolean } = {}) {
@@ -631,14 +668,109 @@ async function switchWorkspace(path: string, options: { persist?: boolean; reset
return true;
}
async function loadSyncConfig(): Promise<SyncConfig | null> {
try {
return await invoke<SyncConfig>("load_sync_config");
} catch (e) {
console.error("Failed to load sync config:", e);
return null;
}
}
function shouldRunSync(config: SyncConfig | null, trigger: "startup" | "save") {
if (!config || config.provider === "none") return false;
if (trigger === "startup") return config.autoSyncOnStartup;
return config.autoSyncAfterSave;
}
function describeSyncResult(result: SyncResult) {
const { stats } = result;
return `同步完成: 上传 ${stats.uploaded}, 下载 ${stats.downloaded}, 删除本地 ${stats.deletedLocal}, 删除远端 ${stats.deletedRemote}`;
}
async function refreshCurrentOpenFileFromDisk() {
if (!currentFilePath.value) return;
if (!folderPath.value) return;
try {
if (isMarkdownDocument.value) {
const fileContent = await invoke<string>("read_file", { path: currentFilePath.value });
setDocumentContent(fileContent);
hasDraftDocument.value = false;
editorResetKey.value += 1;
return;
}
if (currentFileType.value === "image" || currentFileType.value === "video") {
mediaPreviewSrc.value = getLocalAssetSrc(currentFilePath.value);
mediaPreviewError.value = "";
mediaPreviewKey.value += 1;
}
} catch (e) {
console.error("Failed to refresh current file after sync:", e);
resetMarkdownEditorState();
}
}
async function runConfiguredSync(trigger: "startup" | "save" | "manual") {
if (!folderPath.value || syncInFlight) return false;
const config = await loadSyncConfig();
if (trigger !== "manual" && !shouldRunSync(config, trigger)) return false;
if (!config || config.provider === "none") return false;
if (trigger !== "save") {
const saved = await saveCurrentWorkspaceDocumentBeforeSwitch();
if (!saved) return false;
}
syncInFlight = true;
try {
const result = await invoke<SyncResult>("sync_now", {
workspace: folderPath.value,
config,
});
saveStatus.value = describeSyncResult(result);
setTimeout(() => {
saveStatus.value = "";
}, 3600);
await refreshWorkspaceTree();
await refreshCurrentOpenFileFromDisk();
return true;
} catch (e) {
console.error("Sync failed:", e);
saveStatus.value = `同步失败: ${e}`;
return false;
} finally {
syncInFlight = false;
}
}
async function handleWorkspaceSynced() {
await refreshWorkspaceTree();
await refreshCurrentOpenFileFromDisk();
}
async function prepareWorkspaceSync(done: (saved: boolean) => void) {
try {
done(await saveCurrentWorkspaceDocumentBeforeSwitch());
} catch (e) {
console.error("Failed to prepare workspace sync:", e);
done(false);
}
}
async function restoreLastWorkspace() {
const lastWorkspacePath = getLastWorkspacePath();
if (!lastWorkspacePath) return;
await switchWorkspace(lastWorkspacePath, {
const restored = await switchWorkspace(lastWorkspacePath, {
persist: false,
resetEditor: true,
});
if (restored) {
void runConfiguredSync("startup");
}
}
async function onOpenFolder() {
@@ -877,7 +1009,7 @@ function getLatestEditorContent(contentOverride?: string) {
return markdownEditorRef.value?.flushPendingChanges() ?? content.value;
}
async function doSave(contentOverride?: string) {
async function doSave(contentOverride?: string, options: { syncAfterSave?: boolean } = {}) {
if (!isMarkdownDocument.value) return false;
const contentToSave = getLatestEditorContent(contentOverride);
@@ -901,6 +1033,9 @@ async function doSave(contentOverride?: string) {
saveStatus.value = "";
}, 3000);
hasDraftDocument.value = false;
if (options.syncAfterSave !== false) {
void runConfiguredSync("save");
}
return true;
} catch (e) {
console.error("Save failed:", e);
@@ -923,6 +1058,8 @@ async function doSaveAs() {
setTimeout(() => {
saveStatus.value = "";
}, 3000);
hasDraftDocument.value = false;
void runConfiguredSync("save");
} catch (e) {
console.error("Save failed:", e);
saveStatus.value = `保存失败: ${e}`;
@@ -1023,6 +1160,8 @@ async function handleSearchResultClick(filePath: string, lineNumber: number) {
@search-result-click="handleSearchResultClick"
@update-default-document-mode="updateDefaultDocumentMode"
@update-app-theme="updateAppTheme"
@prepare-workspace-sync="prepareWorkspaceSync"
@workspace-synced="handleWorkspaceSynced"
/>
<!-- Right: Editor area -->

View File

@@ -5,7 +5,7 @@ import SettingsModal from "./SettingsModal.vue";
import TreeEntry from "./TreeEntry.vue";
import PromptDialog from "./PromptDialog.vue";
type SettingsPage = "about" | "editor";
type SettingsPage = "about" | "editor" | "sync";
type DocumentMode = "preview" | "edit" | "mixed";
type AppTheme = "system" | "warm" | "white" | "black" | "gruvbox" | "gray";
@@ -55,9 +55,15 @@ const emit = defineEmits<{
renameEntry: [path: string, newName: string];
updateDefaultDocumentMode: [mode: DocumentMode];
updateAppTheme: [theme: AppTheme];
prepareWorkspaceSync: [done: (saved: boolean) => void];
workspaceSynced: [];
"search-result-click": [filePath: string, lineNumber: number];
}>();
function forwardPrepareWorkspaceSync(done: (saved: boolean) => void) {
emit("prepareWorkspaceSync", done);
}
// ---- sidebar view mode ----
type ViewMode = "folder" | "search" | "setting";
const activeView = ref<ViewMode>("folder");
@@ -833,10 +839,13 @@ function handleCreateRootFile() {
<SettingsModal
v-model:show="showSettings"
:initial-page="settingsPage"
:workspace-path="folderPath"
:default-document-mode="defaultDocumentMode"
:app-theme="appTheme"
@update-default-document-mode="(mode: DocumentMode) => emit('updateDefaultDocumentMode', mode)"
@update-app-theme="(theme: AppTheme) => emit('updateAppTheme', theme)"
@prepare-workspace-sync="forwardPrepareWorkspaceSync"
@workspace-synced="emit('workspaceSynced')"
/>
<PromptDialog

View File

@@ -1,20 +1,64 @@
<script setup lang="ts">
import { getVersion } from "@tauri-apps/api/app";
import { invoke } from "@tauri-apps/api/core";
import { computed, onBeforeUnmount, ref, watch } from "vue";
type SettingsPage = "about" | "editor" | "appearance";
type SettingsPage = "about" | "editor" | "appearance" | "sync";
type DocumentMode = "preview" | "edit" | "mixed";
type AppTheme = "system" | "warm" | "white" | "black" | "gruvbox" | "gray";
type SyncProvider = "none" | "webdav" | "s3";
interface SyncConfig {
provider: SyncProvider;
webdav: {
endpoint: string;
username: string;
password: string;
remoteDir: string;
};
s3: {
endpoint: string;
region: string;
bucket: string;
accessKeyId: string;
secretAccessKey: string;
remotePrefix: string;
pathStyle: boolean;
useHttps: boolean;
};
autoSyncOnStartup: boolean;
autoSyncAfterSave: boolean;
lastSyncAt: string | null;
}
interface SyncResult {
message: string;
lastSyncAt: string;
stats: {
uploaded: number;
downloaded: number;
deletedLocal: number;
deletedRemote: number;
skipped: number;
};
}
interface SyncConnectionTestResult {
message: string;
details: string[];
}
const props = withDefaults(
defineProps<{
show: boolean;
initialPage?: SettingsPage;
workspacePath?: string;
defaultDocumentMode?: DocumentMode;
appTheme?: AppTheme;
}>(),
{
initialPage: "about",
workspacePath: "",
defaultDocumentMode: "edit",
appTheme: "warm",
},
@@ -24,6 +68,8 @@ const emit = defineEmits<{
"update:show": [value: boolean];
updateDefaultDocumentMode: [mode: DocumentMode];
updateAppTheme: [theme: AppTheme];
prepareWorkspaceSync: [done: (saved: boolean) => void];
workspaceSynced: [];
}>();
const APP_NAME = "yurou";
@@ -36,14 +82,23 @@ const appVersion = ref(FALLBACK_VERSION);
const autoUpdate = ref(loadBooleanPref(AUTO_UPDATE_KEY, true));
const language = ref(loadStringPref(LANGUAGE_KEY, "zh-Hans"));
const updateStatus = ref("");
const syncConfig = ref<SyncConfig>(createDefaultSyncConfig());
const syncStatus = ref("");
const syncError = ref("");
const syncSaving = ref(false);
const syncTesting = ref(false);
const syncRunning = ref(false);
let updateStatusTimer: ReturnType<typeof setTimeout> | null = null;
const pageTitle = computed(() => {
if (activePage.value === "about") return "关于";
if (activePage.value === "editor") return "编辑器";
if (activePage.value === "sync") return "同步";
return "外观";
});
const isSyncBusy = computed(() => syncSaving.value || syncTesting.value || syncRunning.value);
const menuItems: Array<{
id: SettingsPage;
label: string;
@@ -52,6 +107,7 @@ const menuItems: Array<{
{ id: "about", label: "关于", icon: "ri-information-line" },
{ id: "editor", label: "编辑器", icon: "ri-edit-2-line" },
{ id: "appearance", label: "外观", icon: "ri-palette-line" },
{ id: "sync", label: "同步", icon: "ri-loop-left-line" },
];
const documentModeOptions: Array<{
@@ -103,6 +159,49 @@ function savePref(key: string, value: string) {
}
}
function createDefaultSyncConfig(): SyncConfig {
return {
provider: "none",
webdav: {
endpoint: "",
username: "",
password: "",
remoteDir: "/yurou",
},
s3: {
endpoint: "",
region: "us-east-1",
bucket: "",
accessKeyId: "",
secretAccessKey: "",
remotePrefix: "yurou",
pathStyle: true,
useHttps: true,
},
autoSyncOnStartup: false,
autoSyncAfterSave: false,
lastSyncAt: null,
};
}
function mergeSyncConfig(config: Partial<SyncConfig> | null | undefined): SyncConfig {
const defaults = createDefaultSyncConfig();
if (!config) return defaults;
return {
...defaults,
...config,
webdav: {
...defaults.webdav,
...(config.webdav || {}),
},
s3: {
...defaults.s3,
...(config.s3 || {}),
},
};
}
function close() {
emit("update:show", false);
}
@@ -149,6 +248,87 @@ function resetTheme() {
emit("updateAppTheme", "warm");
}
function setSyncStatus(message: string, isError = false) {
if (isError) {
syncError.value = message;
syncStatus.value = "";
} else {
syncStatus.value = message;
syncError.value = "";
}
}
async function loadSyncConfig() {
try {
const config = await invoke<SyncConfig>("load_sync_config");
syncConfig.value = mergeSyncConfig(config);
syncError.value = "";
} catch (error) {
setSyncStatus(`读取同步配置失败: ${error}`, true);
}
}
async function saveSyncConfig() {
syncSaving.value = true;
try {
await invoke("save_sync_config", { config: syncConfig.value });
setSyncStatus("同步配置已保存");
} catch (error) {
setSyncStatus(`保存失败: ${error}`, true);
} finally {
syncSaving.value = false;
}
}
async function testSyncConnection() {
syncTesting.value = true;
try {
const result = await invoke<SyncConnectionTestResult>("test_sync_connection", { config: syncConfig.value });
setSyncStatus([result.message, ...result.details.map((item) => `- ${item}`)].join("\n"));
} catch (error) {
setSyncStatus(String(error), true);
} finally {
syncTesting.value = false;
}
}
function formatSyncResult(result: SyncResult) {
const { stats } = result;
return `同步完成:上传 ${stats.uploaded},下载 ${stats.downloaded},删除本地 ${stats.deletedLocal},删除远端 ${stats.deletedRemote},跳过 ${stats.skipped}`;
}
async function runSyncNow() {
if (!props.workspacePath) {
setSyncStatus("请先打开一个工作目录", true);
return;
}
syncRunning.value = true;
try {
setSyncStatus("正在同步:扫描本地和远端文件,远端列表请求最长等待 120 秒。");
await invoke("save_sync_config", { config: syncConfig.value });
const prepared = await new Promise<boolean>((resolve) => {
emit("prepareWorkspaceSync", resolve);
});
if (!prepared) {
setSyncStatus("同步已取消,当前文档尚未保存", true);
return;
}
const result = await invoke<SyncResult>("sync_now", {
workspace: props.workspacePath,
config: syncConfig.value,
});
syncConfig.value.lastSyncAt = result.lastSyncAt;
setSyncStatus(formatSyncResult(result));
emit("workspaceSynced");
} catch (error) {
setSyncStatus(`同步失败: ${error}`, true);
} finally {
syncRunning.value = false;
}
}
watch(
() => props.show,
(show) => {
@@ -156,6 +336,7 @@ watch(
activePage.value = props.initialPage;
updateStatus.value = "";
void loadAppVersion();
void loadSyncConfig();
},
);
@@ -185,7 +366,6 @@ onBeforeUnmount(() => {
v-if="show"
class="settings-overlay"
role="presentation"
@click.self="close"
@keydown="onOverlayKeydown"
>
<section
@@ -298,6 +478,214 @@ onBeforeUnmount(() => {
</section>
</div>
<div v-else-if="activePage === 'sync'" class="settings-content settings-sync-content">
<section class="settings-about-card" aria-label="同步设置">
<div class="settings-row settings-row-top">
<div class="settings-copy">
<h3>同步方式</h3>
<p>同步当前工作目录中的所有文件包括隐藏文件</p>
</div>
<select v-model="syncConfig.provider" class="settings-select" aria-label="同步方式">
<option value="none">关闭同步</option>
<option value="webdav">WebDAV</option>
<option value="s3">S3 兼容</option>
</select>
</div>
<template v-if="syncConfig.provider === 'webdav'">
<div class="settings-form-row">
<label class="settings-field">
<span>服务器地址</span>
<input
v-model="syncConfig.webdav.endpoint"
class="settings-input"
type="url"
placeholder="https://example.com/dav"
/>
</label>
<label class="settings-field">
<span>远端目录</span>
<input
v-model="syncConfig.webdav.remoteDir"
class="settings-input"
type="text"
placeholder="/yurou"
/>
</label>
</div>
<div class="settings-form-row">
<label class="settings-field">
<span>用户名</span>
<input
v-model="syncConfig.webdav.username"
class="settings-input"
type="text"
autocomplete="username"
/>
</label>
<label class="settings-field">
<span>密码或应用密码</span>
<input
v-model="syncConfig.webdav.password"
class="settings-input"
type="password"
autocomplete="current-password"
/>
</label>
</div>
</template>
<template v-else-if="syncConfig.provider === 's3'">
<div class="settings-form-row">
<label class="settings-field">
<span>Endpoint</span>
<input
v-model="syncConfig.s3.endpoint"
class="settings-input"
type="text"
placeholder="https://s3.example.com"
/>
</label>
<label class="settings-field">
<span>Region</span>
<input
v-model="syncConfig.s3.region"
class="settings-input"
type="text"
placeholder="us-east-1"
/>
</label>
</div>
<div class="settings-form-row">
<label class="settings-field">
<span>Bucket</span>
<input
v-model="syncConfig.s3.bucket"
class="settings-input"
type="text"
placeholder="notes"
/>
</label>
<label class="settings-field">
<span>远端前缀</span>
<input
v-model="syncConfig.s3.remotePrefix"
class="settings-input"
type="text"
placeholder="yurou"
/>
</label>
</div>
<div class="settings-form-row">
<label class="settings-field">
<span>Access Key ID</span>
<input
v-model="syncConfig.s3.accessKeyId"
class="settings-input"
type="text"
autocomplete="username"
/>
</label>
<label class="settings-field">
<span>Secret Access Key</span>
<input
v-model="syncConfig.s3.secretAccessKey"
class="settings-input"
type="password"
autocomplete="current-password"
/>
</label>
</div>
<div class="settings-row">
<div class="settings-copy">
<h3>Path style</h3>
<p>MinIO部分私有云和兼容 S3 服务通常需要开启</p>
</div>
<label class="settings-switch" title="Path style">
<input v-model="syncConfig.s3.pathStyle" type="checkbox" />
<span></span>
</label>
</div>
<div class="settings-row">
<div class="settings-copy">
<h3>使用 HTTPS</h3>
<p> Endpoint 未填写协议时用这个选项决定 http https</p>
</div>
<label class="settings-switch" title="使用 HTTPS">
<input v-model="syncConfig.s3.useHttps" type="checkbox" />
<span></span>
</label>
</div>
</template>
<div class="settings-row">
<div class="settings-copy">
<h3>同步策略</h3>
<p>启动时和保存后可自动同步冲突时以最新修改为准</p>
</div>
<div class="settings-switch-group">
<label class="settings-switch-line">
<span>启动时</span>
<span class="settings-switch">
<input v-model="syncConfig.autoSyncOnStartup" type="checkbox" />
<span></span>
</span>
</label>
<label class="settings-switch-line">
<span>保存后</span>
<span class="settings-switch">
<input v-model="syncConfig.autoSyncAfterSave" type="checkbox" />
<span></span>
</span>
</label>
</div>
</div>
<div class="settings-row settings-sync-actions">
<div class="settings-copy">
<h3>立即同步</h3>
<p>
{{ syncConfig.lastSyncAt ? `上次同步:${syncConfig.lastSyncAt}` : '尚未同步' }}
</p>
<div
v-if="syncStatus || syncError"
class="settings-sync-log"
:class="{ 'settings-sync-log-error': Boolean(syncError) }"
role="status"
>
{{ syncError || syncStatus }}
</div>
</div>
<div class="settings-action-stack">
<button
class="settings-secondary-btn"
type="button"
:disabled="isSyncBusy"
@click="saveSyncConfig"
>
{{ syncSaving ? '保存中' : '保存配置' }}
</button>
<button
class="settings-secondary-btn"
type="button"
:disabled="isSyncBusy || syncConfig.provider === 'none'"
@click="testSyncConnection"
>
{{ syncTesting ? '测试中' : '测试连接' }}
</button>
<button
class="settings-primary-btn"
type="button"
:disabled="isSyncBusy || syncConfig.provider === 'none' || !workspacePath"
@click="runSyncNow"
>
{{ syncRunning ? '同步中' : '立即同步' }}
</button>
</div>
</div>
</section>
</div>
<div v-else class="settings-content">
<section class="settings-about-card" aria-label="外观设置">
<div class="settings-row settings-row-top">
@@ -372,7 +760,9 @@ onBeforeUnmount(() => {
.settings-shell {
display: grid;
grid-template-columns: 176px minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr);
width: min(820px, calc(100vw - 48px));
height: min(680px, calc(100vh - 48px));
min-height: 430px;
max-height: min(680px, calc(100vh - 48px));
overflow: hidden;
@@ -433,6 +823,7 @@ onBeforeUnmount(() => {
.settings-main {
display: flex;
min-height: 0;
min-width: 0;
flex-direction: column;
background: var(--app-panel-bg);
@@ -480,9 +871,14 @@ onBeforeUnmount(() => {
min-height: 0;
flex: 1;
overflow-y: auto;
overscroll-behavior: contain;
padding: 22px;
}
.settings-sync-content {
scrollbar-gutter: stable;
}
.settings-about-card {
overflow: hidden;
border-radius: 10px;
@@ -592,6 +988,21 @@ onBeforeUnmount(() => {
background: var(--app-surface-muted);
}
.settings-primary-btn:disabled,
.settings-secondary-btn:disabled {
cursor: default;
opacity: 0.56;
box-shadow: none;
}
.settings-primary-btn:disabled:hover {
background: var(--app-control);
}
.settings-secondary-btn:disabled:hover {
background: var(--app-surface);
}
.settings-status {
color: var(--app-muted);
font-size: 11px;
@@ -665,6 +1076,95 @@ onBeforeUnmount(() => {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--app-accent) 12%, transparent);
}
.settings-form-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
padding: 16px 20px;
border-top: 1px solid var(--app-border-strong);
}
.settings-field {
display: flex;
min-width: 0;
flex-direction: column;
gap: 7px;
}
.settings-field > span {
color: var(--app-text-soft);
font-size: 12px;
font-weight: 600;
line-height: 1.2;
}
.settings-input {
width: 100%;
min-width: 0;
height: 32px;
border: 1px solid var(--app-border-strong);
border-radius: 6px;
background: var(--app-surface);
color: var(--app-text);
font-size: 13px;
line-height: 1;
padding: 0 10px;
box-shadow: 0 1px 4px color-mix(in srgb, var(--app-shadow) 48%, transparent);
outline: none;
}
.settings-input::placeholder {
color: var(--app-subtle);
}
.settings-input:focus {
border-color: var(--app-accent-muted);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--app-accent) 12%, transparent);
}
.settings-switch-group {
display: flex;
min-width: 164px;
flex-direction: column;
gap: 10px;
}
.settings-switch-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
color: var(--app-text-soft);
font-size: 12px;
line-height: 1;
}
.settings-sync-actions {
align-items: flex-start;
}
.settings-sync-log {
width: min(100%, 420px);
max-height: 138px;
margin-top: 10px;
overflow: auto;
border: 1px solid color-mix(in srgb, var(--app-accent) 26%, var(--app-border));
border-radius: 6px;
background: color-mix(in srgb, var(--app-accent) 7%, var(--app-surface));
color: var(--app-accent);
font-size: 12px;
line-height: 1.5;
padding: 9px 10px;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.settings-sync-log-error {
border-color: color-mix(in srgb, #dc2626 32%, var(--app-border));
background: color-mix(in srgb, #dc2626 7%, var(--app-surface));
color: #dc2626;
}
.theme-swatch-group {
display: inline-flex;
align-items: center;
@@ -727,8 +1227,11 @@ onBeforeUnmount(() => {
.settings-shell {
grid-template-columns: 1fr;
grid-template-rows: auto minmax(0, 1fr);
width: 100%;
max-height: none;
height: calc(100vh - 28px);
max-height: calc(100vh - 28px);
min-height: 0;
}
.settings-menu {
@@ -751,8 +1254,16 @@ onBeforeUnmount(() => {
gap: 12px;
}
.settings-form-row {
grid-template-columns: 1fr;
}
.settings-action-stack {
align-items: flex-start;
}
.settings-switch-group {
min-width: 0;
}
}
</style>