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

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