完成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"
|
||||
notify = "8.2.0"
|
||||
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/
|
||||
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 ----
|
||||
|
||||
149
src/App.vue
149
src/App.vue
@@ -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 -->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user