From f22ee608a7f075e858a176da1bcc61e30cec1987 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 23 Jun 2026 21:07:13 +0000 Subject: [PATCH] feat(git-sync): auto-push on compile, PDF path, icon fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Icon: change rail tab from integration_instructions to merge - Auto-push on compile: toggle stored in localStorage, watches compile context; when compiling goes true→false with no error, triggers a push automatically (including the latest build's PDF if configured) - PDF destination path: new gitSyncPdfPath field; backend fetches the compiled PDF from CLSI (buildId + clsiServerId passed from frontend) and writes it at the configured path in the repo; silently skipped if no recent compile or field is blank - Push now always sends current buildId/clsiServerId so PDF is included without needing a separate save step Co-Authored-By: Claude Sonnet 4.6 --- .../Features/GitSync/GitSyncController.mjs | 32 ++++-- .../src/Features/GitSync/GitSyncHandler.mjs | 35 ++++++- .../Features/Project/ProjectController.mjs | 2 + services/web/app/src/models/Project.mjs | 1 + .../web/app/views/project/editor/_meta.pug | 1 + .../web/frontend/extracted-translations.json | 3 + .../ide-react/components/rail/rail.tsx | 2 +- .../integrations-panel/git-sync-widget.tsx | 97 ++++++++++++++++--- services/web/frontend/js/utils/meta.ts | 1 + services/web/locales/en.json | 3 + services/web/locales/fr.json | 3 + 11 files changed, 152 insertions(+), 28 deletions(-) diff --git a/services/web/app/src/Features/GitSync/GitSyncController.mjs b/services/web/app/src/Features/GitSync/GitSyncController.mjs index 2247546b87..a575b732d0 100644 --- a/services/web/app/src/Features/GitSync/GitSyncController.mjs +++ b/services/web/app/src/Features/GitSync/GitSyncController.mjs @@ -4,7 +4,7 @@ import { expressify } from '@overleaf/promise-utils' async function configureGitSync(req, res) { const projectId = req.params.project_id - const { remoteUrl, subPath = '' } = req.body + const { remoteUrl, subPath = '', pdfPath = '' } = req.body if (typeof remoteUrl !== 'string' || remoteUrl.trim() === '') { return res.status(400).json({ error: 'remoteUrl is required' }) @@ -15,15 +15,20 @@ async function configureGitSync(req, res) { return res.status(400).json({ error: 'remoteUrl must start with https:// or git@' }) } - // Normalise subPath: strip leading/trailing slashes, reject traversal - const trimmedPath = String(subPath).trim().replace(/^\/+|\/+$/g, '') - if (trimmedPath.includes('..')) { + const trimmedSubPath = String(subPath).trim().replace(/^\/+|\/+$/g, '') + if (trimmedSubPath.includes('..')) { return res.status(400).json({ error: 'subPath must not contain ..' }) } + const trimmedPdfPath = String(pdfPath).trim().replace(/^\/+/, '') + if (trimmedPdfPath.includes('..')) { + return res.status(400).json({ error: 'pdfPath must not contain ..' }) + } + await GitSyncHandler.setConfig(projectId, { remoteUrl: trimmedUrl, - subPath: trimmedPath, + subPath: trimmedSubPath, + pdfPath: trimmedPdfPath, }) logger.debug({ projectId }, 'git sync: config saved') res.sendStatus(204) @@ -31,15 +36,22 @@ async function configureGitSync(req, res) { async function pushToGit(req, res) { const projectId = req.params.project_id + const userId = req.session.user._id + const { buildId, clsiServerId } = req.body ?? {} - const { remoteUrl, subPath } = await GitSyncHandler.getConfig(projectId) + const { remoteUrl, subPath, pdfPath } = await GitSyncHandler.getConfig(projectId) if (!remoteUrl) { return res.status(400).json({ error: 'No git remote configured for this project' }) } logger.debug({ projectId }, 'git sync: starting push') try { - await GitSyncHandler.pushToRemote(projectId, remoteUrl, subPath) + await GitSyncHandler.pushToRemote(projectId, remoteUrl, subPath, { + pdfPath, + pdfBuildId: buildId, + pdfClsiServerId: clsiServerId, + userId, + }) res.sendStatus(204) } catch (err) { logger.warn({ err, projectId }, 'git sync: push failed') @@ -50,7 +62,11 @@ async function pushToGit(req, res) { async function getGitSyncConfig(req, res) { const projectId = req.params.project_id const config = await GitSyncHandler.getConfig(projectId) - res.json({ remoteUrl: config.remoteUrl ?? '', subPath: config.subPath }) + res.json({ + remoteUrl: config.remoteUrl ?? '', + subPath: config.subPath, + pdfPath: config.pdfPath, + }) } export default { diff --git a/services/web/app/src/Features/GitSync/GitSyncHandler.mjs b/services/web/app/src/Features/GitSync/GitSyncHandler.mjs index 55fbf55c54..da78b26a91 100644 --- a/services/web/app/src/Features/GitSync/GitSyncHandler.mjs +++ b/services/web/app/src/Features/GitSync/GitSyncHandler.mjs @@ -7,6 +7,7 @@ import { pipeline } from 'node:stream/promises' import logger from '@overleaf/logger' import ProjectEntityHandler from '../Project/ProjectEntityHandler.mjs' import HistoryManager from '../History/HistoryManager.mjs' +import ClsiManager from '../Compile/ClsiManager.mjs' import { Project } from '../../models/Project.mjs' import { ObjectId } from '../../infrastructure/mongodb.mjs' @@ -29,10 +30,10 @@ async function spawnGit(args, cwd) { }) } -async function setConfig(projectId, { remoteUrl, subPath }) { +async function setConfig(projectId, { remoteUrl, subPath, pdfPath }) { await Project.updateOne( { _id: new ObjectId(projectId) }, - { $set: { gitRemote: remoteUrl, gitSyncPath: subPath } } + { $set: { gitRemote: remoteUrl, gitSyncPath: subPath, gitSyncPdfPath: pdfPath } } ) } @@ -40,19 +41,24 @@ async function getConfig(projectId) { const project = await Project.findById(projectId, { gitRemote: 1, gitSyncPath: 1, + gitSyncPdfPath: 1, }).lean() return { remoteUrl: project?.gitRemote ?? null, subPath: project?.gitSyncPath ?? '', + pdfPath: project?.gitSyncPdfPath ?? '', } } -async function pushToRemote(projectId, remoteUrl, subPath) { +async function pushToRemote( + projectId, + remoteUrl, + subPath, + { pdfPath, pdfBuildId, pdfClsiServerId, userId } = {} +) { const tmpDir = await mkdtemp(join(tmpdir(), 'verso-git-')) logger.debug({ projectId, tmpDir, subPath }, 'git sync: writing project to temp dir') try { - // Files go into tmpDir/subPath/ when a subdirectory is configured, - // or directly into tmpDir when subPath is empty (repo root). const fileRoot = subPath ? join(tmpDir, subPath) : tmpDir // Fetch all text documents @@ -78,6 +84,25 @@ async function pushToRemote(projectId, remoteUrl, subPath) { await pipeline(stream, createWriteStream(dest)) } + // Optionally include the compiled PDF at a configured path + if (pdfPath && pdfBuildId && pdfClsiServerId && userId) { + try { + const pdfStream = await ClsiManager.promises.getOutputFileStream( + projectId, + userId, + pdfClsiServerId, + pdfBuildId, + 'output.pdf' + ) + const pdfDest = join(fileRoot, pdfPath) + await mkdir(dirname(pdfDest), { recursive: true }) + await pipeline(pdfStream, createWriteStream(pdfDest)) + logger.debug({ projectId }, 'git sync: PDF included') + } catch (err) { + logger.warn({ err, projectId }, 'git sync: could not fetch PDF, skipping') + } + } + // Git operations — force-push a single commit so the remote always // reflects the current Verso state exactly. await spawnGit(['init', '-b', 'main'], tmpDir) diff --git a/services/web/app/src/Features/Project/ProjectController.mjs b/services/web/app/src/Features/Project/ProjectController.mjs index 4c461ac771..8410e31870 100644 --- a/services/web/app/src/Features/Project/ProjectController.mjs +++ b/services/web/app/src/Features/Project/ProjectController.mjs @@ -606,6 +606,7 @@ const _ProjectController = { tokens: 1, gitRemote: 1, gitSyncPath: 1, + gitSyncPdfPath: 1, }), userIsMemberOfGroupSubscription: sessionUser ? (async () => @@ -1027,6 +1028,7 @@ const _ProjectController = { gitSyncEnabled: Boolean(Settings.enableGitSync), gitRemote: project.gitRemote ?? '', gitSyncPath: project.gitSyncPath ?? '', + gitSyncPdfPath: project.gitSyncPdfPath ?? '', wsUrl, showSupport: Features.hasFeature('support'), showTemplatesServerPro, diff --git a/services/web/app/src/models/Project.mjs b/services/web/app/src/models/Project.mjs index 49e448843e..d5d01536d2 100644 --- a/services/web/app/src/models/Project.mjs +++ b/services/web/app/src/models/Project.mjs @@ -110,6 +110,7 @@ export const ProjectSchema = new Schema( deferredTpdsFlushCounter: { type: Number }, gitRemote: { type: String }, gitSyncPath: { type: String }, + gitSyncPdfPath: { type: String }, }, { minimize: false } ) diff --git a/services/web/app/views/project/editor/_meta.pug b/services/web/app/views/project/editor/_meta.pug index de67be0243..8ac7125f0d 100644 --- a/services/web/app/views/project/editor/_meta.pug +++ b/services/web/app/views/project/editor/_meta.pug @@ -17,6 +17,7 @@ meta(name="ol-gitBridgeEnabled" data-type="boolean" content=gitBridgeEnabled) meta(name="ol-gitSyncEnabled" data-type="boolean" content=gitSyncEnabled) meta(name="ol-gitRemote" content=gitRemote) meta(name="ol-gitSyncPath" content=gitSyncPath) +meta(name="ol-gitSyncPdfPath" content=gitSyncPdfPath) meta(name="ol-compilesUserContentDomain" content=settings.compilesUserContentDomain) //- enable doc hash checking for all projects //- used in public/js/libs/sharejs.js diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index ae967dac9d..eaeba66584 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -801,6 +801,9 @@ "git_sync_push_success": "", "git_sync_remote_url": "", "git_sync_saved": "", + "git_sync_auto_push_on_compile": "", + "git_sync_pdf_path": "", + "git_sync_pdf_path_placeholder": "", "git_sync_sub_path": "", "git_sync_sub_path_placeholder": "", "git_bridge_modal_review_access": "", diff --git a/services/web/frontend/js/features/ide-react/components/rail/rail.tsx b/services/web/frontend/js/features/ide-react/components/rail/rail.tsx index c01949b18a..effd63e31a 100644 --- a/services/web/frontend/js/features/ide-react/components/rail/rail.tsx +++ b/services/web/frontend/js/features/ide-react/components/rail/rail.tsx @@ -117,7 +117,7 @@ export const RailLayout = () => { }, { key: 'integrations', - icon: 'integration_instructions', + icon: 'merge', title: t('integrations'), component: , hide: !isOverleaf && !gitBridgeEnabled && !gitSyncEnabled, diff --git a/services/web/frontend/js/features/integrations-panel/git-sync-widget.tsx b/services/web/frontend/js/features/integrations-panel/git-sync-widget.tsx index ed4901e213..9e10f63369 100644 --- a/services/web/frontend/js/features/integrations-panel/git-sync-widget.tsx +++ b/services/web/frontend/js/features/integrations-panel/git-sync-widget.tsx @@ -1,35 +1,75 @@ -import { useState } from 'react' +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' type Status = 'idle' | 'busy' | 'success' | 'error' +const projectId = getMeta('ol-project_id') +const AUTO_PUSH_KEY = `gitSync.autoPushOnCompile.${projectId}` + export default function GitSyncWidget() { const { t } = useTranslation() - const projectId = getMeta('ol-project_id') + 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 [autoPush, setAutoPush] = useState( + () => localStorage.getItem(AUTO_PUSH_KEY) === 'true' + ) const [lastAction, setLastAction] = useState('save') const [status, setStatus] = useState('idle') const [errorMsg, setErrorMsg] = useState('') - async function handleSave(e: React.FormEvent) { - e.preventDefault() - setLastAction('save') + const wasCompilingRef = useRef(false) + const isBusyRef = useRef(false) + + // Keep isBusyRef in sync so the compile watcher can check it + useEffect(() => { + isBusyRef.current = status === 'busy' + }, [status]) + + // Auto-push when compile finishes successfully + 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 }, + }) + } + + async function doPush(buildId?: string, serverId?: string) { + setLastAction('push') setStatus('busy') setErrorMsg('') try { - await postJSON(`/project/${projectId}/git-sync`, { - body: { remoteUrl, subPath }, + await saveConfig() + await postJSON(`/project/${projectId}/git-sync/push`, { + body: { buildId, clsiServerId: serverId }, }) setStatus('success') } catch (err: any) { @@ -38,16 +78,13 @@ export default function GitSyncWidget() { } } - async function handlePush() { - setLastAction('push') + async function handleSave(e: React.FormEvent) { + e.preventDefault() + setLastAction('save') setStatus('busy') setErrorMsg('') try { - // Always save config first so the push reads the current form values - await postJSON(`/project/${projectId}/git-sync`, { - body: { remoteUrl, subPath }, - }) - await postJSON(`/project/${projectId}/git-sync/push`, { body: {} }) + await saveConfig() setStatus('success') } catch (err: any) { setStatus('error') @@ -55,6 +92,16 @@ export default function GitSyncWidget() { } } + function handlePush() { + doPush(pdfFile?.build, clsiServerId) + } + + function handleAutoPushToggle(e: React.ChangeEvent) { + const checked = e.target.checked + setAutoPush(checked) + localStorage.setItem(AUTO_PUSH_KEY, String(checked)) + } + const isBusy = status === 'busy' return ( @@ -88,6 +135,28 @@ export default function GitSyncWidget() { /> + + {t('git_sync_pdf_path')} + setPdfPath(e.target.value)} + placeholder={t('git_sync_pdf_path_placeholder')} + disabled={isBusy} + /> + + + + + +
You have review access to this project. This means you can pull from __appName__ but you can’t push any changes you make back to this project.", diff --git a/services/web/locales/fr.json b/services/web/locales/fr.json index 55a81d4548..0a7064ee89 100644 --- a/services/web/locales/fr.json +++ b/services/web/locales/fr.json @@ -1053,6 +1053,9 @@ "git_sync_push_success": "Projet envoyé vers le dépôt git avec succès.", "git_sync_remote_url": "URL distante (avec jeton d'auth)", "git_sync_saved": "Paramètres sauvegardés.", + "git_sync_auto_push_on_compile": "Pousser automatiquement après compilation réussie", + "git_sync_pdf_path": "Chemin de destination du PDF (optionnel)", + "git_sync_pdf_path_placeholder": "ex. output/main.pdf (laisser vide pour ne pas inclure le PDF)", "git_sync_sub_path": "Sous-dossier (optionnel)", "git_sync_sub_path_placeholder": "ex. docs/mon-projet (laisser vide pour la racine)", "git_bridge_modal_review_access": "<0>You have review access to this project. Cela signifie que vous pouvez extraire de __appName__ mais vous ne pouvez pas repousser les modifications que vous apportez à ce projet.",