完成webdav同步功能
This commit is contained in:
1352
src-tauri/Cargo.lock
generated
1352
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -27,4 +27,5 @@ serde_json = "1"
|
|||||||
time = ">=0.3.0, <0.3.47"
|
time = ">=0.3.0, <0.3.47"
|
||||||
notify = "8.2.0"
|
notify = "8.2.0"
|
||||||
regex = "1"
|
regex = "1"
|
||||||
|
opendal = { version = "0.57.0", features = ["services-s3", "services-webdav"] }
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread", "time"] }
|
||||||
|
|||||||
@@ -1,14 +1,26 @@
|
|||||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||||
use notify::{RecursiveMode, Watcher};
|
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::fs;
|
||||||
use std::path::PathBuf;
|
use std::hash::{Hash, Hasher};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
use tauri::menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder};
|
use tauri::menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder};
|
||||||
use tauri::{Emitter, Manager};
|
use tauri::{Emitter, Manager};
|
||||||
use tauri_plugin_dialog::DialogExt;
|
use tauri_plugin_dialog::DialogExt;
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use regex::Regex;
|
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)]
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
struct DirEntry {
|
struct DirEntry {
|
||||||
name: String,
|
name: String,
|
||||||
@@ -455,6 +467,845 @@ fn walk_dir(
|
|||||||
Ok(())
|
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]
|
#[tauri::command]
|
||||||
fn greet(name: &str) -> String {
|
fn greet(name: &str) -> String {
|
||||||
format!("Hello, {}! You've been greeted from Rust!", name)
|
format!("Hello, {}! You've been greeted from Rust!", name)
|
||||||
@@ -480,7 +1331,11 @@ pub fn run() {
|
|||||||
delete_entry,
|
delete_entry,
|
||||||
rename_entry,
|
rename_entry,
|
||||||
save_media_file,
|
save_media_file,
|
||||||
search_files
|
search_files,
|
||||||
|
load_sync_config,
|
||||||
|
save_sync_config,
|
||||||
|
test_sync_connection,
|
||||||
|
sync_now
|
||||||
])
|
])
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
// ---- File menu ----
|
// ---- File menu ----
|
||||||
|
|||||||
149
src/App.vue
149
src/App.vue
@@ -14,6 +14,7 @@ const charCount = ref(0);
|
|||||||
type DocumentMode = "preview" | "edit" | "mixed";
|
type DocumentMode = "preview" | "edit" | "mixed";
|
||||||
type AppTheme = "system" | "warm" | "white" | "black" | "gruvbox" | "gray";
|
type AppTheme = "system" | "warm" | "white" | "black" | "gruvbox" | "gray";
|
||||||
type WorkspaceFileType = "markdown" | "image" | "video";
|
type WorkspaceFileType = "markdown" | "image" | "video";
|
||||||
|
type SyncProvider = "none" | "webdav" | "s3";
|
||||||
const DEFAULT_DOCUMENT_MODE_KEY = "yurou:default-document-mode";
|
const DEFAULT_DOCUMENT_MODE_KEY = "yurou:default-document-mode";
|
||||||
const APP_THEME_KEY = "yurou:app-theme";
|
const APP_THEME_KEY = "yurou:app-theme";
|
||||||
const MARKDOWN_EXTENSIONS = new Set(["md"]);
|
const MARKDOWN_EXTENSIONS = new Set(["md"]);
|
||||||
@@ -104,6 +105,41 @@ interface WorkspaceWatchErrorPayload {
|
|||||||
message: string;
|
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 folderPath = ref("");
|
||||||
const dirEntries = ref<DirEntry[]>([]);
|
const dirEntries = ref<DirEntry[]>([]);
|
||||||
const LAST_WORKSPACE_PATH_KEY = "yurou:last-workspace-path";
|
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 workspaceRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let workspaceRefreshInFlight = false;
|
let workspaceRefreshInFlight = false;
|
||||||
let workspaceRefreshQueued = false;
|
let workspaceRefreshQueued = false;
|
||||||
|
let syncInFlight = false;
|
||||||
|
|
||||||
// ---- editor zoom (Ctrl+scroll) ----
|
// ---- editor zoom (Ctrl+scroll) ----
|
||||||
const ZOOM_MIN = 0.7;
|
const ZOOM_MIN = 0.7;
|
||||||
@@ -172,7 +209,7 @@ const documentMode = ref<DocumentMode>(defaultDocumentMode.value);
|
|||||||
const appTheme = ref<AppTheme>(loadAppTheme());
|
const appTheme = ref<AppTheme>(loadAppTheme());
|
||||||
const showMoreMenu = ref(false);
|
const showMoreMenu = ref(false);
|
||||||
const settingsRequest = ref(0);
|
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 markdownEditorRef = ref<{ flushPendingChanges: () => string | undefined; scrollToLine: (line: number) => void } | null>(null);
|
||||||
const directorySidebarRef = ref<{ activateSearch: () => void } | null>(null);
|
const directorySidebarRef = ref<{ activateSearch: () => void } | null>(null);
|
||||||
const isWindowMaximized = ref(false);
|
const isWindowMaximized = ref(false);
|
||||||
@@ -279,7 +316,7 @@ function onTitlebarDoubleClick(event: MouseEvent) {
|
|||||||
void toggleWindowMaximize();
|
void toggleWindowMaximize();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openSettings(page: "about" | "editor" = "about") {
|
function openSettings(page: "about" | "editor" | "sync" = "about") {
|
||||||
settingsInitialPage.value = page;
|
settingsInitialPage.value = page;
|
||||||
settingsRequest.value += 1;
|
settingsRequest.value += 1;
|
||||||
}
|
}
|
||||||
@@ -565,7 +602,7 @@ async function saveCurrentWorkspaceDocumentBeforeSwitch() {
|
|||||||
const contentToSave = getLatestEditorContent();
|
const contentToSave = getLatestEditorContent();
|
||||||
if (!currentFilePath.value && !contentToSave.trim()) return true;
|
if (!currentFilePath.value && !contentToSave.trim()) return true;
|
||||||
|
|
||||||
return doSave(contentToSave);
|
return doSave(contentToSave, { syncAfterSave: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetWorkspaceAndEditorState(options: { stopWatcher?: boolean } = {}) {
|
function resetWorkspaceAndEditorState(options: { stopWatcher?: boolean } = {}) {
|
||||||
@@ -631,14 +668,109 @@ async function switchWorkspace(path: string, options: { persist?: boolean; reset
|
|||||||
return true;
|
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() {
|
async function restoreLastWorkspace() {
|
||||||
const lastWorkspacePath = getLastWorkspacePath();
|
const lastWorkspacePath = getLastWorkspacePath();
|
||||||
if (!lastWorkspacePath) return;
|
if (!lastWorkspacePath) return;
|
||||||
|
|
||||||
await switchWorkspace(lastWorkspacePath, {
|
const restored = await switchWorkspace(lastWorkspacePath, {
|
||||||
persist: false,
|
persist: false,
|
||||||
resetEditor: true,
|
resetEditor: true,
|
||||||
});
|
});
|
||||||
|
if (restored) {
|
||||||
|
void runConfiguredSync("startup");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onOpenFolder() {
|
async function onOpenFolder() {
|
||||||
@@ -877,7 +1009,7 @@ function getLatestEditorContent(contentOverride?: string) {
|
|||||||
return markdownEditorRef.value?.flushPendingChanges() ?? content.value;
|
return markdownEditorRef.value?.flushPendingChanges() ?? content.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doSave(contentOverride?: string) {
|
async function doSave(contentOverride?: string, options: { syncAfterSave?: boolean } = {}) {
|
||||||
if (!isMarkdownDocument.value) return false;
|
if (!isMarkdownDocument.value) return false;
|
||||||
|
|
||||||
const contentToSave = getLatestEditorContent(contentOverride);
|
const contentToSave = getLatestEditorContent(contentOverride);
|
||||||
@@ -901,6 +1033,9 @@ async function doSave(contentOverride?: string) {
|
|||||||
saveStatus.value = "";
|
saveStatus.value = "";
|
||||||
}, 3000);
|
}, 3000);
|
||||||
hasDraftDocument.value = false;
|
hasDraftDocument.value = false;
|
||||||
|
if (options.syncAfterSave !== false) {
|
||||||
|
void runConfiguredSync("save");
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Save failed:", e);
|
console.error("Save failed:", e);
|
||||||
@@ -923,6 +1058,8 @@ async function doSaveAs() {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
saveStatus.value = "";
|
saveStatus.value = "";
|
||||||
}, 3000);
|
}, 3000);
|
||||||
|
hasDraftDocument.value = false;
|
||||||
|
void runConfiguredSync("save");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Save failed:", e);
|
console.error("Save failed:", e);
|
||||||
saveStatus.value = `保存失败: ${e}`;
|
saveStatus.value = `保存失败: ${e}`;
|
||||||
@@ -1023,6 +1160,8 @@ async function handleSearchResultClick(filePath: string, lineNumber: number) {
|
|||||||
@search-result-click="handleSearchResultClick"
|
@search-result-click="handleSearchResultClick"
|
||||||
@update-default-document-mode="updateDefaultDocumentMode"
|
@update-default-document-mode="updateDefaultDocumentMode"
|
||||||
@update-app-theme="updateAppTheme"
|
@update-app-theme="updateAppTheme"
|
||||||
|
@prepare-workspace-sync="prepareWorkspaceSync"
|
||||||
|
@workspace-synced="handleWorkspaceSynced"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Right: Editor area -->
|
<!-- Right: Editor area -->
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import SettingsModal from "./SettingsModal.vue";
|
|||||||
import TreeEntry from "./TreeEntry.vue";
|
import TreeEntry from "./TreeEntry.vue";
|
||||||
import PromptDialog from "./PromptDialog.vue";
|
import PromptDialog from "./PromptDialog.vue";
|
||||||
|
|
||||||
type SettingsPage = "about" | "editor";
|
type SettingsPage = "about" | "editor" | "sync";
|
||||||
type DocumentMode = "preview" | "edit" | "mixed";
|
type DocumentMode = "preview" | "edit" | "mixed";
|
||||||
type AppTheme = "system" | "warm" | "white" | "black" | "gruvbox" | "gray";
|
type AppTheme = "system" | "warm" | "white" | "black" | "gruvbox" | "gray";
|
||||||
|
|
||||||
@@ -55,9 +55,15 @@ const emit = defineEmits<{
|
|||||||
renameEntry: [path: string, newName: string];
|
renameEntry: [path: string, newName: string];
|
||||||
updateDefaultDocumentMode: [mode: DocumentMode];
|
updateDefaultDocumentMode: [mode: DocumentMode];
|
||||||
updateAppTheme: [theme: AppTheme];
|
updateAppTheme: [theme: AppTheme];
|
||||||
|
prepareWorkspaceSync: [done: (saved: boolean) => void];
|
||||||
|
workspaceSynced: [];
|
||||||
"search-result-click": [filePath: string, lineNumber: number];
|
"search-result-click": [filePath: string, lineNumber: number];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
function forwardPrepareWorkspaceSync(done: (saved: boolean) => void) {
|
||||||
|
emit("prepareWorkspaceSync", done);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- sidebar view mode ----
|
// ---- sidebar view mode ----
|
||||||
type ViewMode = "folder" | "search" | "setting";
|
type ViewMode = "folder" | "search" | "setting";
|
||||||
const activeView = ref<ViewMode>("folder");
|
const activeView = ref<ViewMode>("folder");
|
||||||
@@ -833,10 +839,13 @@ function handleCreateRootFile() {
|
|||||||
<SettingsModal
|
<SettingsModal
|
||||||
v-model:show="showSettings"
|
v-model:show="showSettings"
|
||||||
:initial-page="settingsPage"
|
:initial-page="settingsPage"
|
||||||
|
:workspace-path="folderPath"
|
||||||
:default-document-mode="defaultDocumentMode"
|
:default-document-mode="defaultDocumentMode"
|
||||||
:app-theme="appTheme"
|
:app-theme="appTheme"
|
||||||
@update-default-document-mode="(mode: DocumentMode) => emit('updateDefaultDocumentMode', mode)"
|
@update-default-document-mode="(mode: DocumentMode) => emit('updateDefaultDocumentMode', mode)"
|
||||||
@update-app-theme="(theme: AppTheme) => emit('updateAppTheme', theme)"
|
@update-app-theme="(theme: AppTheme) => emit('updateAppTheme', theme)"
|
||||||
|
@prepare-workspace-sync="forwardPrepareWorkspaceSync"
|
||||||
|
@workspace-synced="emit('workspaceSynced')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PromptDialog
|
<PromptDialog
|
||||||
|
|||||||
@@ -1,20 +1,64 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { getVersion } from "@tauri-apps/api/app";
|
import { getVersion } from "@tauri-apps/api/app";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
||||||
|
|
||||||
type SettingsPage = "about" | "editor" | "appearance";
|
type SettingsPage = "about" | "editor" | "appearance" | "sync";
|
||||||
type DocumentMode = "preview" | "edit" | "mixed";
|
type DocumentMode = "preview" | "edit" | "mixed";
|
||||||
type AppTheme = "system" | "warm" | "white" | "black" | "gruvbox" | "gray";
|
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(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
show: boolean;
|
show: boolean;
|
||||||
initialPage?: SettingsPage;
|
initialPage?: SettingsPage;
|
||||||
|
workspacePath?: string;
|
||||||
defaultDocumentMode?: DocumentMode;
|
defaultDocumentMode?: DocumentMode;
|
||||||
appTheme?: AppTheme;
|
appTheme?: AppTheme;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
initialPage: "about",
|
initialPage: "about",
|
||||||
|
workspacePath: "",
|
||||||
defaultDocumentMode: "edit",
|
defaultDocumentMode: "edit",
|
||||||
appTheme: "warm",
|
appTheme: "warm",
|
||||||
},
|
},
|
||||||
@@ -24,6 +68,8 @@ const emit = defineEmits<{
|
|||||||
"update:show": [value: boolean];
|
"update:show": [value: boolean];
|
||||||
updateDefaultDocumentMode: [mode: DocumentMode];
|
updateDefaultDocumentMode: [mode: DocumentMode];
|
||||||
updateAppTheme: [theme: AppTheme];
|
updateAppTheme: [theme: AppTheme];
|
||||||
|
prepareWorkspaceSync: [done: (saved: boolean) => void];
|
||||||
|
workspaceSynced: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const APP_NAME = "yurou";
|
const APP_NAME = "yurou";
|
||||||
@@ -36,14 +82,23 @@ const appVersion = ref(FALLBACK_VERSION);
|
|||||||
const autoUpdate = ref(loadBooleanPref(AUTO_UPDATE_KEY, true));
|
const autoUpdate = ref(loadBooleanPref(AUTO_UPDATE_KEY, true));
|
||||||
const language = ref(loadStringPref(LANGUAGE_KEY, "zh-Hans"));
|
const language = ref(loadStringPref(LANGUAGE_KEY, "zh-Hans"));
|
||||||
const updateStatus = ref("");
|
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;
|
let updateStatusTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
const pageTitle = computed(() => {
|
const pageTitle = computed(() => {
|
||||||
if (activePage.value === "about") return "关于";
|
if (activePage.value === "about") return "关于";
|
||||||
if (activePage.value === "editor") return "编辑器";
|
if (activePage.value === "editor") return "编辑器";
|
||||||
|
if (activePage.value === "sync") return "同步";
|
||||||
return "外观";
|
return "外观";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isSyncBusy = computed(() => syncSaving.value || syncTesting.value || syncRunning.value);
|
||||||
|
|
||||||
const menuItems: Array<{
|
const menuItems: Array<{
|
||||||
id: SettingsPage;
|
id: SettingsPage;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -52,6 +107,7 @@ const menuItems: Array<{
|
|||||||
{ id: "about", label: "关于", icon: "ri-information-line" },
|
{ id: "about", label: "关于", icon: "ri-information-line" },
|
||||||
{ id: "editor", label: "编辑器", icon: "ri-edit-2-line" },
|
{ id: "editor", label: "编辑器", icon: "ri-edit-2-line" },
|
||||||
{ id: "appearance", label: "外观", icon: "ri-palette-line" },
|
{ id: "appearance", label: "外观", icon: "ri-palette-line" },
|
||||||
|
{ id: "sync", label: "同步", icon: "ri-loop-left-line" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const documentModeOptions: Array<{
|
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() {
|
function close() {
|
||||||
emit("update:show", false);
|
emit("update:show", false);
|
||||||
}
|
}
|
||||||
@@ -149,6 +248,87 @@ function resetTheme() {
|
|||||||
emit("updateAppTheme", "warm");
|
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(
|
watch(
|
||||||
() => props.show,
|
() => props.show,
|
||||||
(show) => {
|
(show) => {
|
||||||
@@ -156,6 +336,7 @@ watch(
|
|||||||
activePage.value = props.initialPage;
|
activePage.value = props.initialPage;
|
||||||
updateStatus.value = "";
|
updateStatus.value = "";
|
||||||
void loadAppVersion();
|
void loadAppVersion();
|
||||||
|
void loadSyncConfig();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -185,7 +366,6 @@ onBeforeUnmount(() => {
|
|||||||
v-if="show"
|
v-if="show"
|
||||||
class="settings-overlay"
|
class="settings-overlay"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
@click.self="close"
|
|
||||||
@keydown="onOverlayKeydown"
|
@keydown="onOverlayKeydown"
|
||||||
>
|
>
|
||||||
<section
|
<section
|
||||||
@@ -298,6 +478,214 @@ onBeforeUnmount(() => {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</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">
|
<div v-else class="settings-content">
|
||||||
<section class="settings-about-card" aria-label="外观设置">
|
<section class="settings-about-card" aria-label="外观设置">
|
||||||
<div class="settings-row settings-row-top">
|
<div class="settings-row settings-row-top">
|
||||||
@@ -372,7 +760,9 @@ onBeforeUnmount(() => {
|
|||||||
.settings-shell {
|
.settings-shell {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 176px minmax(0, 1fr);
|
grid-template-columns: 176px minmax(0, 1fr);
|
||||||
|
grid-template-rows: minmax(0, 1fr);
|
||||||
width: min(820px, calc(100vw - 48px));
|
width: min(820px, calc(100vw - 48px));
|
||||||
|
height: min(680px, calc(100vh - 48px));
|
||||||
min-height: 430px;
|
min-height: 430px;
|
||||||
max-height: min(680px, calc(100vh - 48px));
|
max-height: min(680px, calc(100vh - 48px));
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -433,6 +823,7 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
.settings-main {
|
.settings-main {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: var(--app-panel-bg);
|
background: var(--app-panel-bg);
|
||||||
@@ -480,9 +871,14 @@ onBeforeUnmount(() => {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
padding: 22px;
|
padding: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-sync-content {
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-about-card {
|
.settings-about-card {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@@ -592,6 +988,21 @@ onBeforeUnmount(() => {
|
|||||||
background: var(--app-surface-muted);
|
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 {
|
.settings-status {
|
||||||
color: var(--app-muted);
|
color: var(--app-muted);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -665,6 +1076,95 @@ onBeforeUnmount(() => {
|
|||||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--app-accent) 12%, transparent);
|
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 {
|
.theme-swatch-group {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -727,8 +1227,11 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
.settings-shell {
|
.settings-shell {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: none;
|
height: calc(100vh - 28px);
|
||||||
|
max-height: calc(100vh - 28px);
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-menu {
|
.settings-menu {
|
||||||
@@ -751,8 +1254,16 @@ onBeforeUnmount(() => {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-form-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-action-stack {
|
.settings-action-stack {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-switch-group {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user