feat(w5): ship v2.0.0 platform sync, undo persistence, and updates
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import './styles/themes.css'
|
||||
import './styles/globals.css'
|
||||
import './styles/layout.css'
|
||||
import { TopBar } from '@renderer/components/layout/TopBar'
|
||||
import { UpdateBanner } from '@renderer/components/settings/UpdateBanner'
|
||||
import { HomePage } from '@renderer/components/home/HomePage'
|
||||
import { EditorLayout } from '@renderer/components/layout/EditorLayout'
|
||||
import { SettingsPage } from '@renderer/components/settings/SettingsPage'
|
||||
@@ -31,6 +32,7 @@ import { insertLandmarkInEditor } from '@renderer/lib/editor-commands'
|
||||
import { isSpeaking, speakText, stopSpeaking } from '@renderer/lib/tts-controller'
|
||||
import { useAiStore } from '@renderer/stores/useAiStore'
|
||||
import { useExportStore } from '@renderer/stores/useExportStore'
|
||||
import { CrashReportDialog } from '@renderer/components/settings/CrashReportDialog'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
|
||||
function AppInner(): React.JSX.Element {
|
||||
@@ -48,6 +50,13 @@ function AppInner(): React.JSX.Element {
|
||||
const { activeTabId, openTabs } = useTabStore()
|
||||
const openBook = useBookStore((s) => s.openBook)
|
||||
const [showOnboarding, setShowOnboarding] = useState(false)
|
||||
const [crashDialogOpen, setCrashDialogOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
void ipcCall(() => window.electronAPI.log.hadCrash()).then((had) => {
|
||||
if (had) setCrashDialogOpen(true)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
@@ -202,6 +211,7 @@ function AppInner(): React.JSX.Element {
|
||||
return (
|
||||
<div id="app" className={focusMode ? 'focus-mode' : undefined} data-testid="app-root">
|
||||
<TopBar />
|
||||
<UpdateBanner />
|
||||
<div id="main-area">
|
||||
{view === 'home' && <HomePage />}
|
||||
{view === 'editor' && <EditorLayout />}
|
||||
@@ -215,6 +225,7 @@ function AppInner(): React.JSX.Element {
|
||||
<ImportBookModal />
|
||||
<ExportAllModal />
|
||||
<TimelineModal />
|
||||
<CrashReportDialog open={crashDialogOpen} onClose={() => setCrashDialogOpen(false)} />
|
||||
<NamingCheckModal open={namingCheckOpen} onClose={() => setNamingCheckOpen(false)} />
|
||||
{toast && <div className="toast">{toast}</div>}
|
||||
</div>
|
||||
|
||||
@@ -87,6 +87,13 @@ export function TipTapEditor({ target, bookId }: TipTapEditorProps): React.JSX.E
|
||||
const updateInspirationLocal = useBookStore((s) => s.updateInspirationLocal)
|
||||
const settings = useBookStore((s) => s.settings)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>()
|
||||
const undoTimerRef = useRef<ReturnType<typeof setTimeout>>()
|
||||
const lastPushedContentRef = useRef<string | null>(null)
|
||||
const persistentUndoRef = useRef<{ entries: import('@shared/undo').UndoStackEntry[]; index: number }>({
|
||||
entries: [],
|
||||
index: -1
|
||||
})
|
||||
const editorRef = useRef<ReturnType<typeof useEditor>>(null)
|
||||
const targetKeyRef = useRef<string | null>(null)
|
||||
const [mentionOpen, setMentionOpen] = useState(false)
|
||||
const [mentionQuery, setMentionQuery] = useState('')
|
||||
@@ -139,6 +146,26 @@ export function TipTapEditor({ target, bookId }: TipTapEditorProps): React.JSX.E
|
||||
}
|
||||
const text = ed.getText()
|
||||
const chapterCount = countPlain(text)
|
||||
if (isChapter && bookId && target?.kind === 'chapter') {
|
||||
clearTimeout(undoTimerRef.current)
|
||||
undoTimerRef.current = setTimeout(() => {
|
||||
const html = ed.getHTML()
|
||||
const prev = lastPushedContentRef.current
|
||||
if (prev !== null && prev !== html) {
|
||||
void ipcCall(() =>
|
||||
window.electronAPI.undo.push(bookId, target.id, { type: 'doc', content: prev })
|
||||
).then(() =>
|
||||
ipcCall(() => window.electronAPI.undo.list(bookId, target.id)).then((entries) => {
|
||||
persistentUndoRef.current = {
|
||||
entries,
|
||||
index: entries.length > 0 ? entries.length - 1 : -1
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
lastPushedContentRef.current = html
|
||||
}, 1500)
|
||||
}
|
||||
if (isChapter && target?.kind === 'chapter') {
|
||||
const ch = chapters.find((c) => c.id === target.id)
|
||||
const volId = ch?.volumeId
|
||||
@@ -185,12 +212,26 @@ export function TipTapEditor({ target, bookId }: TipTapEditorProps): React.JSX.E
|
||||
const pos = coords?.pos ?? view.state.selection.from
|
||||
view.dispatch(view.state.tr.insertText(text, pos))
|
||||
return true
|
||||
},
|
||||
handleKeyDown: (_view, event) => {
|
||||
if (!(event.ctrlKey || event.metaKey) || event.key !== 'z' || event.shiftKey) return false
|
||||
const ed = editorRef.current
|
||||
if (!ed || ed.can().undo()) return false
|
||||
const slot = persistentUndoRef.current
|
||||
const entry = slot.entries[slot.index]
|
||||
if (!entry) return false
|
||||
slot.index -= 1
|
||||
ed.commands.setContent(entry.operation.content)
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
}
|
||||
},
|
||||
[target?.kind, target?.id, i18n.language]
|
||||
[target?.kind, target?.id, i18n.language, isChapter, bookId]
|
||||
)
|
||||
|
||||
editorRef.current = editor
|
||||
|
||||
const save = useCallback(async () => {
|
||||
if (!bookId || !target || !editor) return
|
||||
setSaving(true)
|
||||
@@ -356,6 +397,15 @@ export function TipTapEditor({ target, bookId }: TipTapEditorProps): React.JSX.E
|
||||
targetKeyRef.current = key
|
||||
|
||||
editor.commands.setContent(content || '')
|
||||
lastPushedContentRef.current = content || ''
|
||||
if (bookId && target.kind === 'chapter') {
|
||||
void ipcCall(() => window.electronAPI.undo.list(bookId, target.id)).then((entries) => {
|
||||
persistentUndoRef.current = {
|
||||
entries,
|
||||
index: entries.length > 0 ? entries.length - 1 : -1
|
||||
}
|
||||
})
|
||||
}
|
||||
if (target.kind === 'chapter') {
|
||||
const ch = chapters.find((c) => c.id === target.id)
|
||||
const pos = Math.min(ch?.cursorOffset ?? 0, editor.state.doc.content.size)
|
||||
|
||||
@@ -148,6 +148,38 @@ export function AiSettingsPage(): React.JSX.Element {
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="setting-item" style={{ flexDirection: 'column', alignItems: 'flex-start' }}>
|
||||
<span>{t('ai.settings.builtinSkills')}</span>
|
||||
<ul className="builtin-skill-list" data-testid="builtin-skills-list">
|
||||
<li>{t('ai.settings.skillContinue')}</li>
|
||||
<li>{t('ai.settings.skillPolish')}</li>
|
||||
<li>{t('ai.settings.skillProofread')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="setting-item" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: 8 }}>
|
||||
<span>{t('ai.settings.mcpConnector')}</span>
|
||||
<input
|
||||
className="form-control form-control--wide"
|
||||
data-testid="mcp-connector-url"
|
||||
value={settings.mcpConnectorUrl ?? ''}
|
||||
onChange={(e) => void settings.update({ mcpConnectorUrl: e.target.value })}
|
||||
placeholder="https://mcp.example.com"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-testid="mcp-test-connection"
|
||||
onClick={() =>
|
||||
void ipcCall(() =>
|
||||
window.electronAPI.mcp.testConnection(settings.mcpConnectorUrl ?? '')
|
||||
).then((r) => showToast(r.message))
|
||||
}
|
||||
>
|
||||
{t('ai.settings.mcpTest')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="setting-item" style={{ flexDirection: 'column', alignItems: 'flex-start' }}>
|
||||
<span>{t('ai.settings.customSlashCommands')}</span>
|
||||
<div className="custom-slash-list" data-testid="custom-slash-commands">
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
|
||||
interface CrashReportDialogProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function CrashReportDialog({ open, onClose }: CrashReportDialogProps): React.JSX.Element | null {
|
||||
const { t } = useTranslation()
|
||||
if (!open) return null
|
||||
|
||||
const handleSend = async (): Promise<void> => {
|
||||
await ipcCall(() => window.electronAPI.log.sendReport())
|
||||
await ipcCall(() => window.electronAPI.log.clearCrash())
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleDismiss = async (): Promise<void> => {
|
||||
await ipcCall(() => window.electronAPI.log.clearCrash())
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" data-testid="crash-report-modal" role="dialog" aria-modal="true">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h3>{t('crash.title')}</h3>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<p>{t('crash.desc')}</p>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn btn-primary" data-testid="crash-send-report" onClick={() => void handleSend()}>
|
||||
{t('crash.send')}
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={() => void handleDismiss()}>
|
||||
{t('crash.dismiss')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||
|
||||
export function ExtensionsSettingsTab(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const settings = useSettingsStore()
|
||||
|
||||
return (
|
||||
<div data-testid="extensions-settings-tab">
|
||||
<h4>{t('extensions.title')}</h4>
|
||||
<p className="text-muted" style={{ marginBottom: 12 }}>
|
||||
{t('extensions.desc')}
|
||||
</p>
|
||||
<div className="setting-item">
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="extensions-dev-mode"
|
||||
checked={settings.extensionDevMode === true}
|
||||
onChange={(e) => void settings.update({ extensionDevMode: e.target.checked })}
|
||||
/>
|
||||
{t('extensions.devMode')}
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-muted" style={{ fontSize: 12 }}>
|
||||
{t('extensions.stubHint')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { ShortcutEditor } from '@renderer/components/settings/ShortcutEditor'
|
||||
import { AiSettingsPage } from '@renderer/components/settings/AiSettingsPage'
|
||||
import { TemplateSettingsTab } from '@renderer/components/settings/TemplateSettingsTab'
|
||||
import { BackupSettingsTab } from '@renderer/components/settings/BackupSettingsTab'
|
||||
import { SyncSettingsTab } from '@renderer/components/settings/SyncSettingsTab'
|
||||
import { ExtensionsSettingsTab } from '@renderer/components/settings/ExtensionsSettingsTab'
|
||||
import { SubmissionPresetTab } from '@renderer/components/settings/SubmissionPresetTab'
|
||||
|
||||
const THEMES: { id: ThemeId; key: string }[] = [
|
||||
@@ -22,7 +24,7 @@ export function SettingsPage(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const settings = useSettingsStore()
|
||||
const [section, setSection] = useState<
|
||||
'general' | 'ai' | 'templates' | 'submission' | 'backup' | 'shortcuts' | 'about'
|
||||
'general' | 'ai' | 'templates' | 'submission' | 'backup' | 'extensions' | 'shortcuts' | 'about'
|
||||
>('general')
|
||||
|
||||
return (
|
||||
@@ -72,6 +74,15 @@ export function SettingsPage(): React.JSX.Element {
|
||||
>
|
||||
{t('settings.backup')}
|
||||
</div>
|
||||
<div
|
||||
className={`settings-nav-item ${section === 'extensions' ? 'active' : ''}`}
|
||||
onClick={() => setSection('extensions')}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-testid="settings-nav-extensions"
|
||||
>
|
||||
{t('settings.extensions')}
|
||||
</div>
|
||||
<div
|
||||
className={`settings-nav-item ${section === 'shortcuts' ? 'active' : ''}`}
|
||||
onClick={() => setSection('shortcuts')}
|
||||
@@ -305,11 +316,18 @@ export function SettingsPage(): React.JSX.Element {
|
||||
{section === 'ai' && <AiSettingsPage />}
|
||||
{section === 'templates' && <TemplateSettingsTab />}
|
||||
{section === 'submission' && <SubmissionPresetTab />}
|
||||
{section === 'backup' && <BackupSettingsTab />}
|
||||
{section === 'backup' && (
|
||||
<>
|
||||
<BackupSettingsTab />
|
||||
<hr style={{ margin: '24px 0', borderColor: 'var(--border-color)' }} />
|
||||
<SyncSettingsTab />
|
||||
</>
|
||||
)}
|
||||
{section === 'extensions' && <ExtensionsSettingsTab />}
|
||||
{section === 'shortcuts' && <ShortcutEditor />}
|
||||
{section === 'about' && (
|
||||
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
||||
{t('app.name')} v1.5.0
|
||||
{t('app.name')} v2.0.0
|
||||
<br />
|
||||
{t('app.tagline')}
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface SyncConflictModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onChoose: (choice: 'local' | 'remote' | 'cancel') => void
|
||||
}
|
||||
|
||||
export function SyncConflictModal({
|
||||
open,
|
||||
onClose,
|
||||
onChoose
|
||||
}: SyncConflictModalProps): React.JSX.Element | null {
|
||||
const { t } = useTranslation()
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" data-testid="sync-conflict-modal" role="dialog" aria-modal="true">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h3>{t('sync.conflictTitle')}</h3>
|
||||
<button type="button" className="modal-close" onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<p>{t('sync.conflictDesc')}</p>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn" onClick={() => onChoose('remote')}>
|
||||
{t('sync.useRemote')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={() => onChoose('local')}>
|
||||
{t('sync.useLocal')}
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={() => onChoose('cancel')}>
|
||||
{t('dialog.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { SyncConfig, SyncMode, SyncProgressEvent, SyncSchedule } from '@shared/sync'
|
||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
import { SyncConflictModal } from '@renderer/components/settings/SyncConflictModal'
|
||||
|
||||
export function SyncSettingsTab(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const showToast = useAppStore((s) => s.showToast)
|
||||
const [mode, setMode] = useState<SyncMode>('local-folder')
|
||||
const [schedule, setSchedule] = useState<SyncSchedule>('manual')
|
||||
const [localFolderPath, setLocalFolderPath] = useState('')
|
||||
const [webdavUrl, setWebdavUrl] = useState('')
|
||||
const [webdavUser, setWebdavUser] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const [progress, setProgress] = useState<SyncProgressEvent | null>(null)
|
||||
const [status, setStatus] = useState<SyncConfig | null>(null)
|
||||
const [conflictOpen, setConflictOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
void ipcCall(() => window.electronAPI.sync.status()).then((cfg) => {
|
||||
if (!cfg) return
|
||||
setStatus(cfg)
|
||||
setMode(cfg.mode)
|
||||
setSchedule(cfg.schedule)
|
||||
setLocalFolderPath(cfg.localFolderPath ?? '')
|
||||
setWebdavUrl(cfg.webdavUrl ?? '')
|
||||
setWebdavUser(cfg.webdavUser ?? '')
|
||||
})
|
||||
const unsub = window.electronAPI.onSyncProgress(setProgress)
|
||||
return unsub
|
||||
}, [])
|
||||
|
||||
const buildConfig = (): SyncConfig => ({
|
||||
mode,
|
||||
schedule,
|
||||
localFolderPath: localFolderPath || undefined,
|
||||
webdavUrl: webdavUrl || undefined,
|
||||
webdavUser: webdavUser || undefined,
|
||||
syncSalt: status?.syncSalt,
|
||||
passwordConfigured: status?.passwordConfigured,
|
||||
lastSyncAt: status?.lastSyncAt,
|
||||
lastSyncStatus: status?.lastSyncStatus
|
||||
})
|
||||
|
||||
const handleSaveConfig = async (): Promise<void> => {
|
||||
if (password.length < 8) {
|
||||
showToast(t('sync.passwordHint'))
|
||||
return
|
||||
}
|
||||
const cfg = await ipcCall(() =>
|
||||
window.electronAPI.sync.configure({ config: buildConfig(), password })
|
||||
)
|
||||
setStatus(cfg)
|
||||
showToast(t('sync.configSaved'))
|
||||
}
|
||||
|
||||
const handlePickFolder = async (): Promise<void> => {
|
||||
const path = await ipcCall(() => window.electronAPI.sync.pickFolder())
|
||||
if (path) setLocalFolderPath(path)
|
||||
}
|
||||
|
||||
const runSync = async (conflictResolution?: 'local' | 'remote' | 'cancel'): Promise<void> => {
|
||||
if (password.length < 8 && !status?.passwordConfigured) {
|
||||
showToast(t('sync.passwordHint'))
|
||||
return
|
||||
}
|
||||
setSyncing(true)
|
||||
setProgress(null)
|
||||
try {
|
||||
const result = await ipcCall(() =>
|
||||
window.electronAPI.sync.run({ password, conflictResolution })
|
||||
)
|
||||
if (result.status === 'conflict') {
|
||||
setConflictOpen(true)
|
||||
return
|
||||
}
|
||||
setStatus((s) => (s ? { ...s, lastSyncStatus: result.status, lastSyncAt: result.syncedAt } : s))
|
||||
showToast(t('sync.done'))
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
setProgress(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sync-settings" data-testid="sync-settings-tab">
|
||||
<h4>{t('sync.title')}</h4>
|
||||
<p className="text-muted" style={{ marginBottom: 12 }}>
|
||||
{t('sync.desc')}
|
||||
</p>
|
||||
<div className="setting-item">
|
||||
<span>{t('sync.mode')}</span>
|
||||
<select
|
||||
className="form-control"
|
||||
data-testid="sync-mode"
|
||||
value={mode}
|
||||
onChange={(e) => setMode(e.target.value as SyncMode)}
|
||||
>
|
||||
<option value="local-folder">{t('sync.modeLocal')}</option>
|
||||
<option value="webdav">{t('sync.modeWebdav')}</option>
|
||||
</select>
|
||||
</div>
|
||||
{mode === 'local-folder' ? (
|
||||
<div className="setting-item" style={{ gap: 8 }}>
|
||||
<input
|
||||
className="form-control form-control--wide"
|
||||
data-testid="sync-local-path"
|
||||
value={localFolderPath}
|
||||
onChange={(e) => setLocalFolderPath(e.target.value)}
|
||||
placeholder={t('sync.localPathPlaceholder')}
|
||||
/>
|
||||
<button type="button" className="btn" onClick={() => void handlePickFolder()}>
|
||||
{t('sync.pickFolder')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="setting-item">
|
||||
<input
|
||||
className="form-control form-control--wide"
|
||||
data-testid="sync-webdav-url"
|
||||
value={webdavUrl}
|
||||
onChange={(e) => setWebdavUrl(e.target.value)}
|
||||
placeholder={t('sync.webdavUrl')}
|
||||
/>
|
||||
</div>
|
||||
<div className="setting-item">
|
||||
<input
|
||||
className="form-control form-control--wide"
|
||||
data-testid="sync-webdav-user"
|
||||
value={webdavUser}
|
||||
onChange={(e) => setWebdavUser(e.target.value)}
|
||||
placeholder={t('sync.webdavUser')}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="setting-item">
|
||||
<span>{t('sync.schedule')}</span>
|
||||
<select
|
||||
className="form-control"
|
||||
data-testid="sync-schedule"
|
||||
value={schedule}
|
||||
onChange={(e) => setSchedule(e.target.value as SyncSchedule)}
|
||||
>
|
||||
<option value="manual">{t('sync.scheduleManual')}</option>
|
||||
<option value="hourly">{t('sync.scheduleHourly')}</option>
|
||||
<option value="daily">{t('sync.scheduleDaily')}</option>
|
||||
<option value="weekly">{t('sync.scheduleWeekly')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="setting-item">
|
||||
<input
|
||||
type="password"
|
||||
className="form-control form-control--wide"
|
||||
data-testid="sync-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t('sync.passwordPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className="setting-item" style={{ gap: 8 }}>
|
||||
<button type="button" className="btn" data-testid="sync-save-config" onClick={() => void handleSaveConfig()}>
|
||||
{t('sync.saveConfig')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
data-testid="sync-run-now"
|
||||
disabled={syncing}
|
||||
onClick={() => void runSync()}
|
||||
>
|
||||
{syncing ? t('sync.running') : t('sync.runNow')}
|
||||
</button>
|
||||
</div>
|
||||
{status?.lastSyncAt && (
|
||||
<p className="text-muted" data-testid="sync-last-status" style={{ fontSize: 12 }}>
|
||||
{t('sync.lastSync', {
|
||||
time: new Date(status.lastSyncAt).toLocaleString(),
|
||||
status: status.lastSyncStatus ?? 'idle'
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{progress && (
|
||||
<div className="import-progress" data-testid="sync-progress">
|
||||
<div className="import-progress-fill" style={{ width: `${progress.percent ?? 0}%` }} />
|
||||
<span>{progress.message}</span>
|
||||
</div>
|
||||
)}
|
||||
<SyncConflictModal
|
||||
open={conflictOpen}
|
||||
onClose={() => setConflictOpen(false)}
|
||||
onChoose={(choice) => {
|
||||
setConflictOpen(false)
|
||||
void runSync(choice)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
|
||||
export function UpdateBanner(): React.JSX.Element | null {
|
||||
const { t } = useTranslation()
|
||||
const [version, setVersion] = useState<string | null>(null)
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
const [ready, setReady] = useState(false)
|
||||
const [percent, setPercent] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const unsubAvail = window.electronAPI.onUpdateAvailable((p) => setVersion(p.version))
|
||||
const unsubProg = window.electronAPI.onUpdateProgress((p) => setPercent(Math.round(p.percent)))
|
||||
const unsubDone = window.electronAPI.onUpdateDownloaded(() => {
|
||||
setDownloading(false)
|
||||
setReady(true)
|
||||
})
|
||||
void ipcCall(() => window.electronAPI.update.check()).then((info) => {
|
||||
if (info?.version) setVersion(info.version)
|
||||
})
|
||||
return () => {
|
||||
unsubAvail()
|
||||
unsubProg()
|
||||
unsubDone()
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!version) return null
|
||||
|
||||
return (
|
||||
<div className="update-banner" data-testid="update-banner">
|
||||
<span>{t('update.available', { version })}</span>
|
||||
{!ready ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
data-testid="update-download"
|
||||
disabled={downloading}
|
||||
onClick={() => {
|
||||
setDownloading(true)
|
||||
void ipcCall(() => window.electronAPI.update.download())
|
||||
}}
|
||||
>
|
||||
{downloading ? t('update.downloading', { percent }) : t('update.download')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
data-testid="update-install"
|
||||
onClick={() => void ipcCall(() => window.electronAPI.update.install())}
|
||||
>
|
||||
{t('update.install')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -38,6 +38,8 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
enableComplianceCheck: false,
|
||||
complianceWords: [] as string[],
|
||||
customSlashCommands: [] as { id: string; name: string; template: string }[],
|
||||
mcpConnectorUrl: '',
|
||||
extensionDevMode: false,
|
||||
loaded: false,
|
||||
load: async () => {
|
||||
const data = await ipcCall(() => window.electronAPI.settings.get())
|
||||
|
||||
@@ -2574,3 +2574,25 @@
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.update-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 6px 12px;
|
||||
background: var(--accent-muted, rgba(59, 130, 246, 0.12));
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sync-settings h4 {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.builtin-skill-list {
|
||||
margin: 8px 0 0;
|
||||
padding-left: 20px;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user