Files
claude 0caa4ff144
Build and Deploy Verso / deploy (push) Successful in 12m35s
feat(git-sync): configurable branch, first-push confirmation, PDF-only replace
Branch:
- New "Branch" text field (default: main) stored in gitSyncBranch on
  the project. Used for git fetch, symbolic-ref, and push target.
  Validated server-side: alphanumeric + / _ . - only, no ..

First-push confirmation:
- When pushFiles is enabled and the user hasn't confirmed for the
  current subPath, clicking "Push now" shows an inline warning:
  "The <subPath> directory will be completely replaced …"
  with "Yes, push and replace" / "Cancel" buttons.
- Confirmation is stored in localStorage keyed by projectId+subPath,
  so it's shown again if the subPath is changed.
- Auto-push bypasses the dialog (user already opted in explicitly).

PDF-only replace:
- Already correct with the fetch-first approach: only the specific
  file at pdfPath is written; other files in the same directory
  (e.g. output/old.pdf) are preserved from the fetched remote state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 07:23:53 +00:00

329 lines
10 KiB
TypeScript

import { useState, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import getMeta from '@/utils/meta'
import { postJSON } from '@/infrastructure/fetch-json'
import { useDetachCompileContext as useCompileContext } from '@/shared/context/detach-compile-context'
import OLButton from '@/shared/components/ol/ol-button'
import OLFormControl from '@/shared/components/ol/ol-form-control'
import OLFormGroup from '@/shared/components/ol/ol-form-group'
import OLFormLabel from '@/shared/components/ol/ol-form-label'
import { FormCheck } from 'react-bootstrap'
import MaterialIcon from '@/shared/components/material-icon'
import OLNotification from '@/shared/components/ol/ol-notification'
type Action = 'save' | 'push' | 'pull'
type Status = 'idle' | 'busy' | 'success' | 'error' | 'confirm'
const projectId = getMeta('ol-project_id')
const AUTO_PUSH_KEY = `gitSync.autoPushOnCompile.${projectId}`
// Tracks the last subPath the user confirmed clearing on push
const CONFIRMED_SUBPATH_KEY = `gitSync.confirmedSubPath.${projectId}`
export default function GitSyncWidget() {
const { t } = useTranslation()
const { compiling, error: compileError, pdfFile, clsiServerId } =
useCompileContext()
const [remoteUrl, setRemoteUrl] = useState(getMeta('ol-gitRemote') ?? '')
const [subPath, setSubPath] = useState(getMeta('ol-gitSyncPath') ?? '')
const [pdfPath, setPdfPath] = useState(getMeta('ol-gitSyncPdfPath') ?? '')
const [branch, setBranch] = useState(getMeta('ol-gitSyncBranch') ?? 'main')
const [pushFiles, setPushFiles] = useState(getMeta('ol-gitSyncPushFiles') ?? true)
const [pushPdf, setPushPdf] = useState(getMeta('ol-gitSyncPushPdf') ?? true)
const [autoPush, setAutoPush] = useState(
() => localStorage.getItem(AUTO_PUSH_KEY) === 'true'
)
const [lastAction, setLastAction] = useState<Action>('save')
const [status, setStatus] = useState<Status>('idle')
const [errorMsg, setErrorMsg] = useState('')
// Pending push args while waiting for user to confirm subPath reset
const pendingPushRef = useRef<{ buildId?: string; serverId?: string } | null>(null)
const wasCompilingRef = useRef(false)
const isBusyRef = useRef(false)
useEffect(() => {
isBusyRef.current = status === 'busy'
}, [status])
useEffect(() => {
const wasCompiling = wasCompilingRef.current
wasCompilingRef.current = compiling
if (
wasCompiling &&
!compiling &&
!compileError &&
autoPush &&
!isBusyRef.current
) {
doPush(pdfFile?.build, clsiServerId)
}
}, [compiling]) // eslint-disable-line react-hooks/exhaustive-deps
async function saveConfig() {
await postJSON(`/project/${projectId}/git-sync`, {
body: { remoteUrl, subPath, pdfPath, branch, pushFiles, pushPdf },
})
}
function extractError(err: any): string {
return err?.data?.error ?? err?.message ?? String(err)
}
function needsConfirmation(): boolean {
if (!pushFiles) return false
const confirmed = localStorage.getItem(CONFIRMED_SUBPATH_KEY)
return confirmed !== subPath
}
function confirmPush() {
localStorage.setItem(CONFIRMED_SUBPATH_KEY, subPath)
const args = pendingPushRef.current ?? {}
pendingPushRef.current = null
setStatus('idle')
executePush(args.buildId, args.serverId)
}
function cancelConfirm() {
pendingPushRef.current = null
setStatus('idle')
}
async function executePush(buildId?: string, serverId?: string) {
setLastAction('push')
setStatus('busy')
setErrorMsg('')
try {
await saveConfig()
await postJSON(`/project/${projectId}/git-sync/push`, {
body: { buildId, clsiServerId: serverId },
})
setStatus('success')
} catch (err: any) {
setStatus('error')
setErrorMsg(extractError(err))
}
}
function doPush(buildId?: string, serverId?: string) {
if (needsConfirmation()) {
pendingPushRef.current = { buildId, serverId }
setStatus('confirm')
return
}
executePush(buildId, serverId)
}
async function handleSave(e: React.FormEvent) {
e.preventDefault()
setLastAction('save')
setStatus('busy')
setErrorMsg('')
try {
await saveConfig()
setStatus('success')
} catch (err: any) {
setStatus('error')
setErrorMsg(extractError(err))
}
}
function handlePush() {
doPush(pdfFile?.build, clsiServerId)
}
async function handlePull() {
setLastAction('pull')
setStatus('busy')
setErrorMsg('')
try {
await saveConfig()
await postJSON(`/project/${projectId}/git-sync/pull`, { body: {} })
setStatus('success')
} catch (err: any) {
setStatus('error')
setErrorMsg(extractError(err))
}
}
function handleAutoPushToggle(e: React.ChangeEvent<HTMLInputElement>) {
const checked = e.target.checked
setAutoPush(checked)
localStorage.setItem(AUTO_PUSH_KEY, String(checked))
}
const isBusy = status === 'busy'
const isConfirm = status === 'confirm'
const nothingToPush = !pushFiles && (!pushPdf || !pdfPath.trim())
const managedArea = subPath.trim() || t('git_sync_repo_root')
return (
<div className="git-sync-widget">
<div className="git-sync-widget-header">
<MaterialIcon type="merge" />
<span className="git-sync-widget-title">{t('git_sync')}</span>
</div>
<p className="git-sync-widget-description">{t('git_sync_description')}</p>
{isConfirm ? (
<div className="git-sync-confirm">
<OLNotification
type="warning"
content={t('git_sync_confirm_reset', { path: managedArea })}
/>
<div className="git-sync-widget-actions" style={{ marginTop: 'var(--spacing-04)' }}>
<OLButton
type="button"
variant="danger"
size="sm"
onClick={confirmPush}
>
{t('git_sync_confirm_push')}
</OLButton>
<OLButton
type="button"
variant="secondary"
size="sm"
onClick={cancelConfirm}
>
{t('cancel')}
</OLButton>
</div>
</div>
) : (
<form onSubmit={handleSave}>
<OLFormGroup controlId="git-sync-remote-url">
<OLFormLabel>{t('git_sync_remote_url')}</OLFormLabel>
<OLFormControl
type="text"
value={remoteUrl}
onChange={e => setRemoteUrl(e.target.value)}
placeholder="https://user:token@github.com/org/repo.git"
disabled={isBusy}
/>
</OLFormGroup>
<OLFormGroup controlId="git-sync-branch">
<OLFormLabel>{t('git_sync_branch')}</OLFormLabel>
<OLFormControl
type="text"
value={branch}
onChange={e => setBranch(e.target.value)}
placeholder="main"
disabled={isBusy}
/>
</OLFormGroup>
<OLFormGroup controlId="git-sync-sub-path">
<OLFormLabel>{t('git_sync_sub_path')}</OLFormLabel>
<OLFormControl
type="text"
value={subPath}
onChange={e => setSubPath(e.target.value)}
placeholder={t('git_sync_sub_path_placeholder')}
disabled={isBusy}
/>
</OLFormGroup>
<OLFormGroup controlId="git-sync-push-files">
<FormCheck
type="switch"
id="git-sync-push-files"
label={t('git_sync_push_files')}
checked={pushFiles}
onChange={e => setPushFiles(e.target.checked)}
disabled={isBusy}
/>
</OLFormGroup>
<OLFormGroup controlId="git-sync-pdf-path">
<OLFormLabel>{t('git_sync_pdf_path')}</OLFormLabel>
<OLFormControl
type="text"
value={pdfPath}
onChange={e => setPdfPath(e.target.value)}
placeholder={t('git_sync_pdf_path_placeholder')}
disabled={isBusy}
/>
</OLFormGroup>
<OLFormGroup controlId="git-sync-push-pdf">
<FormCheck
type="switch"
id="git-sync-push-pdf"
label={t('git_sync_push_pdf')}
checked={pushPdf}
onChange={e => setPushPdf(e.target.checked)}
disabled={isBusy || !pdfPath.trim()}
/>
</OLFormGroup>
<OLFormGroup controlId="git-sync-auto-push">
<FormCheck
type="switch"
id="git-sync-auto-push"
label={t('git_sync_auto_push_on_compile')}
checked={autoPush}
onChange={handleAutoPushToggle}
disabled={isBusy || !remoteUrl.trim() || nothingToPush}
/>
</OLFormGroup>
<div className="git-sync-widget-actions">
<OLButton
type="submit"
variant="secondary"
size="sm"
disabled={isBusy || !remoteUrl.trim()}
isLoading={isBusy && lastAction === 'save'}
loadingLabel={t('saving')}
>
{t('save')}
</OLButton>
<OLButton
type="button"
variant="primary"
size="sm"
onClick={handlePush}
disabled={isBusy || !remoteUrl.trim() || nothingToPush}
isLoading={isBusy && lastAction === 'push'}
loadingLabel={t('git_sync_pushing')}
>
{t('git_sync_push_now')}
</OLButton>
<OLButton
type="button"
variant="secondary"
size="sm"
onClick={handlePull}
disabled={isBusy || !remoteUrl.trim()}
isLoading={isBusy && lastAction === 'pull'}
loadingLabel={t('git_sync_pulling')}
>
{t('git_sync_pull_now')}
</OLButton>
</div>
{status === 'success' && (
<OLNotification
type="success"
content={
lastAction === 'push'
? t('git_sync_push_success')
: lastAction === 'pull'
? t('git_sync_pull_success')
: t('git_sync_saved')
}
/>
)}
{status === 'error' && (
<OLNotification type="error" content={errorMsg} />
)}
</form>
)}
</div>
)
}