Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/components/SettingProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,20 @@ import SettingsContext from '../context/settings.context'

export default function SettingProvider({ children }: PropsWithChildren) {
const [accessToken, setAccessToken] = useStorage('accessToken')
const [settings, updateSettings] = useSettings()
const [settings, updateSettings, patchSettings] = useSettings()
const queryClient = useQueryClient()

useGistSync(settings)
useGistSync(settings, patchSettings)

const value = useMemo(() => {
return {
updateSettings,
patchSettings,
settings,
accessToken,
updateAccessToken: setAccessToken,
}
}, [updateSettings, settings, accessToken, setAccessToken])
}, [updateSettings, patchSettings, settings, accessToken, setAccessToken])

useEffect(() => {
if (!accessToken) {
Expand Down
5 changes: 5 additions & 0 deletions src/context/settings.context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import { createContext } from 'react'

export interface SettingsContextType {
updateSettings: (newSettings: Setting) => void
/**
* 局部更新 settings 字段(不触发 updatedAt 自增)。
* 用于写入同步元数据(如切换 gist 后写入 remoteUpdatedAt),避免被识别为"本地有未推送修改"。
*/
patchSettings: (patch: Partial<Setting>) => void
settings: Setting
accessToken: string
updateAccessToken: (token: string) => void
Expand Down
75 changes: 53 additions & 22 deletions src/hooks/useGistSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,29 +8,42 @@ import parseGistContent from '@/utils/parseGistContent'
import { useGistUpdate } from './useGistMutation'
import { useGistOne } from './useGistQuery'

export function useGistSync(settings: Setting | null) {
function toMs(iso: string | undefined): number {
if (!iso) {
return 0
}
const ms = new Date(iso).getTime()
return Number.isFinite(ms) ? ms : 0
}

const DEFAULT_FILE_NAME = 'candi-tab-settings.json'

export function useGistSync(
settings: Setting | null,
patchSettings: (patch: Partial<Setting>) => void,
) {
const { t } = useTranslation()
const gistId = settings?.gist?.id || settings?.gistId
const mutation = useGistUpdate(gistId)
const oneGist = useGistOne(gistId)
// oneGist.data 经过 select: data => data?.data 已是 GistObject,
// 但 TS 因 options?: any 无法推断,需手动断言
// oneGist.data 经过 select: data => data?.data 已是 GistObject
const gistFiles = (oneGist.data as any)?.files

// Create refs to hold latest values to avoid recreating the debounced function
// Refs 保持最新引用,避免在 debounce 闭包内拿到旧值
const mutationRef = useRef(mutation)
const gistFilesRef = useRef(gistFiles)
const gistIdRef = useRef(gistId)
const tRef = useRef(t)
const oneGistRef = useRef(oneGist)
const patchSettingsRef = useRef(patchSettings)

// Update refs on every render via effect
useEffect(() => {
mutationRef.current = mutation
gistFilesRef.current = gistFiles
gistIdRef.current = gistId
tRef.current = t
oneGistRef.current = oneGist
patchSettingsRef.current = patchSettings
})

const syncRef = useRef<any>(null)
Expand All @@ -42,18 +55,25 @@ export function useGistSync(settings: Setting | null) {
const _gistFiles = gistFilesRef.current
const _t = tRef.current
const _oneGist = oneGistRef.current
const _patchSettings = patchSettingsRef.current

if (!currentSettings || !_gistId || _mutation.isPending) {
return
}

// 核心不变量 1:本地存在未推送修改 ⇔ updatedAt > remoteUpdatedAt
const localRemoteUpdatedAt = currentSettings.remoteUpdatedAt ?? 0
if ((currentSettings.updatedAt ?? 0) <= localRemoteUpdatedAt) {
return
}

let fileName = currentSettings.gist?.fileName

// Attempt to find filename if missing (legacy support)
// 兼容旧配置:未记录 fileName 时回退(注意:默认文件名是 candi-tab-settings.json)
if (!fileName && _gistFiles) {
const keys = Object.keys(_gistFiles)
if ('candi_tab_settings.json' in _gistFiles) {
fileName = 'candi_tab_settings.json'
if (DEFAULT_FILE_NAME in _gistFiles) {
fileName = DEFAULT_FILE_NAME
}
else if (keys.length === 1) {
fileName = keys[0]
Expand All @@ -64,17 +84,32 @@ export function useGistSync(settings: Setting | null) {
return
}

// 只有当本地比远程更新时才推送
// 如果远程更新或相等,不需要推送
// 推送前主动刷新远程,做并发冲突校验:避免覆盖其他设备的最新修改
let refetchedRemoteUpdatedAt = toMs((_oneGist.data as any)?.updated_at)
try {
const refetched = await _oneGist.refetch()
refetchedRemoteUpdatedAt = toMs((refetched.data as any)?.updated_at) || refetchedRemoteUpdatedAt
}
catch (err) {
console.warn('[sync] refetch before push failed', err)
}

// 远程比我们记录的基线更新 → 另一台设备改过;不推送,把决策权交给 useSettings 的合并 effect
if (refetchedRemoteUpdatedAt && refetchedRemoteUpdatedAt > localRemoteUpdatedAt) {
toast.error(_t('Remote has newer changes, please retry'))
return
}

// 同时校验:refetched 内容里的 updatedAt 也不能比本地新(防御性兜底)
const remoteSettings = parseGistContent(_oneGist.data, fileName)
if (!remoteSettings || currentSettings.updatedAt <= remoteSettings.updatedAt) {
if (remoteSettings && remoteSettings.updatedAt > currentSettings.updatedAt) {
return
}

const toastId = toast.loading(_t('syncing'))

try {
await _mutation.mutateAsync({
const result = await _mutation.mutateAsync({
gist_id: _gistId,
description: currentSettings.gist?.description,
files: {
Expand All @@ -83,6 +118,11 @@ export function useGistSync(settings: Setting | null) {
},
},
})

// 推送成功后:用 GitHub 返回的服务端 updated_at 更新本地基线,避免下次重复推送 / 错误地认为远程被外部改过
const pushedAt = toMs((result.data as any)?.updated_at) || Date.now()
_patchSettings({ remoteUpdatedAt: pushedAt })

toast.success(_t('sync success'), { id: toastId })
}
catch (error) {
Expand All @@ -96,20 +136,11 @@ export function useGistSync(settings: Setting | null) {
}
}, [])

// Use a ref to track if it's the first mount to avoid syncing on load
const isFirstMount = useRef(true)

// 首次挂载也允许触发 sync:之前的 isFirstMount 跳过会让"本地有未推送修改但还没改过"的场景永远不上行
useEffect(() => {
if (isFirstMount.current) {
isFirstMount.current = false
return
}

if (settings) {
syncRef.current?.(settings)
}

// Cleanup debounce on unmount
return () => {
syncRef.current?.cancel()
}
Expand Down
100 changes: 82 additions & 18 deletions src/hooks/useSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import _ from 'lodash'
import update from 'lodash/fp/update'
import { useCallback, useEffect, useRef, useState } from 'react'

import toast from 'react-hot-toast'
import { useTranslation } from 'react-i18next'
import { gid } from '@/utils/gid'
import parseGistContent from '@/utils/parseGistContent'
Expand Down Expand Up @@ -46,70 +47,133 @@ const setIds = update('links')((blocks: Block[]) =>
}),
)

function toMs(iso: string | undefined): number {
if (!iso) {
return 0
}
const ms = new Date(iso).getTime()
return Number.isFinite(ms) ? ms : 0
}

// 排除瞬态/同步字段后比较内容是否相同,避免"自己刚推送的回声"触发覆盖
function isSameContent(a: Setting | null | undefined, b: Setting | null | undefined): boolean {
if (!a || !b) {
return false
}
const omitKeys: (keyof Setting)[] = ['updatedAt', 'createdAt', 'remoteUpdatedAt', 'gist']
return _.isEqual(_.omit(a, omitKeys as string[]), _.omit(b, omitKeys as string[]))
}

export default function useSettings(): [
Setting | null,
(settings: Setting) => void,
(patch: Partial<Setting>) => void,
] {
const { i18n } = useTranslation()
const { i18n, t } = useTranslation()
const [settings, setSettings] = useState<Setting | null>(null)
const settingsRef = useRef<Setting | null>(null)
const localLoadedRef = useRef(false)
const gist = settings?.gist || ({} as any)

const oneGist = useGistOne(gist.id || settings?.gistId)

// 始终保持 ref 与最新 settings 同步,给异步逻辑读取
useEffect(() => {
settingsRef.current = settings
}, [settings])

useEffect(() => {
load().then((result) => {
const newSettings = setIds({ ...defaultSettings, ...result })
localLoadedRef.current = true
settingsRef.current = newSettings
setSettings(newSettings)
})
}, [])

// 远程 gist 配置合并:仅在本地加载完成后才比较时间戳
// 远程 Gist → 本地的合并:以 GitHub 服务端 updated_at 作为权威时间戳,避免本地时钟漂移导致漏同步
useEffect(() => {
if (!oneGist.isSuccess || !localLoadedRef.current) {
return
}
const newSettings = parseGistContent(oneGist.data!, settings?.gist?.fileName)

if (!newSettings) {
const current = settingsRef.current
const remoteServerUpdatedAt = toMs((oneGist.data as any)?.updated_at)
if (!remoteServerUpdatedAt) {
return
}

const localRemoteUpdatedAt = current?.remoteUpdatedAt ?? 0

// 远程 updated_at 与本地记录的"上次同步时的服务端时间戳"一致,说明远程未变更
if (remoteServerUpdatedAt === localRemoteUpdatedAt) {
return
}

// 如果本地更新或时间戳相等,不覆盖本地设置
if (settings?.updatedAt && newSettings.updatedAt <= settings?.updatedAt) {
const parsed = parseGistContent(oneGist.data!, current?.gist?.fileName)
if (!parsed) {
return
}
else if (settings?.createdAt && newSettings.createdAt && newSettings.createdAt <= settings?.createdAt) {
// 兼容旧配置

// 内容与本地一致(典型场景:刚推送完成后远程回来的同一份数据),只刷新基线
if (isSameContent(parsed, current)) {
const next = { ...current!, remoteUpdatedAt: remoteServerUpdatedAt }
settingsRef.current = next
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
setSettings(next)
save(next)
return
}

newSettings.gist = {
...newSettings.gist,
// 本地存在未推送修改且与远程内容不同 → 真正的冲突。远程优先,避免静默丢弃他人在远程的修改。
const hasLocalUnpushed = (current?.updatedAt ?? 0) > localRemoteUpdatedAt
if (hasLocalUnpushed) {
toast.error(t('Remote changes detected, local edits discarded'))
}

const merged: Setting = {
...parsed,
remoteUpdatedAt: remoteServerUpdatedAt,
}
merged.gist = {
...(parsed.gist || {}),
..._.pick(oneGist.data, ['description', 'id']),
}

settingsRef.current = merged
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
setSettings(newSettings)
save(newSettings)
setSettings(merged)
save(merged)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [oneGist.data, oneGist.isSuccess])

const updateSettings = useCallback(
async (newSettings: Setting) => {
i18n.changeLanguage(newSettings?.general?.language || chrome?.i18n?.getUILanguage() || 'en-US')
const _value = {
// 单调递增的本地业务时间戳:max(Date.now(), prev+1),避免时钟回拨/同毫秒覆盖
const prevUpdatedAt = settingsRef.current?.updatedAt ?? 0
const _value: Setting = {
...newSettings,
updatedAt: Date.now(),
updatedAt: Math.max(Date.now(), prevUpdatedAt + 1),
}
setSettings(() => {
return _value
})
settingsRef.current = _value
setSettings(() => _value)
save(_value)
},
[i18n],
)

return [settings, updateSettings]
// 局部更新(不动 updatedAt):用于推送/拉取流程回写 remoteUpdatedAt 等同步元数据
const patchSettings = useCallback((patch: Partial<Setting>) => {
const current = settingsRef.current
if (!current) {
return
}
const next = { ...current, ...patch }
settingsRef.current = next
setSettings(next)
save(next)
}, [])

return [settings, updateSettings, patchSettings]
}
2 changes: 2 additions & 0 deletions src/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@
"sync success": "Synchronized",
"sync failed": "Synchronization failed",
"syncing": "Synchronizing...",
"Remote changes detected, local edits discarded": "Remote changes detected. Your unsaved local edits were discarded.",
"Remote has newer changes, please retry": "Remote has newer changes; pulling latest, please retry your edit.",
"built in color": "Built-in color",
"custom color": "Custom color",
"proceed": "Proceed",
Expand Down
2 changes: 2 additions & 0 deletions src/locales/ja-JP.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@
"sync success": "同期しました",
"sync failed": "同期に失敗しました",
"syncing": "同期中...",
"Remote changes detected, local edits discarded": "リモートに更新があります。ローカルの未保存の編集は破棄されました。",
"Remote has newer changes, please retry": "リモートに新しい更新があります。最新を取得しました。再度編集してください。",
"built in color": "プリセットカラー",
"custom color": "カスタムカラー",
"proceed": "続行",
Expand Down
2 changes: 2 additions & 0 deletions src/locales/ko-KR.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@
"sync success": "동기화 성공",
"sync failed": "동기화 실패",
"syncing": "동기화 중...",
"Remote changes detected, local edits discarded": "원격에 변경 사항이 감지되었습니다. 저장되지 않은 로컬 편집은 폐기되었습니다.",
"Remote has newer changes, please retry": "원격에 새로운 변경 사항이 있습니다. 최신을 가져왔습니다. 다시 시도해 주세요.",
"built in color": "기본 색상",
"custom color": "사용자 지정 색상",
"proceed": "계속",
Expand Down
2 changes: 2 additions & 0 deletions src/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@
"sync success": "已同步",
"sync failed": "同步失败",
"syncing": "同步中...",
"Remote changes detected, local edits discarded": "检测到远程有更新,本地未提交的修改已被覆盖",
"Remote has newer changes, please retry": "远程已有更新,正在拉取,请稍后重试修改",
"built in color": "内置颜色",
"custom color": "自定义颜色",
"proceed": "继续",
Expand Down
2 changes: 2 additions & 0 deletions src/locales/zh-TR.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@
"sync success": "已同步",
"sync failed": "同步失敗",
"syncing": "同步中...",
"Remote changes detected, local edits discarded": "偵測到遠端有更新,本機未提交的修改已被覆蓋",
"Remote has newer changes, please retry": "遠端已有更新,正在拉取,請稍後重試修改",
"built in color": "內建顏色",
"custom color": "自訂顏色",
"proceed": "繼續",
Expand Down
Loading
Loading