feat(git): 工作区 Git 同步——侧栏同步条、远程设置、定时同步(提交→变基拉取→推送,冲突停下不覆盖)(docs/27)
This commit is contained in:
53
docs/27-Git同步.md
Normal file
53
docs/27-Git同步.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# docs/27 — 工作区 Git 同步
|
||||||
|
|
||||||
|
需求(2026-11-14):左侧面板给工作区文件夹加 Git 同步:可设置远程
|
||||||
|
仓库、定时同步时间,手动/自动把文档变更同步到远程。
|
||||||
|
|
||||||
|
## 设计
|
||||||
|
|
||||||
|
- 只接管「工作区根目录本身就是仓库根」的文件夹:在父仓库的子目录里
|
||||||
|
不启用,避免误提交别的仓库(Rust 侧用 rev-parse --show-toplevel
|
||||||
|
与根目录比对)。
|
||||||
|
- 同步语义固定一条链:fetch → 提交本地更改(add -A)→ 变基到
|
||||||
|
origin/<分支> 之上 → push(无上游时 push -u)。先提交后变基,
|
||||||
|
避免脏工作区挡住 pull。变基冲突时自动 rebase --abort 还原到同步
|
||||||
|
前状态并报错停下,不留半成品、不强行覆盖,等用户手动处理。
|
||||||
|
- git 身份缺失(未配 user.email)时提交自动附带
|
||||||
|
-c user.name=new-markdown -c user.email=new-markdown@localhost,
|
||||||
|
不让同步卡在身份配置上。
|
||||||
|
- 凭据:走系统 git 与其凭据管理器;GIT_TERMINAL_PROMPT=0 禁止终端
|
||||||
|
交互提问,认证失败直接把错误显示在同步条上。Windows 下子进程加
|
||||||
|
CREATE_NO_WINDOW 防闪黑窗。
|
||||||
|
- 实现走 git CLI(Rust run_git 封装),不引 libgit2。
|
||||||
|
|
||||||
|
## 界面
|
||||||
|
|
||||||
|
- 侧栏文件模式顶部新增「Git 同步」条:状态一句(分支 · 待提交 N 处 ·
|
||||||
|
待推送/待拉取 · 已同步 / 未设置远程 / 未启用),右侧立即同步与
|
||||||
|
设置按钮;同步结果/错误显示在条下方一行,不弹窗打扰。
|
||||||
|
- 设置弹窗:远程仓库地址、(未初始化时)初始分支、定时同步间隔
|
||||||
|
(自绘下拉:关闭/5/15/30/每小时)。未初始化时保存= git init -b
|
||||||
|
分支 + 绑定 origin;已是仓库时只改 origin 与间隔。设置按工作区
|
||||||
|
存偏好 gitsync:<root>。
|
||||||
|
- 定时同步:打开工作区后 5 秒先跑一次,之后按间隔跑;同一工作区
|
||||||
|
多窗口时用 localStorage 租约(25 秒过期、10 秒续租,与草稿认领
|
||||||
|
同一思路)保证只有一个窗口执行,避免 index.lock 冲突;手动同步
|
||||||
|
不受租约限制。定时同步拉到新文件后自动刷新文件树。
|
||||||
|
|
||||||
|
## 代码
|
||||||
|
|
||||||
|
- Rust:commands.rs 新增 git_status / git_init_repo / git_set_remote
|
||||||
|
/ git_sync_now(28 条命令与 lib.rs 注册数对账一致)。
|
||||||
|
- 前端:services/gitSync.ts(设置解析、状态文案、提交信息、租约)、
|
||||||
|
components/GitSyncModal.tsx、Sidebar 的 GitSyncBar、App 状态与
|
||||||
|
定时器接线(设置弹窗纳入 anyModal)。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- vitest 117+2(新增 tests/git-sync.test.tsx 9 例:设置解析、状态
|
||||||
|
文案、提交信息、租约互斥、同步条渲染/点击);tsc、构建通过。
|
||||||
|
- 同步条渲染截图核验(真实 CSS,两态)。
|
||||||
|
- 沙箱无 cargo,Rust 与真实 git 流程未编译/未实跑;真机验收重点:
|
||||||
|
①对 Gitea 仓库初始化并绑定;②改文档后手动同步,远程出现
|
||||||
|
「手动同步」提交;③另一处改同一文件后同步报冲突且本地不乱;
|
||||||
|
④开定时后到点自动同步;⑤双窗口开同一工作区时只有一个在跑。
|
||||||
@@ -625,3 +625,216 @@ pub async fn workspace_search(
|
|||||||
.await
|
.await
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Git 同步(工作区文件夹) ----------
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct GitStatusInfo {
|
||||||
|
pub is_repo: bool,
|
||||||
|
pub branch: Option<String>,
|
||||||
|
pub remote_url: Option<String>,
|
||||||
|
pub dirty_count: u32,
|
||||||
|
pub ahead: u32,
|
||||||
|
pub behind: u32,
|
||||||
|
pub has_upstream: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct GitSyncOutcome {
|
||||||
|
pub committed: bool,
|
||||||
|
pub pushed: bool,
|
||||||
|
pub pulled: bool,
|
||||||
|
pub summary: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_git(dir: &Path, args: &[&str]) -> Result<String, String> {
|
||||||
|
let mut cmd = std::process::Command::new("git");
|
||||||
|
cmd.args(args)
|
||||||
|
.current_dir(dir)
|
||||||
|
.env("GIT_TERMINAL_PROMPT", "0");
|
||||||
|
// GUI 进程调控制台程序会闪黑窗,Windows 下隐藏
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
cmd.creation_flags(0x0800_0000);
|
||||||
|
}
|
||||||
|
let out = cmd
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("无法运行 git(请确认已安装 Git):{e}"))?;
|
||||||
|
if out.status.success() {
|
||||||
|
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
|
||||||
|
} else {
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||||
|
if !stderr.is_empty() {
|
||||||
|
return Err(stderr);
|
||||||
|
}
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||||
|
if !stdout.is_empty() {
|
||||||
|
return Err(stdout);
|
||||||
|
}
|
||||||
|
Err("git 命令执行失败".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn git_status_inner(root: &str) -> GitStatusInfo {
|
||||||
|
let empty = GitStatusInfo {
|
||||||
|
is_repo: false,
|
||||||
|
branch: None,
|
||||||
|
remote_url: None,
|
||||||
|
dirty_count: 0,
|
||||||
|
ahead: 0,
|
||||||
|
behind: 0,
|
||||||
|
has_upstream: false,
|
||||||
|
};
|
||||||
|
let dir = Path::new(root);
|
||||||
|
if run_git(dir, &["rev-parse", "--is-inside-work-tree"]).ok().as_deref() != Some("true") {
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
// 工作区必须是仓库根:在父仓库的子目录里不接管,避免误提交别的仓库
|
||||||
|
if let Ok(top) = run_git(dir, &["rev-parse", "--show-toplevel"]) {
|
||||||
|
let norm = |p: &str| p.replace('\\', "/").trim_end_matches('/').to_lowercase();
|
||||||
|
if norm(&top) != norm(root) {
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
let branch = run_git(dir, &["branch", "--show-current"]).ok().filter(|b| !b.is_empty());
|
||||||
|
let remote_url = run_git(dir, &["remote", "get-url", "origin"]).ok().filter(|u| !u.is_empty());
|
||||||
|
let dirty_count = run_git(dir, &["status", "--porcelain"])
|
||||||
|
.map(|s| s.lines().filter(|l| !l.trim().is_empty()).count() as u32)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let mut ahead = 0u32;
|
||||||
|
let mut behind = 0u32;
|
||||||
|
let has_upstream = run_git(dir, &["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]).is_ok();
|
||||||
|
if has_upstream {
|
||||||
|
if let Ok(counts) = run_git(dir, &["rev-list", "--left-right", "--count", "@{upstream}...HEAD"]) {
|
||||||
|
let mut it = counts.split_whitespace();
|
||||||
|
behind = it.next().and_then(|v| v.parse().ok()).unwrap_or(0);
|
||||||
|
ahead = it.next().and_then(|v| v.parse().ok()).unwrap_or(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GitStatusInfo { is_repo: true, branch, remote_url, dirty_count, ahead, behind, has_upstream }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn git_status(root: String) -> GitStatusInfo {
|
||||||
|
tokio::task::spawn_blocking(move || git_status_inner(&root))
|
||||||
|
.await
|
||||||
|
.unwrap_or(GitStatusInfo {
|
||||||
|
is_repo: false,
|
||||||
|
branch: None,
|
||||||
|
remote_url: None,
|
||||||
|
dirty_count: 0,
|
||||||
|
ahead: 0,
|
||||||
|
behind: 0,
|
||||||
|
has_upstream: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn git_init_repo(root: String, remote_url: String, branch: String) -> Result<GitStatusInfo, String> {
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let dir = Path::new(&root);
|
||||||
|
let b = if branch.trim().is_empty() { "main" } else { branch.trim() };
|
||||||
|
run_git(dir, &["init", "-b", b])?;
|
||||||
|
if !remote_url.trim().is_empty() {
|
||||||
|
run_git(dir, &["remote", "add", "origin", remote_url.trim()])?;
|
||||||
|
}
|
||||||
|
Ok(git_status_inner(&root))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn git_set_remote(root: String, remote_url: String) -> Result<GitStatusInfo, String> {
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let dir = Path::new(&root);
|
||||||
|
let url = remote_url.trim();
|
||||||
|
let has_origin = run_git(dir, &["remote"]).map(|r| r.lines().any(|l| l.trim() == "origin")).unwrap_or(false);
|
||||||
|
if url.is_empty() {
|
||||||
|
if has_origin {
|
||||||
|
run_git(dir, &["remote", "remove", "origin"])?;
|
||||||
|
}
|
||||||
|
} else if has_origin {
|
||||||
|
run_git(dir, &["remote", "set-url", "origin", url])?;
|
||||||
|
} else {
|
||||||
|
run_git(dir, &["remote", "add", "origin", url])?;
|
||||||
|
}
|
||||||
|
Ok(git_status_inner(&root))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn git_sync_now(root: String, message: String) -> Result<GitSyncOutcome, String> {
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let dir = Path::new(&root);
|
||||||
|
let st = git_status_inner(&root);
|
||||||
|
if !st.is_repo {
|
||||||
|
return Err("该文件夹不是 Git 仓库".to_string());
|
||||||
|
}
|
||||||
|
let has_remote = st.remote_url.is_some();
|
||||||
|
if has_remote {
|
||||||
|
run_git(dir, &["fetch", "origin"]).map_err(|e| format!("拉取远程信息失败:{e}"))?;
|
||||||
|
}
|
||||||
|
// 先提交本地更改,再变基到远程之上,最后推送——避免脏工作区挡住 pull
|
||||||
|
let mut committed = false;
|
||||||
|
if st.dirty_count > 0 {
|
||||||
|
run_git(dir, &["add", "-A"])?;
|
||||||
|
let mut args: Vec<&str> = Vec::new();
|
||||||
|
let email_missing = run_git(dir, &["config", "user.email"]).map(|v| v.is_empty()).unwrap_or(true);
|
||||||
|
if email_missing {
|
||||||
|
args.extend(["-c", "user.name=new-markdown", "-c", "user.email=new-markdown@localhost"]);
|
||||||
|
}
|
||||||
|
args.extend(["commit", "-m", message.as_str()]);
|
||||||
|
run_git(dir, &args).map_err(|e| format!("提交失败:{e}"))?;
|
||||||
|
committed = true;
|
||||||
|
}
|
||||||
|
let branch = st.branch.clone().unwrap_or_else(|| "main".to_string());
|
||||||
|
let mut pulled = false;
|
||||||
|
if has_remote {
|
||||||
|
let remote_ref = format!("origin/{branch}");
|
||||||
|
let remote_exists = run_git(dir, &["rev-parse", "--verify", &remote_ref]).is_ok();
|
||||||
|
if remote_exists {
|
||||||
|
let before = run_git(dir, &["rev-parse", "HEAD"]).unwrap_or_default();
|
||||||
|
if let Err(e) = run_git(dir, &["rebase", &remote_ref]) {
|
||||||
|
let _ = run_git(dir, &["rebase", "--abort"]);
|
||||||
|
if e.contains("CONFLICT") || e.contains("could not apply") || e.contains("Cannot rebase") {
|
||||||
|
return Err("同步冲突:本地与远程修改了相同内容,已取消本次同步,请在该文件夹手动处理冲突后重试。".to_string());
|
||||||
|
}
|
||||||
|
return Err(format!("合并远程更新失败:{e}"));
|
||||||
|
}
|
||||||
|
let after = run_git(dir, &["rev-parse", "HEAD"]).unwrap_or_default();
|
||||||
|
pulled = before != after || st.behind > 0;
|
||||||
|
}
|
||||||
|
if st.has_upstream {
|
||||||
|
run_git(dir, &["push"]).map_err(|e| format!("推送失败:{e}"))?;
|
||||||
|
} else {
|
||||||
|
run_git(dir, &["push", "-u", "origin", &branch]).map_err(|e| format!("推送失败:{e}"))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut parts: Vec<&str> = Vec::new();
|
||||||
|
if pulled {
|
||||||
|
parts.push("已拉取远程更新");
|
||||||
|
}
|
||||||
|
if committed {
|
||||||
|
parts.push("已提交本地更改");
|
||||||
|
}
|
||||||
|
if has_remote {
|
||||||
|
parts.push("已推送到远程");
|
||||||
|
}
|
||||||
|
let summary = if parts.is_empty() {
|
||||||
|
"已是最新,没有需要同步的更改".to_string()
|
||||||
|
} else {
|
||||||
|
parts.join(",")
|
||||||
|
};
|
||||||
|
Ok(GitSyncOutcome { committed, pushed: has_remote, pulled, summary })
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ pub fn run() {
|
|||||||
commands::create_dir,
|
commands::create_dir,
|
||||||
commands::rename_path,
|
commands::rename_path,
|
||||||
commands::reveal_in_explorer,
|
commands::reveal_in_explorer,
|
||||||
|
commands::git_status,
|
||||||
|
commands::git_init_repo,
|
||||||
|
commands::git_set_remote,
|
||||||
|
commands::git_sync_now,
|
||||||
commands::delete_to_trash,
|
commands::delete_to_trash,
|
||||||
commands::db_recent_list,
|
commands::db_recent_list,
|
||||||
commands::db_recent_add,
|
commands::db_recent_add,
|
||||||
|
|||||||
129
src/App.tsx
129
src/App.tsx
@@ -29,10 +29,13 @@ import {
|
|||||||
pickImageFile, pickAnyFile, pickAndReadWebImage, copyToAssets, saveBytesToAssets, fileToBase64,
|
pickImageFile, pickAnyFile, pickAndReadWebImage, copyToAssets, saveBytesToAssets, fileToBase64,
|
||||||
statFile, createTextFile, createDirPath, renamePath, deleteToTrash, revealInExplorer, type DirEntry, type RecoveryEntry,
|
statFile, createTextFile, createDirPath, renamePath, deleteToTrash, revealInExplorer, type DirEntry, type RecoveryEntry,
|
||||||
pickSaveHtmlFile, pickImportFile, readFileBase64, readBinaryFile, pickAndReadWebImport,
|
pickSaveHtmlFile, pickImportFile, readFileBase64, readBinaryFile, pickAndReadWebImport,
|
||||||
|
gitStatus, gitInitRepo, gitSetRemote, gitSyncNow, type GitStatusInfo,
|
||||||
} from './services/platform'
|
} from './services/platform'
|
||||||
import * as store from './services/store'
|
import * as store from './services/store'
|
||||||
import { recoveryKey, planRecovery } from './utils/recovery'
|
import { recoveryKey, planRecovery } from './utils/recovery'
|
||||||
import { appWindowClaims } from './services/windowClaims'
|
import { appWindowClaims, appWindowId } from './services/windowClaims'
|
||||||
|
import { acquireGitSyncLease, gitStatusLabel, gitSyncCommitMessage, loadGitSyncSettings, releaseGitSyncLease, saveGitSyncSettings, type GitSyncSettings } from './services/gitSync'
|
||||||
|
import { GitSyncModal } from './components/GitSyncModal'
|
||||||
import { RecoveryModal, ConflictModal, QuitModal, PromptModal, ConfirmModal } from './components/Stage4Modals'
|
import { RecoveryModal, ConflictModal, QuitModal, PromptModal, ConfirmModal } from './components/Stage4Modals'
|
||||||
import { AppearanceModal, ExportModal } from './components/Stage5Modals'
|
import { AppearanceModal, ExportModal } from './components/Stage5Modals'
|
||||||
import { DOC_THEMES, docThemeById } from './themes'
|
import { DOC_THEMES, docThemeById } from './themes'
|
||||||
@@ -78,6 +81,7 @@ export default function App() {
|
|||||||
const tab = activeTab(state)
|
const tab = activeTab(state)
|
||||||
|
|
||||||
const [workspaceRoot, setWorkspaceRoot] = useState<string | null>(null)
|
const [workspaceRoot, setWorkspaceRoot] = useState<string | null>(null)
|
||||||
|
|
||||||
const [sidebarMode, setSidebarMode] = useState<'files' | 'outline' | 'search'>('outline')
|
const [sidebarMode, setSidebarMode] = useState<'files' | 'outline' | 'search'>('outline')
|
||||||
const [recent, setRecent] = useState<string[]>(loadRecent)
|
const [recent, setRecent] = useState<string[]>(loadRecent)
|
||||||
const [cursor, setCursor] = useState<CursorInfo>({ line: 1, col: 1 })
|
const [cursor, setCursor] = useState<CursorInfo>({ line: 1, col: 1 })
|
||||||
@@ -235,6 +239,106 @@ export default function App() {
|
|||||||
|
|
||||||
const tauriMode = isTauri()
|
const tauriMode = isTauri()
|
||||||
|
|
||||||
|
// ---- Git 同步(工作区文件夹) ----
|
||||||
|
const [gitInfo, setGitInfo] = useState<GitStatusInfo | null>(null)
|
||||||
|
const [gitCfg, setGitCfg] = useState<GitSyncSettings>({ intervalMin: 0 })
|
||||||
|
const [gitSyncing, setGitSyncing] = useState(false)
|
||||||
|
const [gitMsg, setGitMsg] = useState<{ text: string; isError: boolean } | null>(null)
|
||||||
|
const [gitSettingsOpen, setGitSettingsOpen] = useState(false)
|
||||||
|
const [gitSettingsError, setGitSettingsError] = useState<string | null>(null)
|
||||||
|
const [gitSettingsBusy, setGitSettingsBusy] = useState(false)
|
||||||
|
const gitSyncingRef = useRef(false)
|
||||||
|
const gitRootRef = useRef<string | null>(null)
|
||||||
|
|
||||||
|
const refreshGitStatus = useCallback(async (root: string) => {
|
||||||
|
try {
|
||||||
|
const info = await gitStatus(root)
|
||||||
|
if (gitRootRef.current === root) setGitInfo(info)
|
||||||
|
} catch {
|
||||||
|
if (gitRootRef.current === root) setGitInfo(null)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const runGitSync = useCallback(async (auto: boolean) => {
|
||||||
|
const root = gitRootRef.current
|
||||||
|
if (!root || gitSyncingRef.current) return
|
||||||
|
if (auto && !acquireGitSyncLease(root, appWindowId)) return
|
||||||
|
gitSyncingRef.current = true
|
||||||
|
setGitSyncing(true)
|
||||||
|
try {
|
||||||
|
const outcome = await gitSyncNow(root, gitSyncCommitMessage(auto))
|
||||||
|
if (gitRootRef.current !== root) return
|
||||||
|
setGitMsg({ text: outcome.summary, isError: false })
|
||||||
|
if (outcome.pulled) setTreeRefresh((n) => n + 1)
|
||||||
|
} catch (e) {
|
||||||
|
if (gitRootRef.current === root) setGitMsg({ text: String(e), isError: true })
|
||||||
|
} finally {
|
||||||
|
gitSyncingRef.current = false
|
||||||
|
setGitSyncing(false)
|
||||||
|
if (gitRootRef.current === root) void refreshGitStatus(root)
|
||||||
|
}
|
||||||
|
}, [refreshGitStatus])
|
||||||
|
const gitSyncRunnerRef = useRef(runGitSync)
|
||||||
|
gitSyncRunnerRef.current = runGitSync
|
||||||
|
|
||||||
|
// 工作区切换:重读该工作区的 Git 状态与同步设置
|
||||||
|
useEffect(() => {
|
||||||
|
gitRootRef.current = workspaceRoot
|
||||||
|
setGitInfo(null)
|
||||||
|
setGitMsg(null)
|
||||||
|
setGitCfg({ intervalMin: 0 })
|
||||||
|
setGitSettingsOpen(false)
|
||||||
|
if (!workspaceRoot || !tauriMode) return
|
||||||
|
let cancelled = false
|
||||||
|
void (async () => {
|
||||||
|
const cfg = await loadGitSyncSettings(workspaceRoot)
|
||||||
|
if (!cancelled && gitRootRef.current === workspaceRoot) setGitCfg(cfg)
|
||||||
|
})()
|
||||||
|
void refreshGitStatus(workspaceRoot)
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [workspaceRoot, tauriMode, refreshGitStatus])
|
||||||
|
|
||||||
|
// 定时同步:到点自动跑一次(租约保证多窗口只有一个执行);开着工作区时先续租
|
||||||
|
useEffect(() => {
|
||||||
|
if (!workspaceRoot || !tauriMode || gitCfg.intervalMin <= 0 || !gitInfo?.isRepo) return
|
||||||
|
const root = workspaceRoot
|
||||||
|
acquireGitSyncLease(root, appWindowId)
|
||||||
|
const leaseTimer = setInterval(() => acquireGitSyncLease(root, appWindowId), 10_000)
|
||||||
|
const first = setTimeout(() => { void gitSyncRunnerRef.current(true) }, 5_000)
|
||||||
|
const timer = setInterval(() => { void gitSyncRunnerRef.current(true) }, gitCfg.intervalMin * 60_000)
|
||||||
|
return () => {
|
||||||
|
clearInterval(leaseTimer)
|
||||||
|
clearTimeout(first)
|
||||||
|
clearInterval(timer)
|
||||||
|
releaseGitSyncLease(root, appWindowId)
|
||||||
|
}
|
||||||
|
}, [workspaceRoot, tauriMode, gitCfg.intervalMin, gitInfo?.isRepo])
|
||||||
|
|
||||||
|
const saveGitSettings = useCallback(async (remoteUrl: string, branch: string, intervalMin: number) => {
|
||||||
|
const root = gitRootRef.current
|
||||||
|
if (!root) return
|
||||||
|
setGitSettingsBusy(true)
|
||||||
|
setGitSettingsError(null)
|
||||||
|
try {
|
||||||
|
let info: GitStatusInfo | null = null
|
||||||
|
if (!gitInfo?.isRepo) {
|
||||||
|
info = await gitInitRepo(root, remoteUrl, branch)
|
||||||
|
} else if (remoteUrl !== (gitInfo.remoteUrl ?? '')) {
|
||||||
|
info = await gitSetRemote(root, remoteUrl)
|
||||||
|
}
|
||||||
|
await saveGitSyncSettings(root, { intervalMin })
|
||||||
|
setGitCfg({ intervalMin })
|
||||||
|
if (info) setGitInfo(info)
|
||||||
|
else void refreshGitStatus(root)
|
||||||
|
setGitSettingsOpen(false)
|
||||||
|
setGitMsg({ text: info && !gitInfo?.isRepo ? '已初始化仓库并保存设置' : 'Git 同步设置已保存', isError: false })
|
||||||
|
} catch (e) {
|
||||||
|
setGitSettingsError(String(e))
|
||||||
|
} finally {
|
||||||
|
setGitSettingsBusy(false)
|
||||||
|
}
|
||||||
|
}, [gitInfo, refreshGitStatus])
|
||||||
|
|
||||||
const uiSaveTimerRef = useRef<number | undefined>(undefined)
|
const uiSaveTimerRef = useRef<number | undefined>(undefined)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!uiLoadedRef.current) return
|
if (!uiLoadedRef.current) return
|
||||||
@@ -1236,7 +1340,7 @@ export default function App() {
|
|||||||
|
|
||||||
anyModalRef.current = Boolean(
|
anyModalRef.current = Boolean(
|
||||||
modal || appearanceOpen || exportOpen || pendingClose || conflict
|
modal || appearanceOpen || exportOpen || pendingClose || conflict
|
||||||
|| promptSpec || confirmSpec || quitOpen || (recoveryOffer.length > 0 && !recoveryDismissed),
|
|| promptSpec || confirmSpec || quitOpen || gitSettingsOpen || (recoveryOffer.length > 0 && !recoveryDismissed),
|
||||||
)
|
)
|
||||||
findOpenRef.current = findUi.open
|
findOpenRef.current = findUi.open
|
||||||
|
|
||||||
@@ -1390,6 +1494,15 @@ export default function App() {
|
|||||||
onJumpHeading={jumpHeading}
|
onJumpHeading={jumpHeading}
|
||||||
onOpenSearchResult={openSearchResult}
|
onOpenSearchResult={openSearchResult}
|
||||||
tauriMode={tauriMode}
|
tauriMode={tauriMode}
|
||||||
|
gitSync={tauriMode && workspaceRoot ? {
|
||||||
|
isRepo: Boolean(gitInfo?.isRepo),
|
||||||
|
label: gitStatusLabel(gitInfo, gitSyncing),
|
||||||
|
syncing: gitSyncing,
|
||||||
|
lastMessage: gitMsg?.text ?? null,
|
||||||
|
lastIsError: gitMsg?.isError ?? false,
|
||||||
|
onSyncNow: () => { void runGitSync(false) },
|
||||||
|
onOpenSettings: () => { setGitSettingsError(null); setGitSettingsOpen(true) },
|
||||||
|
} : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1552,6 +1665,18 @@ export default function App() {
|
|||||||
tauriMode={tauriMode}
|
tauriMode={tauriMode}
|
||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
/>
|
/>
|
||||||
|
{gitSettingsOpen && workspaceRoot && (
|
||||||
|
<GitSyncModal
|
||||||
|
isRepo={Boolean(gitInfo?.isRepo)}
|
||||||
|
branch={gitInfo?.branch ?? null}
|
||||||
|
initialRemoteUrl={gitInfo?.remoteUrl ?? ''}
|
||||||
|
initialIntervalMin={gitCfg.intervalMin}
|
||||||
|
busy={gitSettingsBusy}
|
||||||
|
error={gitSettingsError}
|
||||||
|
onSave={(remoteUrl, branch, intervalMin) => { void saveGitSettings(remoteUrl, branch, intervalMin) }}
|
||||||
|
onClose={() => setGitSettingsOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{appearanceOpen && (
|
{appearanceOpen && (
|
||||||
<AppearanceModal
|
<AppearanceModal
|
||||||
docTheme={ui.docTheme}
|
docTheme={ui.docTheme}
|
||||||
|
|||||||
80
src/components/GitSyncModal.tsx
Normal file
80
src/components/GitSyncModal.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
// Git 同步设置:远程仓库地址、(初始化时的)分支、定时同步间隔。
|
||||||
|
// 工作区还不是仓库时,保存=初始化仓库并绑定远程;已是仓库时只更新设置。
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Dropdown } from './Dropdown'
|
||||||
|
|
||||||
|
const INTERVAL_OPTIONS = [
|
||||||
|
{ value: '0', label: '关闭(只手动同步)' },
|
||||||
|
{ value: '5', label: '每 5 分钟' },
|
||||||
|
{ value: '15', label: '每 15 分钟' },
|
||||||
|
{ value: '30', label: '每 30 分钟' },
|
||||||
|
{ value: '60', label: '每小时' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function GitSyncModal({ isRepo, branch, initialRemoteUrl, initialIntervalMin, busy, error, onSave, onClose }: {
|
||||||
|
isRepo: boolean
|
||||||
|
branch: string | null
|
||||||
|
initialRemoteUrl: string
|
||||||
|
initialIntervalMin: number
|
||||||
|
busy: boolean
|
||||||
|
error: string | null
|
||||||
|
onSave: (remoteUrl: string, branch: string, intervalMin: number) => void
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const [remoteUrl, setRemoteUrl] = useState(initialRemoteUrl)
|
||||||
|
const [branchInput, setBranchInput] = useState('main')
|
||||||
|
const [intervalMin, setIntervalMin] = useState(String(initialIntervalMin))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop">
|
||||||
|
<div className="modal">
|
||||||
|
<h3>Git 同步设置</h3>
|
||||||
|
<p className="modal-note">
|
||||||
|
同步顺序:提交本地更改 → 拉取远程更新(变基)→ 推送。若本地与远程改了相同内容,
|
||||||
|
同步会停下并提示,不会强行覆盖,需要在该文件夹手动处理。
|
||||||
|
</p>
|
||||||
|
<label className="modal-field">
|
||||||
|
<span>远程仓库地址(origin)</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={remoteUrl}
|
||||||
|
placeholder="https://git.xhome.cc/xieyinlu/notes.git"
|
||||||
|
onChange={(e) => setRemoteUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{isRepo ? (
|
||||||
|
<p className="modal-note">当前分支:{branch ?? '(无)'}。分支切换请用 Git 工具完成,这里只管同步。</p>
|
||||||
|
) : (
|
||||||
|
<label className="modal-field">
|
||||||
|
<span>初始分支</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={branchInput}
|
||||||
|
onChange={(e) => setBranchInput(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<label className="modal-field">
|
||||||
|
<span>定时同步</span>
|
||||||
|
<Dropdown
|
||||||
|
value={intervalMin}
|
||||||
|
options={INTERVAL_OPTIONS}
|
||||||
|
onChange={setIntervalMin}
|
||||||
|
ariaLabel="定时同步间隔"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error && <p className="modal-error">{error}</p>}
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button onClick={onClose} disabled={busy}>取消</button>
|
||||||
|
<button
|
||||||
|
className="primary"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onSave(remoteUrl.trim(), branchInput.trim() || 'main', Number(intervalMin) || 0)}
|
||||||
|
>
|
||||||
|
{busy ? '处理中…' : isRepo ? '保存设置' : '初始化并启用'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,6 +6,17 @@ import { isPathInside, parentDirOf, pathsEqual } from '../utils/text'
|
|||||||
import type { OutlineItem } from '../utils/text'
|
import type { OutlineItem } from '../utils/text'
|
||||||
import { baseName } from '../utils/text'
|
import { baseName } from '../utils/text'
|
||||||
|
|
||||||
|
/** Git 同步条所需的展示状态与动作(由 App 持有真实状态)。 */
|
||||||
|
export interface GitSyncUi {
|
||||||
|
isRepo: boolean
|
||||||
|
label: string
|
||||||
|
syncing: boolean
|
||||||
|
lastMessage: string | null
|
||||||
|
lastIsError: boolean
|
||||||
|
onSyncNow: () => void
|
||||||
|
onOpenSettings: () => void
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mode: 'files' | 'outline' | 'search'
|
mode: 'files' | 'outline' | 'search'
|
||||||
onModeChange: (mode: 'files' | 'outline' | 'search') => void
|
onModeChange: (mode: 'files' | 'outline' | 'search') => void
|
||||||
@@ -33,6 +44,8 @@ interface Props {
|
|||||||
outline: OutlineItem[]
|
outline: OutlineItem[]
|
||||||
onJumpHeading: (item: OutlineItem) => void
|
onJumpHeading: (item: OutlineItem) => void
|
||||||
tauriMode: boolean
|
tauriMode: boolean
|
||||||
|
/** Git 同步条(仅桌面模式且已打开文件夹时由 App 传入) */
|
||||||
|
gitSync?: GitSyncUi
|
||||||
}
|
}
|
||||||
|
|
||||||
function Sidebar(p: Props) {
|
function Sidebar(p: Props) {
|
||||||
@@ -67,6 +80,8 @@ function Sidebar(p: Props) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : p.workspaceRoot ? (
|
) : p.workspaceRoot ? (
|
||||||
|
<>
|
||||||
|
{p.gitSync && <GitSyncBar ui={p.gitSync} />}
|
||||||
<FileTree
|
<FileTree
|
||||||
onOpenInNewWindowEntry={p.onOpenInNewWindowEntry}
|
onOpenInNewWindowEntry={p.onOpenInNewWindowEntry}
|
||||||
onSaveAsEntry={p.onSaveAsEntry}
|
onSaveAsEntry={p.onSaveAsEntry}
|
||||||
@@ -83,6 +98,7 @@ function Sidebar(p: Props) {
|
|||||||
onRevealEntry={p.onRevealEntry}
|
onRevealEntry={p.onRevealEntry}
|
||||||
refreshSignal={p.refreshSignal ?? 0}
|
refreshSignal={p.refreshSignal ?? 0}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="sidebar-empty center">
|
<div className="sidebar-empty center">
|
||||||
<p>尚未打开文件夹。</p>
|
<p>尚未打开文件夹。</p>
|
||||||
@@ -106,6 +122,32 @@ interface TreeDnd {
|
|||||||
drop: (dir: string) => void
|
drop: (dir: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function GitSyncBar({ ui }: { ui: GitSyncUi }) {
|
||||||
|
return (
|
||||||
|
<div className="gitsync">
|
||||||
|
<div className="gitsync-bar">
|
||||||
|
<span className="gitsync-title">Git 同步</span>
|
||||||
|
<span className="gitsync-status">{ui.label}</span>
|
||||||
|
<span className="gitsync-actions">
|
||||||
|
{ui.isRepo && (
|
||||||
|
<Tooltip label="立即同步">
|
||||||
|
<button className="gitsync-btn" aria-label="立即同步" disabled={ui.syncing} onClick={ui.onSyncNow}>
|
||||||
|
<svg className={ui.syncing ? 'ws-spin' : ''} width="14" height="14" viewBox="0 0 24 24" aria-hidden="true"><path d="M20 12a8 8 0 1 1-2.34-5.66" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /><path d="M20.2 3.6v4h-4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Tooltip label="Git 同步设置">
|
||||||
|
<button className="gitsync-btn" aria-label="Git 同步设置" onClick={ui.onOpenSettings}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 8h10M18 8h2M4 16h4M12 16h8" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /><circle cx="16" cy="8" r="2.2" fill="none" stroke="currentColor" strokeWidth="1.8" /><circle cx="10" cy="16" r="2.2" fill="none" stroke="currentColor" strokeWidth="1.8" /></svg>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{ui.lastMessage && <div className={`gitsync-msg${ui.lastIsError ? ' error' : ''}`}>{ui.lastMessage}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function FileTree({ root, activePath, onOpenFile, onCreateFileIn, onCreateFolderIn, onRenameEntry, onDeleteEntry, refreshSignal, onOpenInNewWindowEntry, onSaveAsEntry, onExportEntry, onPrintEntry, onMoveEntry, onRevealEntry }: {
|
function FileTree({ root, activePath, onOpenFile, onCreateFileIn, onCreateFolderIn, onRenameEntry, onDeleteEntry, refreshSignal, onOpenInNewWindowEntry, onSaveAsEntry, onExportEntry, onPrintEntry, onMoveEntry, onRevealEntry }: {
|
||||||
root: string
|
root: string
|
||||||
activePath: string | null
|
activePath: string | null
|
||||||
|
|||||||
97
src/services/gitSync.ts
Normal file
97
src/services/gitSync.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
// Git 同步的设置与文案:设置按工作区存进偏好(key=gitsync:<root>),
|
||||||
|
// 定时同步用 localStorage 租约保证多窗口只有一个执行(与草稿认领同一思路)。
|
||||||
|
|
||||||
|
import { dbPrefGet, dbPrefSet, type GitStatusInfo } from './platform'
|
||||||
|
|
||||||
|
export interface GitSyncSettings {
|
||||||
|
/** 定时同步间隔(分钟);0=只手动 */
|
||||||
|
intervalMin: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_GIT_SYNC_SETTINGS: GitSyncSettings = { intervalMin: 0 }
|
||||||
|
|
||||||
|
export function parseGitSyncSettings(raw: string | null): GitSyncSettings {
|
||||||
|
if (!raw) return { ...DEFAULT_GIT_SYNC_SETTINGS }
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(raw) as Partial<GitSyncSettings>
|
||||||
|
const intervalMin = typeof obj.intervalMin === 'number' && obj.intervalMin >= 0 ? obj.intervalMin : 0
|
||||||
|
return { intervalMin }
|
||||||
|
} catch {
|
||||||
|
return { ...DEFAULT_GIT_SYNC_SETTINGS }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadGitSyncSettings(root: string): Promise<GitSyncSettings> {
|
||||||
|
try {
|
||||||
|
return parseGitSyncSettings(await dbPrefGet(`gitsync:${root}`))
|
||||||
|
} catch {
|
||||||
|
return { ...DEFAULT_GIT_SYNC_SETTINGS }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveGitSyncSettings(root: string, settings: GitSyncSettings): Promise<void> {
|
||||||
|
try {
|
||||||
|
await dbPrefSet(`gitsync:${root}`, JSON.stringify(settings))
|
||||||
|
} catch {
|
||||||
|
// 偏好写入失败不挡同步本身
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 侧栏同步条上的一句状态文案。 */
|
||||||
|
export function gitStatusLabel(status: GitStatusInfo | null, syncing: boolean): string {
|
||||||
|
if (syncing) return '正在同步…'
|
||||||
|
if (!status) return '读取仓库状态…'
|
||||||
|
if (!status.isRepo) return '未启用 Git 同步'
|
||||||
|
const parts: string[] = []
|
||||||
|
if (status.branch) parts.push(status.branch)
|
||||||
|
if (!status.remoteUrl) parts.push('未设置远程')
|
||||||
|
if (status.dirtyCount > 0) parts.push(`待提交 ${status.dirtyCount} 处`)
|
||||||
|
if (status.ahead > 0) parts.push(`待推送 ${status.ahead}`)
|
||||||
|
if (status.behind > 0) parts.push(`待拉取 ${status.behind}`)
|
||||||
|
if (status.dirtyCount === 0 && status.ahead === 0 && status.behind === 0) parts.push('已同步')
|
||||||
|
return parts.join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 自动同步的提交信息(本地时间)。 */
|
||||||
|
export function gitSyncCommitMessage(auto: boolean, now = new Date()): string {
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
const stamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}`
|
||||||
|
return `${auto ? '自动同步' : '手动同步'} ${stamp}(new-markdown)`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 定时同步租约:同一工作区只让一个窗口跑定时同步 ----
|
||||||
|
|
||||||
|
const LEASE_PREFIX = 'nm-gitsync-lease:'
|
||||||
|
const LEASE_TTL_MS = 25_000
|
||||||
|
|
||||||
|
interface Lease {
|
||||||
|
win: string
|
||||||
|
ts: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 尝试取得/续租该工作区的定时同步租约;被别的窗口新鲜占用时返回 false。 */
|
||||||
|
export function acquireGitSyncLease(root: string, windowId: string, now = Date.now()): boolean {
|
||||||
|
try {
|
||||||
|
const key = LEASE_PREFIX + root
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
if (raw) {
|
||||||
|
const lease = JSON.parse(raw) as Lease
|
||||||
|
if (lease.win !== windowId && now - lease.ts < LEASE_TTL_MS) return false
|
||||||
|
}
|
||||||
|
const mine: Lease = { win: windowId, ts: now }
|
||||||
|
localStorage.setItem(key, JSON.stringify(mine))
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return true // 存储不可用时不拦(单窗口场景)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function releaseGitSyncLease(root: string, windowId: string): void {
|
||||||
|
try {
|
||||||
|
const key = LEASE_PREFIX + root
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
if (raw && (JSON.parse(raw) as Lease).win === windowId) localStorage.removeItem(key)
|
||||||
|
} catch {
|
||||||
|
// 忽略
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -311,3 +311,40 @@ export async function workspaceSearch(root: string, query: string, caseSensitive
|
|||||||
export async function revealInExplorer(path: string): Promise<void> {
|
export async function revealInExplorer(path: string): Promise<void> {
|
||||||
return invoke<void>('reveal_in_explorer', { path })
|
return invoke<void>('reveal_in_explorer', { path })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GitStatusInfo {
|
||||||
|
isRepo: boolean
|
||||||
|
branch: string | null
|
||||||
|
remoteUrl: string | null
|
||||||
|
dirtyCount: number
|
||||||
|
ahead: number
|
||||||
|
behind: number
|
||||||
|
hasUpstream: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitSyncOutcome {
|
||||||
|
committed: boolean
|
||||||
|
pushed: boolean
|
||||||
|
pulled: boolean
|
||||||
|
summary: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 工作区的 Git 仓库状态(非仓库时 isRepo=false)。 */
|
||||||
|
export async function gitStatus(root: string): Promise<GitStatusInfo> {
|
||||||
|
return invoke<GitStatusInfo>('git_status', { root })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在工作区初始化 Git 仓库并绑定远程(远程可为空)。 */
|
||||||
|
export async function gitInitRepo(root: string, remoteUrl: string, branch: string): Promise<GitStatusInfo> {
|
||||||
|
return invoke<GitStatusInfo>('git_init_repo', { root, remoteUrl, branch })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设置/清除工作区仓库的 origin 远程(空字符串=移除)。 */
|
||||||
|
export async function gitSetRemote(root: string, remoteUrl: string): Promise<GitStatusInfo> {
|
||||||
|
return invoke<GitStatusInfo>('git_set_remote', { root, remoteUrl })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同步一次:提交本地更改→变基拉取远程→推送。冲突时返回错误且不留半成品。 */
|
||||||
|
export async function gitSyncNow(root: string, message: string): Promise<GitSyncOutcome> {
|
||||||
|
return invoke<GitSyncOutcome>('git_sync_now', { root, message })
|
||||||
|
}
|
||||||
|
|||||||
@@ -77,4 +77,7 @@ export function createWindowClaims(
|
|||||||
const randomWindowId = `win-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
const randomWindowId = `win-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
||||||
|
|
||||||
/** 本应用窗口的认领单例(App 使用)。 */
|
/** 本应用窗口的认领单例(App 使用)。 */
|
||||||
|
/** 本窗口的随机标识(草稿认领与 Git 定时同步租约共用)。 */
|
||||||
|
export const appWindowId: string = randomWindowId
|
||||||
|
|
||||||
export const appWindowClaims: WindowClaims = createWindowClaims(randomWindowId)
|
export const appWindowClaims: WindowClaims = createWindowClaims(randomWindowId)
|
||||||
|
|||||||
@@ -416,3 +416,16 @@ kbd { background: var(--code-bg); border: 1px solid var(--border); border-radius
|
|||||||
*::-webkit-scrollbar-thumb { background: var(--border); border-radius: 5px; border: 2px solid transparent; background-clip: content-box; }
|
*::-webkit-scrollbar-thumb { background: var(--border); border-radius: 5px; border: 2px solid transparent; background-clip: content-box; }
|
||||||
*::-webkit-scrollbar-thumb:hover { background: var(--text-dim); border: 2px solid transparent; background-clip: content-box; }
|
*::-webkit-scrollbar-thumb:hover { background: var(--text-dim); border: 2px solid transparent; background-clip: content-box; }
|
||||||
*::-webkit-scrollbar-corner { background: transparent; }
|
*::-webkit-scrollbar-corner { background: transparent; }
|
||||||
|
|
||||||
|
/* Git 同步条(侧栏文件模式顶部) */
|
||||||
|
.gitsync { border-bottom: 1px solid var(--border); padding: 6px 10px; }
|
||||||
|
.gitsync-bar { display: flex; align-items: center; gap: 8px; min-height: 26px; }
|
||||||
|
.gitsync-title { font-size: 12px; font-weight: 600; color: var(--text); white-space: nowrap; }
|
||||||
|
.gitsync-status { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11.5px; color: var(--text-dim); }
|
||||||
|
.gitsync-actions { display: flex; gap: 2px; }
|
||||||
|
.gitsync-btn { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border: none; border-radius: 6px; background: transparent; color: var(--text-dim); cursor: pointer; }
|
||||||
|
.gitsync-btn:hover:not(:disabled) { background: var(--hover); color: var(--text); }
|
||||||
|
.gitsync-btn:disabled { opacity: .5; cursor: default; }
|
||||||
|
.gitsync-msg { margin-top: 4px; font-size: 11.5px; line-height: 1.45; color: var(--text-dim); overflow-wrap: anywhere; }
|
||||||
|
.gitsync-msg.error { color: #cf5b52; }
|
||||||
|
.modal-error { color: #cf5b52; font-size: 12px; line-height: 1.5; margin: 8px 0 0; }
|
||||||
|
|||||||
123
tests/git-sync.test.tsx
Normal file
123
tests/git-sync.test.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
// Git 同步:设置解析、状态文案、定时租约互斥、侧栏同步条渲染。
|
||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { act } from 'react-dom/test-utils'
|
||||||
|
import {
|
||||||
|
parseGitSyncSettings, gitStatusLabel, gitSyncCommitMessage,
|
||||||
|
acquireGitSyncLease, releaseGitSyncLease,
|
||||||
|
} from '../src/services/gitSync'
|
||||||
|
import type { GitStatusInfo } from '../src/services/platform'
|
||||||
|
|
||||||
|
vi.mock('../src/services/platform', async (importOriginal) => {
|
||||||
|
const orig = await importOriginal<typeof import('../src/services/platform')>()
|
||||||
|
return { ...orig, listDir: async () => [] }
|
||||||
|
})
|
||||||
|
|
||||||
|
import Sidebar from '../src/components/Sidebar'
|
||||||
|
|
||||||
|
const base: GitStatusInfo = {
|
||||||
|
isRepo: true, branch: 'main', remoteUrl: 'https://example.com/n.git',
|
||||||
|
dirtyCount: 0, ahead: 0, behind: 0, hasUpstream: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('parseGitSyncSettings', () => {
|
||||||
|
it('空/坏数据回默认(手动)', () => {
|
||||||
|
expect(parseGitSyncSettings(null).intervalMin).toBe(0)
|
||||||
|
expect(parseGitSyncSettings('{bad').intervalMin).toBe(0)
|
||||||
|
expect(parseGitSyncSettings('{"intervalMin":-3}').intervalMin).toBe(0)
|
||||||
|
})
|
||||||
|
it('正常解析间隔', () => {
|
||||||
|
expect(parseGitSyncSettings('{"intervalMin":15}').intervalMin).toBe(15)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('gitStatusLabel', () => {
|
||||||
|
it('未启用 / 同步中 / 已同步', () => {
|
||||||
|
expect(gitStatusLabel({ ...base, isRepo: false }, false)).toBe('未启用 Git 同步')
|
||||||
|
expect(gitStatusLabel(base, true)).toBe('正在同步…')
|
||||||
|
expect(gitStatusLabel(base, false)).toBe('main · 已同步')
|
||||||
|
})
|
||||||
|
it('待提交/待推送/待拉取计数', () => {
|
||||||
|
expect(gitStatusLabel({ ...base, dirtyCount: 2, ahead: 1, behind: 3 }, false))
|
||||||
|
.toBe('main · 待提交 2 处 · 待推送 1 · 待拉取 3')
|
||||||
|
})
|
||||||
|
it('无远程时提示未设置远程', () => {
|
||||||
|
expect(gitStatusLabel({ ...base, remoteUrl: null, hasUpstream: false }, false))
|
||||||
|
.toBe('main · 未设置远程 · 已同步')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('gitSyncCommitMessage', () => {
|
||||||
|
it('带本地时间戳与手动/自动区分', () => {
|
||||||
|
const d = new Date(2026, 10, 14, 9, 5)
|
||||||
|
expect(gitSyncCommitMessage(true, d)).toBe('自动同步 2026-11-14 09:05(new-markdown)')
|
||||||
|
expect(gitSyncCommitMessage(false, d)).toContain('手动同步 2026-11-14 09:05')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('定时同步租约', () => {
|
||||||
|
it('别的窗口新鲜占用时取不到,过期/释放后可取', () => {
|
||||||
|
localStorage.clear()
|
||||||
|
const t0 = 1_000_000
|
||||||
|
expect(acquireGitSyncLease('C:/ws', 'win-a', t0)).toBe(true)
|
||||||
|
expect(acquireGitSyncLease('C:/ws', 'win-b', t0 + 5_000)).toBe(false)
|
||||||
|
expect(acquireGitSyncLease('C:/ws', 'win-a', t0 + 6_000)).toBe(true) // 自己续租
|
||||||
|
expect(acquireGitSyncLease('C:/ws', 'win-b', t0 + 40_000)).toBe(true) // 已过期
|
||||||
|
releaseGitSyncLease('C:/ws', 'win-a') // 不是持有者,无效
|
||||||
|
expect(acquireGitSyncLease('C:/ws', 'win-c', t0 + 41_000)).toBe(false)
|
||||||
|
releaseGitSyncLease('C:/ws', 'win-b')
|
||||||
|
expect(acquireGitSyncLease('C:/ws', 'win-c', t0 + 42_000)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('侧栏 Git 同步条', () => {
|
||||||
|
async function renderBar(gitSync: Record<string, unknown>) {
|
||||||
|
const container = document.createElement('div')
|
||||||
|
document.body.appendChild(container)
|
||||||
|
const root = createRoot(container)
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<Sidebar
|
||||||
|
mode="files"
|
||||||
|
onModeChange={() => undefined}
|
||||||
|
workspaceRoot="C:/ws"
|
||||||
|
activePath={null}
|
||||||
|
onOpenFile={() => undefined}
|
||||||
|
onOpenFolder={() => undefined}
|
||||||
|
outline={[]}
|
||||||
|
onJumpHeading={() => undefined}
|
||||||
|
tauriMode
|
||||||
|
gitSync={gitSync as never}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
await act(async () => { await new Promise((r) => setTimeout(r, 30)) })
|
||||||
|
return container
|
||||||
|
}
|
||||||
|
|
||||||
|
it('未启用时显示状态与设置按钮,无立即同步', async () => {
|
||||||
|
const c = await renderBar({
|
||||||
|
isRepo: false, label: '未启用 Git 同步', syncing: false,
|
||||||
|
lastMessage: null, lastIsError: false,
|
||||||
|
onSyncNow: () => undefined, onOpenSettings: () => undefined,
|
||||||
|
})
|
||||||
|
expect(c.textContent).toContain('Git 同步')
|
||||||
|
expect(c.textContent).toContain('未启用 Git 同步')
|
||||||
|
expect(c.querySelector('[aria-label="Git 同步设置"]')).not.toBeNull()
|
||||||
|
expect(c.querySelector('[aria-label="立即同步"]')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('仓库状态下显示立即同步且可点,结果行可见', async () => {
|
||||||
|
const onSyncNow = vi.fn()
|
||||||
|
const c = await renderBar({
|
||||||
|
isRepo: true, label: 'main · 待提交 2 处', syncing: false,
|
||||||
|
lastMessage: '已提交本地更改', lastIsError: false,
|
||||||
|
onSyncNow, onOpenSettings: () => undefined,
|
||||||
|
})
|
||||||
|
expect(c.textContent).toContain('待提交 2 处')
|
||||||
|
expect(c.textContent).toContain('已提交本地更改')
|
||||||
|
const btn = c.querySelector('[aria-label="立即同步"]') as HTMLButtonElement
|
||||||
|
await act(async () => { btn.click() })
|
||||||
|
expect(onSyncNow).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user