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
|
||||
.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::rename_path,
|
||||
commands::reveal_in_explorer,
|
||||
commands::git_status,
|
||||
commands::git_init_repo,
|
||||
commands::git_set_remote,
|
||||
commands::git_sync_now,
|
||||
commands::delete_to_trash,
|
||||
commands::db_recent_list,
|
||||
commands::db_recent_add,
|
||||
|
||||
129
src/App.tsx
129
src/App.tsx
@@ -29,10 +29,13 @@ import {
|
||||
pickImageFile, pickAnyFile, pickAndReadWebImage, copyToAssets, saveBytesToAssets, fileToBase64,
|
||||
statFile, createTextFile, createDirPath, renamePath, deleteToTrash, revealInExplorer, type DirEntry, type RecoveryEntry,
|
||||
pickSaveHtmlFile, pickImportFile, readFileBase64, readBinaryFile, pickAndReadWebImport,
|
||||
gitStatus, gitInitRepo, gitSetRemote, gitSyncNow, type GitStatusInfo,
|
||||
} from './services/platform'
|
||||
import * as store from './services/store'
|
||||
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 { AppearanceModal, ExportModal } from './components/Stage5Modals'
|
||||
import { DOC_THEMES, docThemeById } from './themes'
|
||||
@@ -78,6 +81,7 @@ export default function App() {
|
||||
const tab = activeTab(state)
|
||||
|
||||
const [workspaceRoot, setWorkspaceRoot] = useState<string | null>(null)
|
||||
|
||||
const [sidebarMode, setSidebarMode] = useState<'files' | 'outline' | 'search'>('outline')
|
||||
const [recent, setRecent] = useState<string[]>(loadRecent)
|
||||
const [cursor, setCursor] = useState<CursorInfo>({ line: 1, col: 1 })
|
||||
@@ -235,6 +239,106 @@ export default function App() {
|
||||
|
||||
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)
|
||||
useEffect(() => {
|
||||
if (!uiLoadedRef.current) return
|
||||
@@ -1236,7 +1340,7 @@ export default function App() {
|
||||
|
||||
anyModalRef.current = Boolean(
|
||||
modal || appearanceOpen || exportOpen || pendingClose || conflict
|
||||
|| promptSpec || confirmSpec || quitOpen || (recoveryOffer.length > 0 && !recoveryDismissed),
|
||||
|| promptSpec || confirmSpec || quitOpen || gitSettingsOpen || (recoveryOffer.length > 0 && !recoveryDismissed),
|
||||
)
|
||||
findOpenRef.current = findUi.open
|
||||
|
||||
@@ -1390,6 +1494,15 @@ export default function App() {
|
||||
onJumpHeading={jumpHeading}
|
||||
onOpenSearchResult={openSearchResult}
|
||||
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>
|
||||
)}
|
||||
@@ -1552,6 +1665,18 @@ export default function App() {
|
||||
tauriMode={tauriMode}
|
||||
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 && (
|
||||
<AppearanceModal
|
||||
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 { 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 {
|
||||
mode: 'files' | 'outline' | 'search'
|
||||
onModeChange: (mode: 'files' | 'outline' | 'search') => void
|
||||
@@ -33,6 +44,8 @@ interface Props {
|
||||
outline: OutlineItem[]
|
||||
onJumpHeading: (item: OutlineItem) => void
|
||||
tauriMode: boolean
|
||||
/** Git 同步条(仅桌面模式且已打开文件夹时由 App 传入) */
|
||||
gitSync?: GitSyncUi
|
||||
}
|
||||
|
||||
function Sidebar(p: Props) {
|
||||
@@ -67,6 +80,8 @@ function Sidebar(p: Props) {
|
||||
))}
|
||||
</div>
|
||||
) : p.workspaceRoot ? (
|
||||
<>
|
||||
{p.gitSync && <GitSyncBar ui={p.gitSync} />}
|
||||
<FileTree
|
||||
onOpenInNewWindowEntry={p.onOpenInNewWindowEntry}
|
||||
onSaveAsEntry={p.onSaveAsEntry}
|
||||
@@ -83,6 +98,7 @@ function Sidebar(p: Props) {
|
||||
onRevealEntry={p.onRevealEntry}
|
||||
refreshSignal={p.refreshSignal ?? 0}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="sidebar-empty center">
|
||||
<p>尚未打开文件夹。</p>
|
||||
@@ -106,6 +122,32 @@ interface TreeDnd {
|
||||
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 }: {
|
||||
root: string
|
||||
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> {
|
||||
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)}`
|
||||
|
||||
/** 本应用窗口的认领单例(App 使用)。 */
|
||||
/** 本窗口的随机标识(草稿认领与 Git 定时同步租约共用)。 */
|
||||
export const appWindowId: string = 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:hover { background: var(--text-dim); border: 2px solid transparent; background-clip: content-box; }
|
||||
*::-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