diff --git a/docs/27-Git同步.md b/docs/27-Git同步.md new file mode 100644 index 0000000..7e5623a --- /dev/null +++ b/docs/27-Git同步.md @@ -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:。 +- 定时同步:打开工作区后 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 仓库初始化并绑定;②改文档后手动同步,远程出现 + 「手动同步」提交;③另一处改同一文件后同步报冲突且本地不乱; + ④开定时后到点自动同步;⑤双窗口开同一工作区时只有一个在跑。 diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 1eee1cf..819ae0c 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -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, + pub remote_url: Option, + 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 { + 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 { + 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 { + 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 { + 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())? +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6fa3123..22aaab5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src/App.tsx b/src/App.tsx index a454f78..ba0f2ff 100644 --- a/src/App.tsx +++ b/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(null) + const [sidebarMode, setSidebarMode] = useState<'files' | 'outline' | 'search'>('outline') const [recent, setRecent] = useState(loadRecent) const [cursor, setCursor] = useState({ line: 1, col: 1 }) @@ -235,6 +239,106 @@ export default function App() { const tauriMode = isTauri() + // ---- Git 同步(工作区文件夹) ---- + const [gitInfo, setGitInfo] = useState(null) + const [gitCfg, setGitCfg] = useState({ 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(null) + const [gitSettingsBusy, setGitSettingsBusy] = useState(false) + const gitSyncingRef = useRef(false) + const gitRootRef = useRef(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(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} /> )} @@ -1552,6 +1665,18 @@ export default function App() { tauriMode={tauriMode} zoom={zoom} /> + {gitSettingsOpen && workspaceRoot && ( + { void saveGitSettings(remoteUrl, branch, intervalMin) }} + onClose={() => setGitSettingsOpen(false)} + /> + )} {appearanceOpen && ( void + onClose: () => void +}) { + const [remoteUrl, setRemoteUrl] = useState(initialRemoteUrl) + const [branchInput, setBranchInput] = useState('main') + const [intervalMin, setIntervalMin] = useState(String(initialIntervalMin)) + + return ( +
+
+

Git 同步设置

+

+ 同步顺序:提交本地更改 → 拉取远程更新(变基)→ 推送。若本地与远程改了相同内容, + 同步会停下并提示,不会强行覆盖,需要在该文件夹手动处理。 +

+ + {isRepo ? ( +

当前分支:{branch ?? '(无)'}。分支切换请用 Git 工具完成,这里只管同步。

+ ) : ( + + )} + + {error &&

{error}

} +
+ + +
+
+
+ ) +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 8ef34f4..4ea8e49 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -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) { ))} ) : p.workspaceRoot ? ( + <> + {p.gitSync && } + ) : (

尚未打开文件夹。

@@ -106,6 +122,32 @@ interface TreeDnd { drop: (dir: string) => void } +function GitSyncBar({ ui }: { ui: GitSyncUi }) { + return ( +
+
+ Git 同步 + {ui.label} + + {ui.isRepo && ( + + + + )} + + + + +
+ {ui.lastMessage &&
{ui.lastMessage}
} +
+ ) +} + function FileTree({ root, activePath, onOpenFile, onCreateFileIn, onCreateFolderIn, onRenameEntry, onDeleteEntry, refreshSignal, onOpenInNewWindowEntry, onSaveAsEntry, onExportEntry, onPrintEntry, onMoveEntry, onRevealEntry }: { root: string activePath: string | null diff --git a/src/services/gitSync.ts b/src/services/gitSync.ts new file mode 100644 index 0000000..c8b112d --- /dev/null +++ b/src/services/gitSync.ts @@ -0,0 +1,97 @@ +// Git 同步的设置与文案:设置按工作区存进偏好(key=gitsync:), +// 定时同步用 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 + 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 { + try { + return parseGitSyncSettings(await dbPrefGet(`gitsync:${root}`)) + } catch { + return { ...DEFAULT_GIT_SYNC_SETTINGS } + } +} + +export async function saveGitSyncSettings(root: string, settings: GitSyncSettings): Promise { + 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 { + // 忽略 + } +} diff --git a/src/services/platform.ts b/src/services/platform.ts index d4b4097..1dbc13d 100644 --- a/src/services/platform.ts +++ b/src/services/platform.ts @@ -311,3 +311,40 @@ export async function workspaceSearch(root: string, query: string, caseSensitive export async function revealInExplorer(path: string): Promise { return invoke('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 { + return invoke('git_status', { root }) +} + +/** 在工作区初始化 Git 仓库并绑定远程(远程可为空)。 */ +export async function gitInitRepo(root: string, remoteUrl: string, branch: string): Promise { + return invoke('git_init_repo', { root, remoteUrl, branch }) +} + +/** 设置/清除工作区仓库的 origin 远程(空字符串=移除)。 */ +export async function gitSetRemote(root: string, remoteUrl: string): Promise { + return invoke('git_set_remote', { root, remoteUrl }) +} + +/** 同步一次:提交本地更改→变基拉取远程→推送。冲突时返回错误且不留半成品。 */ +export async function gitSyncNow(root: string, message: string): Promise { + return invoke('git_sync_now', { root, message }) +} diff --git a/src/services/windowClaims.ts b/src/services/windowClaims.ts index b8bd6d9..4dfd63c 100644 --- a/src/services/windowClaims.ts +++ b/src/services/windowClaims.ts @@ -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) diff --git a/src/styles.css b/src/styles.css index 06e01c7..50f1d18 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; } diff --git a/tests/git-sync.test.tsx b/tests/git-sync.test.tsx new file mode 100644 index 0000000..247bb50 --- /dev/null +++ b/tests/git-sync.test.tsx @@ -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() + 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) { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + await act(async () => { + root.render( + 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) + }) +})