feat(git): 工作区 Git 同步——侧栏同步条、远程设置、定时同步(提交→变基拉取→推送,冲突停下不覆盖)(docs/27)

This commit is contained in:
Muse
2026-11-14 17:35:41 +08:00
parent 13271f11b6
commit fadee6aacf
11 changed files with 792 additions and 2 deletions

53
docs/27-Git同步.md Normal file
View 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 仓库初始化并绑定;②改文档后手动同步,远程出现
「手动同步」提交;③另一处改同一文件后同步报冲突且本地不乱;
④开定时后到点自动同步;⑤双窗口开同一工作区时只有一个在跑。

View File

@@ -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())?
}

View File

@@ -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,

View File

@@ -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}

View 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>
)
}

View File

@@ -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
View 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 {
// 忽略
}
}

View File

@@ -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 })
}

View File

@@ -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)

View File

@@ -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
View 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)
})
})