26 Commits

Author SHA1 Message Date
claude 0caa4ff144 feat(git-sync): configurable branch, first-push confirmation, PDF-only replace
Build and Deploy Verso / deploy (push) Successful in 12m35s
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
claude af906d90b9 fix(git-sync): fetch remote state before push to preserve other files
Build and Deploy Verso / deploy (push) Has been cancelled
Previously the push did git-init from scratch and force-pushed, wiping
everything in the remote branch that wasn't part of the Verso project.

New approach:
1. git init + remote add origin + fetch --depth 1 origin/main
2. reset --hard FETCH_HEAD (skipped silently on first push to empty repo)
3. Clear only the managed area (subPath dir, or all non-.git files if
   no subPath is configured) so Verso deletions are still reflected
4. Write Verso docs and binary files
5. Write PDF at repo-root-relative pdfPath
6. git add -A + commit + push --force origin HEAD:main

With a subPath configured, everything outside the subPath is fetched
from the remote and preserved in the new commit unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 07:16:18 +00:00
claude c041719e6a fix(git-sync): restrict config and tab visibility to project owner
Build and Deploy Verso / deploy (push) Successful in 13m11s
- gitSyncEnabled is now false for non-owners, hiding the rail tab
- gitRemote (and all other git sync config) is served as empty string
  to non-owners, preventing auth token leakage via meta tags to
  collaborators and anonymous token users

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 00:01:49 +00:00
claude dbb519835a fix(git-sync): resolve PDF path from repo root, not from subPath
Build and Deploy Verso / deploy (push) Has been cancelled
pdfPath is now joined against tmpDir (repo root) instead of fileRoot
(subPath dir), so the PDF can be placed anywhere in the repo
independently of where the project files land.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 23:55:15 +00:00
claude 55ad9af7da fix(git-sync): pass undefined not null for optional clsiServerId
Build and Deploy Verso / deploy (push) Successful in 11m45s
zz.clsiServerId().optional() accepts undefined but rejects null.
?? null was coercing an absent clsiServerId into null, failing Zod
validation with "expected string, received null".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 23:38:13 +00:00
claude be8aef44fe feat(git-sync): independent toggles for project files and PDF push
Build and Deploy Verso / deploy (push) Successful in 12m48s
Two new boolean fields on the project (gitSyncPushFiles, gitSyncPushPdf,
both default true) let users control what gets pushed independently:
- "Push project files" switch — skip all docs/binary files when off
- "Push compiled PDF" switch — grayed out when no pdfPath is set

The push button and auto-push are disabled when both switches would
result in nothing being pushed. Config is stored in MongoDB so settings
persist per-project.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 23:21:37 +00:00
claude 9a474f7790 fix(git-sync): don't require clsiServerId to push PDF
In single-server CE deployments (Verso included) the CLSI does not
return a clsiServerId, so it was always undefined. The push condition
checked pdfPath && pdfBuildId && pdfClsiServerId && userId, meaning the
PDF was silently skipped every time in practice.

clsiServerId is optional in getOutputFileURL (single-server deployments
work without it), so only require pdfPath and pdfBuildId. Also remove
the inner try/catch so PDF fetch errors surface to the user instead of
being swallowed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 23:17:39 +00:00
claude 8ea6f6ecb1 fix(git-sync): wrap action buttons on narrow panels or long labels
flex-wrap: wrap lets buttons reflow to a second line instead of
overflowing. flex: 1 1 auto keeps them proportional within each row.
white-space: nowrap prevents individual button labels from breaking.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 23:02:28 +00:00
claude 0e18230d9a fix(git-sync): use SessionManager to read logged-in user id
Build and Deploy Verso / deploy (push) Successful in 11m46s
req.session.user is not the right path in Overleaf/Verso — the session
uses passport's structure. SessionManager.getLoggedInUserId(req.session)
is the standard way all other controllers access the user id.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 22:57:32 +00:00
claude 9c97e7c01a fix(git-sync): wrap controller in try/catch, fix rail icon, improve error display
Build and Deploy Verso / deploy (push) Successful in 21m33s
- Move userId + req.body reads inside try/catch in pushToGit and
  pullFromGit so any synchronous throw returns JSON, not an HTML 500
  (which made err.data undefined and showed only "Internal Server Error")
- Add extractError() helper in widget that tries err.data.error first,
  then err.message, then String(err) — surfaces the actual git failure
  message from the server log
- Change rail icon from 'merge' (not in unfilled-symbols list) to
  'autorenew' (sync arrows, already in the list) — fixes text showing
  when panel is closed and wrong icon when open

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 22:07:58 +00:00
claude e1533d979f feat(git-sync): add pull from remote (2-way sync)
Build and Deploy Verso / deploy (push) Has been cancelled
POST /project/:id/git-sync/pull clones the configured remote at depth 1,
walks all files under the configured subPath, and upserts each into the
Verso project using upsertDocWithPath (text) or upsertFileWithPath
(binary), with full folder creation via mkdirp. The .git directory is
skipped. Pull is additive/update-only — no Verso entities are deleted.

Text vs binary classification uses Settings.textExtensions (same list
the editor uses for file uploads), so .typ, .tex, .md, .yml etc. all
become editable docs while images and PDFs stay as files.

Frontend: "Pull from git" button added alongside "Push now".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 21:39:23 +00:00
claude d5de6550d4 fix(git-sync): wrap entire pushToGit in try/catch, drop git init -b flag
Build and Deploy Verso / deploy (push) Failing after 22m57s
- getConfig was outside try/catch so errors returned as HTML 500 instead
  of JSON, hiding the real message in the frontend
- git init -b main requires git ≥ 2.28; replaced with git init +
  symbolic-ref to support older git versions in the base image
- apt-get update before git install to avoid stale package list failures

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 21:28:52 +00:00
claude 71f1b928e9 feat: default typst preview to PDF mode, mark Live/WASM as experimental
Build and Deploy Verso / deploy (push) Successful in 13m3s
The WASM renderer has ongoing stability issues so PDF (server) is now
the default for new projects / browsers that haven't chosen a mode yet.
The Live (browser) option remains available in the compile dropdown but
is labelled "experimental" in italic to set expectations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 21:12:27 +00:00
claude f22ee608a7 feat(git-sync): auto-push on compile, PDF path, icon fix
Build and Deploy Verso / deploy (push) Has been cancelled
- 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 <noreply@anthropic.com>
2026-06-23 21:07:13 +00:00
claude 9f1c9babf7 fix(git-sync): auto-save config before pushing
Build and Deploy Verso / deploy (push) Successful in 11m3s
Push now always saves the current form values first, so clicking Push
without a prior Save no longer returns "No git remote configured".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 20:52:59 +00:00
claude 44a9adcea1 ci: add OVERLEAF_ENABLE_GIT_SYNC to test deployment env
Build and Deploy Verso / deploy (push) Successful in 1m25s
The flag was added to the prod k8s manifest but missing from the
inline deployment spec in the test workflow — causing the Integrations
tab to stay hidden in the test environment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 20:31:23 +00:00
claude a17d21c0ca feat(git-sync): add optional subdirectory field
Build and Deploy Verso / deploy (push) Successful in 12m29s
Project files can now be pushed into a subfolder of the target repo
rather than always going to the root. The path is sanitised on the
backend (strips leading/trailing slashes, rejects traversal with ..).
An empty value (default) keeps the existing root behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 20:13:37 +00:00
claude a7a569303c ci: allow deploy-verso to trigger from git-bridge-test branch
Build and Deploy Verso / deploy (push) Has been cancelled
- Add git-bridge-test to push trigger branches
- Pass the triggering branch through to the k8s buildkit job via sed
  substitution (same pattern as NAV_TITLE), so workflow_dispatch from
  any branch also clones the right ref instead of always cloning main

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 20:06:24 +00:00
claude 8214ca6121 feat: add pragmatic git sync — push project to external git remote
Adds a "Git Sync" section in the Integrations rail panel that lets
project owners configure an HTTPS remote URL (with embedded auth token)
and force-push all project files as a single commit.

Backend:
- GitSyncHandler: assembles project docs + binary files into a temp dir,
  runs git init/commit/push --force, then cleans up
- GitSyncController: GET/POST /project/:id/git-sync (configure),
  POST /project/:id/git-sync/push (trigger)
- Project model: gitRemote field
- Dockerfile: ensures git is present at runtime
- Env flag: OVERLEAF_ENABLE_GIT_SYNC=true (set in k8s manifest)

Frontend:
- GitSyncWidget: URL input + Save + Push Now buttons, success/error feedback
- Integrations panel: shows widget when gitSyncEnabled
- Rail: shows Integrations tab when gitSyncEnabled (was only gitBridgeEnabled)
- i18n: en + fr translations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 20:01:16 +00:00
claude 4f5dad383b fix(typst-preview): use persistent session to avoid Rc ownership panics
Build and Deploy Verso / deploy (push) Successful in 1m5s
Every call to renderToSvg({ artifactContent }) internally routes through
runWithSession, which calls session.free() after fn resolves. Because the JS
GC may still hold a reference to the first RenderSession wrapper, the next
render's Rc::try_unwrap() panics with 'attempted to take ownership of Rust
value while it was borrowed'.

Fix: use renderer.createModule(data) once to create a persistent RenderSession
that is never freed during the component's lifetime. Subsequent renders call
session.manipulateData({ action: 'reset', data }) (synchronous, no ownership
transfer) + session.renderToSvg({ container }) which routes through
withinOptionSession's renderSession fast-path — bypassing runWithSession and
its session.free() entirely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 15:49:50 +00:00
claude eedf4b50f6 fix(typst-preview): use RenderByContentOptions to avoid Rust aliasing
Build and Deploy Verso / deploy (push) Successful in 10m35s
Replace runWithSession + manipulateData + session.renderToSvg with the
direct RenderByContentOptions form: renderer.renderToSvg({ format: 'vector',
artifactContent, container }).

The session-based API kept hitting 'recursive use of an object detected
which would lead to unsafe aliasing in rust' because runWithSession holds
a mutable borrow of the session while renderToSvg also takes one —
regardless of whether you call renderer.renderToSvg({ renderSession }) or
session.renderToSvg(). The content-based form creates and disposes the
session internally without any caller-visible borrow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 15:22:56 +00:00
claude c6d71e58b7 fix(typst-preview): fix recursive Rust aliasing error and stale renders
Build and Deploy Verso / deploy (push) Successful in 12m44s
Two bugs fixed:

1. 'recursive use of an object' Rust error: inside runWithSession(), calling
   renderer.renderToSvg({ renderSession: session }) passes the session to the
   renderer while runWithSession already holds it — double-aliasing the same
   Rust object. Fixed by using session.renderToSvg({ container }) directly.

2. Stale preview after edits: concurrent doRender calls (compile finishes
   while previous render is still in progress) would both enter runWithSession
   simultaneously, causing the Rust error and leaving the view frozen. Fixed
   with a render guard (isRenderingRef) that queues the latest vectorData and
   flushes it once the current render completes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 14:52:49 +00:00
claude 06085cda21 fix(csp): allow WebAssembly instantiation via wasm-unsafe-eval
Build and Deploy Verso / deploy (push) Successful in 11m54s
WebAssembly.instantiateStreaming() requires 'wasm-unsafe-eval' in the
script-src CSP directive. Unlike 'unsafe-eval', this only permits WASM
compilation and does not allow arbitrary eval() calls.

Needed for the typst.ts WASM preview (both compiler and renderer).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 14:35:26 +00:00
claude 8515a899ac fix(typst-preview): fix race condition and error-masking in WASM preview
Build and Deploy Verso / deploy (push) Successful in 12m35s
Two bugs were causing a brief red error then blank screen:

1. triggerCompile closed over `view` in useCallback deps, so every time
   the CodeMirror view reference changed, useEffect terminated and
   recreated the entire worker. Fixed by reading view via a ref, making
   triggerCompile stable (empty dep array).

2. When 'compiled' arrived before the renderer WASM finished loading,
   the old code called setStatus('ready') to silently skip rendering —
   this cleared any existing error and left a blank screen. Fixed by
   buffering the vectorData in pendingVectorRef and flushing it once
   the renderer is ready.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 14:05:41 +00:00
claude 200bff4ecb feat(typst): browser-side live preview via typst.ts WASM
Build and Deploy Verso / deploy (push) Successful in 12m51s
Adds a dual-mode Typst preview: a new "Live (browser)" mode compiles and
renders Typst documents entirely in-browser using typst.ts WASM (28 MB
compiler + 1 MB renderer). The existing server-side PDF mode is preserved
and selectable via a new "Preview mode" section in the recompile dropdown,
visible only for Typst projects.

Architecture:
- Web Worker (typst-preview-worker.ts) runs the WASM compiler; queues
  compile requests so only the latest compile runs after each keypress
- TypstWasmPreview component initialises the renderer on the main thread,
  listens to changedAt from the compile context, debounces at 400 ms, and
  renders SVG into a container div via renderToSvg
- typstPreviewMode ('wasm'|'pdf') is persisted per-project in localStorage
- isTypstProject, changedAt, typstPreviewMode, setTypstPreviewMode are
  exposed through both LocalCompileContext and DetachCompileContext
- Fonts loaded from jsDelivr CDN (text subset only) on first use
- Phase 1: single-file Typst only (no #include, no images)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 13:42:39 +00:00
claude b5cf5f9e7b docs: add AI writing assistant to alpha-4 TODO
Build and Deploy Verso / deploy (push) Successful in 59s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 12:29:15 +00:00
31 changed files with 1516 additions and 4041 deletions
+6 -2
View File
@@ -4,6 +4,7 @@ on:
push: push:
branches: branches:
- main - main
- git-bridge-test
workflow_dispatch: workflow_dispatch:
env: env:
@@ -19,7 +20,8 @@ jobs:
run: | run: |
kubectl -n ci delete job verso-buildkit --ignore-not-found=true --wait=true kubectl -n ci delete job verso-buildkit --ignore-not-found=true --wait=true
cat <<'EOF' | kubectl apply -f - BRANCH=${GITEA_REF_NAME:-${GITHUB_REF_NAME:-main}}
cat <<'EOF' | sed "s|__BRANCH__|${BRANCH}|g" | kubectl apply -f -
apiVersion: batch/v1 apiVersion: batch/v1
kind: Job kind: Job
metadata: metadata:
@@ -38,7 +40,7 @@ jobs:
- | - |
set -eux set -eux
REG=registry.git.svc.cluster.local:5000 REG=registry.git.svc.cluster.local:5000
git clone --depth 1 https://git.alocoq.fr/alois/verso.git /workspace/repo git clone --depth 1 --branch __BRANCH__ https://git.alocoq.fr/alois/verso.git /workspace/repo
# (#1) Build the base image only when it actually changes. # (#1) Build the base image only when it actually changes.
# The base layers' only repo input is Dockerfile-base, so # The base layers' only repo input is Dockerfile-base, so
@@ -302,6 +304,8 @@ jobs:
value: "true" value: "true"
- name: OVERLEAF_LATEX_SHELL_ESCAPE - name: OVERLEAF_LATEX_SHELL_ESCAPE
value: "true" value: "true"
- name: OVERLEAF_ENABLE_GIT_SYNC
value: "true"
--- ---
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
+9
View File
@@ -39,3 +39,12 @@ Ideas and features deferred from the current alpha.
- **Quarto RevealJS**: insert slide divider (`---`), insert speaker - **Quarto RevealJS**: insert slide divider (`---`), insert speaker
notes (`::: notes`), insert columns layout, insert video embed notes (`::: notes`), insert columns layout, insert video embed
(using Quarto's `{{< video >}}` shortcode). (using Quarto's `{{< video >}}` shortcode).
### AI writing assistant
- **In-editor AI assistant** — Inline writing help similar to what Overleaf
and CoCalc offer: suggest completions, rephrase selections, explain LaTeX
errors, and generate boilerplate (figures, tables, equations). Should work
across all three formats (`.tex`, `.typ`, `.qmd`). Backend would proxy
requests to a configurable model API (Claude, OpenAI-compatible) so
self-hosters can bring their own key.
+4
View File
@@ -75,6 +75,10 @@ RUN --mount=type=cache,target=/root/.cache \
echo "==== PACK LOGS (all attempts failed) ===="; \ echo "==== PACK LOGS (all attempts failed) ===="; \
find /tmp -name pack.log -exec cat {} \; 2>/dev/null || true; \ find /tmp -name pack.log -exec cat {} \; 2>/dev/null || true; \
exit 1 exit 1
# Ensure git is available for the git sync feature
# -------------------------------------------------
RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/*
# Copy runit service startup scripts to its location # Copy runit service startup scripts to its location
# -------------------------------------------------- # --------------------------------------------------
ADD server-ce/runit /etc/service ADD server-ce/runit /etc/service
+2
View File
@@ -58,6 +58,8 @@ const settings = {
allowAnonymousReadAndWriteSharing: allowAnonymousReadAndWriteSharing:
process.env.OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING === 'true', process.env.OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING === 'true',
enableGitSync: process.env.OVERLEAF_ENABLE_GIT_SYNC === 'true',
// Databases // Databases
// --------- // ---------
+2
View File
@@ -77,6 +77,8 @@ spec:
# them). Public self-registration is also off (CE default). # them). Public self-registration is also off (CE default).
- name: OVERLEAF_ENABLE_PROJECT_PYTHON_VENV - name: OVERLEAF_ENABLE_PROJECT_PYTHON_VENV
value: "true" value: "true"
- name: OVERLEAF_ENABLE_GIT_SYNC
value: "true"
# SMTP for password-reset / invite emails. All OVERLEAF_EMAIL_* vars # SMTP for password-reset / invite emails. All OVERLEAF_EMAIL_* vars
# are loaded from the optional 'verso-smtp' Secret — its keys must be # are loaded from the optional 'verso-smtp' Secret — its keys must be
# named exactly like these env vars (see the kubectl create secret # named exactly like these env vars (see the kubectl create secret
@@ -0,0 +1,115 @@
import logger from '@overleaf/logger'
import GitSyncHandler from './GitSyncHandler.mjs'
import SessionManager from '../Authentication/SessionManager.mjs'
import { expressify } from '@overleaf/promise-utils'
async function configureGitSync(req, res) {
const projectId = req.params.project_id
const {
remoteUrl,
subPath = '',
pdfPath = '',
pushFiles = true,
pushPdf = true,
branch = 'main',
} = req.body
if (typeof remoteUrl !== 'string' || remoteUrl.trim() === '') {
return res.status(400).json({ error: 'remoteUrl is required' })
}
const trimmedUrl = remoteUrl.trim()
if (!trimmedUrl.startsWith('https://') && !trimmedUrl.startsWith('git@')) {
return res.status(400).json({ error: 'remoteUrl must start with https:// or git@' })
}
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 ..' })
}
const trimmedBranch = String(branch).trim()
if (!/^[a-zA-Z0-9/_.-]+$/.test(trimmedBranch) || trimmedBranch.includes('..')) {
return res.status(400).json({ error: 'Invalid branch name' })
}
await GitSyncHandler.setConfig(projectId, {
remoteUrl: trimmedUrl,
subPath: trimmedSubPath,
pdfPath: trimmedPdfPath,
pushFiles: Boolean(pushFiles),
pushPdf: Boolean(pushPdf),
branch: trimmedBranch,
})
logger.debug({ projectId }, 'git sync: config saved')
res.sendStatus(204)
}
async function pushToGit(req, res) {
const projectId = req.params.project_id
try {
const userId = SessionManager.getLoggedInUserId(req.session)
const { buildId, clsiServerId } = req.body ?? {}
const { remoteUrl, subPath, pdfPath, pushFiles, pushPdf, branch } =
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')
await GitSyncHandler.pushToRemote(projectId, remoteUrl, subPath, {
pdfPath,
pdfBuildId: buildId,
pdfClsiServerId: clsiServerId,
userId,
pushFiles,
pushPdf,
branch,
})
res.sendStatus(204)
} catch (err) {
logger.warn({ err, projectId }, 'git sync: push failed')
res.status(500).json({ error: err.message })
}
}
async function pullFromGit(req, res) {
const projectId = req.params.project_id
try {
const userId = SessionManager.getLoggedInUserId(req.session)
const { remoteUrl, subPath } = 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 pull')
await GitSyncHandler.pullFromRemote(projectId, remoteUrl, subPath, userId)
res.sendStatus(204)
} catch (err) {
logger.warn({ err, projectId }, 'git sync: pull failed')
res.status(500).json({ error: err.message })
}
}
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,
pdfPath: config.pdfPath,
pushFiles: config.pushFiles,
pushPdf: config.pushPdf,
branch: config.branch,
})
}
export default {
configureGitSync: expressify(configureGitSync),
pushToGit: expressify(pushToGit),
pullFromGit: expressify(pullFromGit),
getGitSyncConfig: expressify(getGitSyncConfig),
}
@@ -0,0 +1,243 @@
import { mkdtemp, mkdir, writeFile, rm, readdir, readFile, access } from 'node:fs/promises'
import { createWriteStream } from 'node:fs'
import { join, dirname, extname, basename } from 'node:path'
import { tmpdir } from 'node:os'
import { spawn } from 'node:child_process'
import { pipeline } from 'node:stream/promises'
import logger from '@overleaf/logger'
import Settings from '@overleaf/settings'
import ProjectEntityHandler from '../Project/ProjectEntityHandler.mjs'
import ProjectEntityUpdateHandler from '../Project/ProjectEntityUpdateHandler.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'
const TEXT_EXTENSIONS = new Set(
Settings.textExtensions.map(ext => `.${ext}`)
)
const EDITABLE_FILENAMES = new Set(
(Settings.editableFilenames || []).map(n => n.toLowerCase())
)
function isTextFile(filename) {
const ext = extname(filename).toLowerCase()
return TEXT_EXTENSIONS.has(ext) || EDITABLE_FILENAMES.has(filename.toLowerCase())
}
async function walkDir(dir) {
const entries = await readdir(dir, { withFileTypes: true })
const files = []
for (const entry of entries) {
if (entry.name === '.git') continue
const fullPath = join(dir, entry.name)
if (entry.isDirectory()) {
files.push(...(await walkDir(fullPath)))
} else {
files.push(fullPath)
}
}
return files
}
async function spawnGit(args, cwd) {
return new Promise((resolve, reject) => {
const proc = spawn('git', args, {
cwd,
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
})
let stderr = ''
proc.stderr?.on('data', d => (stderr += d.toString()))
proc.on('close', code => {
if (code !== 0) {
reject(new Error(`git ${args[0]} exited ${code}: ${stderr.trim()}`))
} else {
resolve()
}
})
proc.on('error', reject)
})
}
async function setConfig(projectId, { remoteUrl, subPath, pdfPath, pushFiles, pushPdf, branch }) {
await Project.updateOne(
{ _id: new ObjectId(projectId) },
{
$set: {
gitRemote: remoteUrl,
gitSyncPath: subPath,
gitSyncPdfPath: pdfPath,
gitSyncPushFiles: pushFiles,
gitSyncPushPdf: pushPdf,
gitSyncBranch: branch,
},
}
)
}
async function getConfig(projectId) {
const project = await Project.findById(projectId, {
gitRemote: 1,
gitSyncPath: 1,
gitSyncPdfPath: 1,
gitSyncPushFiles: 1,
gitSyncPushPdf: 1,
gitSyncBranch: 1,
}).lean()
return {
remoteUrl: project?.gitRemote ?? null,
subPath: project?.gitSyncPath ?? '',
pdfPath: project?.gitSyncPdfPath ?? '',
pushFiles: project?.gitSyncPushFiles ?? true,
pushPdf: project?.gitSyncPushPdf ?? true,
branch: project?.gitSyncBranch ?? 'main',
}
}
async function pushToRemote(
projectId,
remoteUrl,
subPath,
{ pdfPath, pdfBuildId, pdfClsiServerId, userId, pushFiles = true, pushPdf = true, branch = 'main' } = {}
) {
const tmpDir = await mkdtemp(join(tmpdir(), 'verso-git-'))
logger.debug({ projectId, tmpDir, subPath, pushFiles, pushPdf, branch }, 'git sync: starting push')
try {
const repoDir = join(tmpDir, 'repo')
await mkdir(repoDir)
// Init, connect to remote, fetch existing state so files outside our
// managed area are preserved in the push.
await spawnGit(['init'], repoDir)
await spawnGit(['symbolic-ref', 'HEAD', `refs/heads/${branch}`], repoDir)
await spawnGit(['config', 'user.email', 'verso-sync@localhost'], repoDir)
await spawnGit(['config', 'user.name', 'Verso Sync'], repoDir)
await spawnGit(['remote', 'add', 'origin', remoteUrl], repoDir)
try {
await spawnGit(['fetch', '--depth', '1', 'origin', branch], repoDir)
await spawnGit(['reset', '--hard', 'FETCH_HEAD'], repoDir)
} catch {
// Remote branch doesn't exist yet (first push to empty repo) — proceed
}
const fileRoot = subPath ? join(repoDir, subPath) : repoDir
if (pushFiles) {
// Clear the managed area so files deleted in Verso are removed from git.
// With a subPath this leaves everything outside the subPath untouched.
// Without a subPath the entire repo is considered Verso-owned.
if (subPath) {
await rm(fileRoot, { recursive: true, force: true })
await mkdir(fileRoot, { recursive: true })
} else {
const entries = await readdir(repoDir, { withFileTypes: true })
for (const entry of entries) {
if (entry.name === '.git') continue
await rm(join(repoDir, entry.name), { recursive: true, force: true })
}
}
const docs = await ProjectEntityHandler.promises.getAllDocs(projectId)
for (const [path, doc] of Object.entries(docs)) {
const rel = path.startsWith('/') ? path.slice(1) : path
const dest = join(fileRoot, rel)
await mkdir(dirname(dest), { recursive: true })
await writeFile(dest, doc.lines.join('\n'))
}
const files = await ProjectEntityHandler.promises.getAllFiles(projectId)
for (const [path, file] of Object.entries(files)) {
const rel = path.startsWith('/') ? path.slice(1) : path
const dest = join(fileRoot, rel)
await mkdir(dirname(dest), { recursive: true })
const { stream } =
await HistoryManager.promises.requestBlobWithProjectId(
projectId,
file.hash
)
await pipeline(stream, createWriteStream(dest))
}
}
// PDF path is always relative to repo root, independent of subPath
if (pushPdf && pdfPath && pdfBuildId) {
const pdfStream = await ClsiManager.promises.getOutputFileStream(
projectId,
userId,
pdfClsiServerId ?? undefined,
pdfBuildId,
'output.pdf'
)
const pdfDest = join(repoDir, pdfPath)
await mkdir(dirname(pdfDest), { recursive: true })
await pipeline(pdfStream, createWriteStream(pdfDest))
logger.debug({ projectId }, 'git sync: PDF included')
}
await spawnGit(['add', '-A'], repoDir)
await spawnGit(['commit', '--allow-empty', '-m', 'Verso sync'], repoDir)
await spawnGit(['push', '--force', 'origin', `HEAD:${branch}`], repoDir)
logger.debug({ projectId }, 'git sync: push complete')
} finally {
await rm(tmpDir, { recursive: true, force: true })
}
}
async function pullFromRemote(projectId, remoteUrl, subPath, userId) {
const tmpDir = await mkdtemp(join(tmpdir(), 'verso-pull-'))
logger.debug({ projectId, subPath }, 'git sync: cloning remote for pull')
try {
await spawnGit(['clone', '--depth', '1', '--', remoteUrl, 'repo'], tmpDir)
const repoDir = join(tmpDir, 'repo')
const fileRoot = subPath ? join(repoDir, subPath) : repoDir
// Verify the subdirectory exists in the repo
try {
await access(fileRoot)
} catch {
throw new Error(
subPath
? `Subdirectory "${subPath}" not found in the repository`
: 'Repository is empty'
)
}
const allFiles = await walkDir(fileRoot)
logger.debug({ projectId, count: allFiles.length }, 'git sync: importing files from remote')
for (const absPath of allFiles) {
// Path relative to fileRoot → Verso project path (leading slash required)
const rel = absPath.slice(fileRoot.length)
const projectPath = rel.startsWith('/') ? rel : `/${rel}`
if (isTextFile(basename(absPath))) {
const content = await readFile(absPath, 'utf8')
const lines = content.split('\n')
await ProjectEntityUpdateHandler.promises.upsertDocWithPath(
projectId,
projectPath,
lines,
'git-sync',
userId
)
} else {
await ProjectEntityUpdateHandler.promises.upsertFileWithPath(
projectId,
projectPath,
absPath,
null,
userId,
'git-sync'
)
}
}
logger.debug({ projectId }, 'git sync: pull complete')
} finally {
await rm(tmpDir, { recursive: true, force: true })
}
}
export default { setConfig, getConfig, pushToRemote, pullFromRemote }
@@ -604,6 +604,12 @@ const _ProjectController = {
brandVariationId: 1, brandVariationId: 1,
overleaf: 1, overleaf: 1,
tokens: 1, tokens: 1,
gitRemote: 1,
gitSyncPath: 1,
gitSyncPdfPath: 1,
gitSyncPushFiles: 1,
gitSyncPushPdf: 1,
gitSyncBranch: 1,
}), }),
userIsMemberOfGroupSubscription: sessionUser userIsMemberOfGroupSubscription: sessionUser
? (async () => ? (async () =>
@@ -1022,6 +1028,27 @@ const _ProjectController = {
imageNames, imageNames,
gitBridgePublicBaseUrl: Settings.gitBridgePublicBaseUrl, gitBridgePublicBaseUrl: Settings.gitBridgePublicBaseUrl,
gitBridgeEnabled: Features.hasFeature('git-bridge'), gitBridgeEnabled: Features.hasFeature('git-bridge'),
gitSyncEnabled:
Boolean(Settings.enableGitSync) &&
privilegeLevel === PrivilegeLevels.OWNER,
gitRemote: privilegeLevel === PrivilegeLevels.OWNER
? (project.gitRemote ?? '')
: '',
gitSyncPath: privilegeLevel === PrivilegeLevels.OWNER
? (project.gitSyncPath ?? '')
: '',
gitSyncPdfPath: privilegeLevel === PrivilegeLevels.OWNER
? (project.gitSyncPdfPath ?? '')
: '',
gitSyncPushFiles: privilegeLevel === PrivilegeLevels.OWNER
? (project.gitSyncPushFiles ?? true)
: true,
gitSyncPushPdf: privilegeLevel === PrivilegeLevels.OWNER
? (project.gitSyncPushPdf ?? true)
: true,
gitSyncBranch: privilegeLevel === PrivilegeLevels.OWNER
? (project.gitSyncBranch ?? 'main')
: 'main',
wsUrl, wsUrl,
showSupport: Features.hasFeature('support'), showSupport: Features.hasFeature('support'),
showTemplatesServerPro, showTemplatesServerPro,
+1 -1
View File
@@ -85,7 +85,7 @@ const buildViewPolicy = (
viewDirectives viewDirectives
) => { ) => {
const directives = [ const directives = [
`script-src 'nonce-${scriptNonce}' 'unsafe-inline' 'strict-dynamic' https: 'report-sample'`, // only allow scripts from certain sources `script-src 'nonce-${scriptNonce}' 'unsafe-inline' 'strict-dynamic' 'wasm-unsafe-eval' https: 'report-sample'`, // only allow scripts from certain sources
`object-src 'none'`, // forbid loading an "object" element `object-src 'none'`, // forbid loading an "object" element
`base-uri 'none'`, // forbid setting a "base" element `base-uri 'none'`, // forbid setting a "base" element
...(viewDirectives ?? []), ...(viewDirectives ?? []),
+6
View File
@@ -108,6 +108,12 @@ export const ProjectSchema = new Schema(
}, },
], ],
deferredTpdsFlushCounter: { type: Number }, deferredTpdsFlushCounter: { type: Number },
gitRemote: { type: String },
gitSyncPath: { type: String },
gitSyncPdfPath: { type: String },
gitSyncPushFiles: { type: Boolean },
gitSyncPushPdf: { type: Boolean },
gitSyncBranch: { type: String },
}, },
{ minimize: false } { minimize: false }
) )
+26
View File
@@ -68,6 +68,7 @@ import { plainTextResponse } from './infrastructure/Response.mjs'
import SocketDiagnostics from './Features/SocketDiagnostics/SocketDiagnostics.mjs' import SocketDiagnostics from './Features/SocketDiagnostics/SocketDiagnostics.mjs'
import ClsiCacheController from './Features/Compile/ClsiCacheController.mjs' import ClsiCacheController from './Features/Compile/ClsiCacheController.mjs'
import AsyncLocalStorage from './infrastructure/AsyncLocalStorage.mjs' import AsyncLocalStorage from './infrastructure/AsyncLocalStorage.mjs'
import GitSyncController from './Features/GitSync/GitSyncController.mjs'
const { renderUnsupportedBrowserPage, unsupportedBrowserMiddleware } = const { renderUnsupportedBrowserPage, unsupportedBrowserMiddleware } =
UnsupportedBrowserMiddleware UnsupportedBrowserMiddleware
@@ -603,6 +604,31 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
ProjectController.updateProjectAdminSettings ProjectController.updateProjectAdminSettings
) )
webRouter.get(
'/project/:project_id/git-sync',
AuthenticationController.requireLogin(),
AuthorizationMiddleware.ensureUserCanAdminProject,
GitSyncController.getGitSyncConfig
)
webRouter.post(
'/project/:project_id/git-sync',
AuthenticationController.requireLogin(),
AuthorizationMiddleware.ensureUserCanAdminProject,
GitSyncController.configureGitSync
)
webRouter.post(
'/project/:project_id/git-sync/push',
AuthenticationController.requireLogin(),
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
GitSyncController.pushToGit
)
webRouter.post(
'/project/:project_id/git-sync/pull',
AuthenticationController.requireLogin(),
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
GitSyncController.pullFromGit
)
webRouter.post( webRouter.post(
'/project/:Project_id/compile', '/project/:Project_id/compile',
RateLimiterMiddleware.rateLimit(rateLimiters.compileProjectHttp, { RateLimiterMiddleware.rateLimit(rateLimiters.compileProjectHttp, {
@@ -14,6 +14,13 @@ meta(name="ol-wikiEnabled" data-type="boolean" content=settings.proxyLearn)
meta(name="ol-capabilities" data-type="json" content=capabilities) meta(name="ol-capabilities" data-type="json" content=capabilities)
meta(name="ol-gitBridgePublicBaseUrl" content=gitBridgePublicBaseUrl) meta(name="ol-gitBridgePublicBaseUrl" content=gitBridgePublicBaseUrl)
meta(name="ol-gitBridgeEnabled" data-type="boolean" content=gitBridgeEnabled) 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-gitSyncPushFiles" data-type="boolean" content=gitSyncPushFiles)
meta(name="ol-gitSyncPushPdf" data-type="boolean" content=gitSyncPushPdf)
meta(name="ol-gitSyncBranch" content=gitSyncBranch)
meta(name="ol-compilesUserContentDomain" content=settings.compilesUserContentDomain) meta(name="ol-compilesUserContentDomain" content=settings.compilesUserContentDomain)
//- enable doc hash checking for all projects //- enable doc hash checking for all projects
//- used in public/js/libs/sharejs.js //- used in public/js/libs/sharejs.js
@@ -794,6 +794,27 @@
"git_bridge_modal_git_clone_your_project": "", "git_bridge_modal_git_clone_your_project": "",
"git_bridge_modal_learn_more_about_authentication_tokens": "", "git_bridge_modal_learn_more_about_authentication_tokens": "",
"git_bridge_modal_read_only": "", "git_bridge_modal_read_only": "",
"git_sync": "",
"git_sync_description": "",
"git_sync_pull_now": "",
"git_sync_pull_success": "",
"git_sync_pulling": "",
"git_sync_push_now": "",
"git_sync_pushing": "",
"git_sync_push_success": "",
"git_sync_remote_url": "",
"git_sync_saved": "",
"git_sync_auto_push_on_compile": "",
"git_sync_branch": "",
"git_sync_confirm_reset": "",
"git_sync_confirm_push": "",
"git_sync_repo_root": "",
"git_sync_push_files": "",
"git_sync_push_pdf": "",
"git_sync_pdf_path": "",
"git_sync_pdf_path_placeholder": "",
"git_sync_sub_path": "",
"git_sync_sub_path_placeholder": "",
"git_bridge_modal_review_access": "", "git_bridge_modal_review_access": "",
"git_bridge_modal_see_once": "", "git_bridge_modal_see_once": "",
"git_bridge_modal_use_previous_token": "", "git_bridge_modal_use_previous_token": "",
@@ -2265,6 +2286,12 @@
"turn_on": "", "turn_on": "",
"turn_on_link_sharing": "", "turn_on_link_sharing": "",
"typst_export_feedback_message": "", "typst_export_feedback_message": "",
"typst_preview_mode": "",
"typst_preview_pdf": "",
"typst_preview_wasm": "",
"experimental": "",
"typst_wasm_error": "",
"typst_wasm_loading": "",
"unarchive": "", "unarchive": "",
"uncategorized": "", "uncategorized": "",
"uncategorized_projects": "", "uncategorized_projects": "",
@@ -71,6 +71,7 @@ export const RailLayout = () => {
const { features } = useProjectContext() const { features } = useProjectContext()
const { isRestrictedTokenMember } = useEditorContext() const { isRestrictedTokenMember } = useEditorContext()
const gitBridgeEnabled = getMeta('ol-gitBridgeEnabled') const gitBridgeEnabled = getMeta('ol-gitBridgeEnabled')
const gitSyncEnabled = getMeta('ol-gitSyncEnabled')
const { isOverleaf } = getMeta('ol-ExposedSettings') const { isOverleaf } = getMeta('ol-ExposedSettings')
const { view, setLeftMenuShown } = useLayoutContext() const { view, setLeftMenuShown } = useLayoutContext()
@@ -116,10 +117,10 @@ export const RailLayout = () => {
}, },
{ {
key: 'integrations', key: 'integrations',
icon: 'integration_instructions', icon: 'autorenew',
title: t('integrations'), title: t('integrations'),
component: <IntegrationsPanel />, component: <IntegrationsPanel />,
hide: !isOverleaf && !gitBridgeEnabled, hide: !isOverleaf && !gitBridgeEnabled && !gitSyncEnabled,
}, },
{ {
key: 'review-panel', key: 'review-panel',
@@ -148,6 +149,7 @@ export const RailLayout = () => {
isRestrictedTokenMember, isRestrictedTokenMember,
isOverleaf, isOverleaf,
gitBridgeEnabled, gitBridgeEnabled,
gitSyncEnabled,
] ]
) )
@@ -0,0 +1,328 @@
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>
)
}
@@ -2,6 +2,8 @@ import { ElementType } from 'react'
import importOverleafModules from '../../../macros/import-overleaf-module.macro' import importOverleafModules from '../../../macros/import-overleaf-module.macro'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import RailPanelHeader from '@/features/ide-react/components/rail/rail-panel-header' import RailPanelHeader from '@/features/ide-react/components/rail/rail-panel-header'
import getMeta from '@/utils/meta'
import GitSyncWidget from './git-sync-widget'
const integrationPanelComponents = importOverleafModules( const integrationPanelComponents = importOverleafModules(
'integrationPanelComponents' 'integrationPanelComponents'
@@ -9,10 +11,12 @@ const integrationPanelComponents = importOverleafModules(
export default function IntegrationsPanel() { export default function IntegrationsPanel() {
const { t } = useTranslation() const { t } = useTranslation()
const gitSyncEnabled = getMeta('ol-gitSyncEnabled')
return ( return (
<div className="integrations-panel"> <div className="integrations-panel">
<RailPanelHeader title={t('integrations')} /> <RailPanelHeader title={t('integrations')} />
{gitSyncEnabled && <GitSyncWidget />}
{integrationPanelComponents.map( {integrationPanelComponents.map(
({ import: { default: Component }, path }) => ( ({ import: { default: Component }, path }) => (
<Component key={path} /> <Component key={path} />
@@ -51,6 +51,9 @@ function PdfCompileButton() {
smoothPdfTransition, smoothPdfTransition,
setSmoothPdfTransition, setSmoothPdfTransition,
isLatexProject, isLatexProject,
isTypstProject,
typstPreviewMode,
setTypstPreviewMode,
} = useCompileContext() } = useCompileContext()
const { enableStopOnFirstError, disableStopOnFirstError } = const { enableStopOnFirstError, disableStopOnFirstError } =
useStopOnFirstError({ eventSource: 'dropdown' }) useStopOnFirstError({ eventSource: 'dropdown' })
@@ -172,6 +175,33 @@ function PdfCompileButton() {
{t('off')} {t('off')}
</DropdownItem> </DropdownItem>
</li> </li>
{isTypstProject && (
<>
<DropdownDivider />
<DropdownHeader>{t('typst_preview_mode')}</DropdownHeader>
<li role="none">
<DropdownItem
as="button"
onClick={() => setTypstPreviewMode('wasm')}
trailingIcon={typstPreviewMode === 'wasm' ? 'check' : null}
>
{t('typst_preview_wasm')}{' '}
<span className="typst-preview-experimental-badge">
{t('experimental')}
</span>
</DropdownItem>
</li>
<li role="none">
<DropdownItem
as="button"
onClick={() => setTypstPreviewMode('pdf')}
trailingIcon={typstPreviewMode === 'pdf' ? 'check' : null}
>
{t('typst_preview_pdf')}
</DropdownItem>
</li>
</>
)}
{isLatexProject && ( {isLatexProject && (
<> <>
<DropdownDivider /> <DropdownDivider />
@@ -1,4 +1,4 @@
import { ElementType, memo, Suspense } from 'react' import { ElementType, lazy, memo, Suspense } from 'react'
import classNames from 'classnames' import classNames from 'classnames'
import PdfViewer from './pdf-viewer' import PdfViewer from './pdf-viewer'
import { FullSizeLoadingSpinner } from '../../../shared/components/loading-spinner' import { FullSizeLoadingSpinner } from '../../../shared/components/loading-spinner'
@@ -12,12 +12,22 @@ import PdfCodeCheckFailedBanner from '@/features/pdf-preview/components/pdf-code
import getMeta from '@/utils/meta' import getMeta from '@/utils/meta'
import PdfLogsViewer from '@/features/pdf-preview/components/pdf-logs-viewer' import PdfLogsViewer from '@/features/pdf-preview/components/pdf-logs-viewer'
const TypstWasmPreview = lazy(
() =>
import(
/* webpackChunkName: "typst-wasm-preview" */
'@/features/typst-preview/components/typst-wasm-preview'
)
)
function PdfPreviewPane() { function PdfPreviewPane() {
const { const {
pdfUrl, pdfUrl,
pdfViewer, pdfViewer,
darkModePdf: darkModeSetting, darkModePdf: darkModeSetting,
activeOverallTheme, activeOverallTheme,
isTypstProject,
typstPreviewMode,
} = useCompileContext() } = useCompileContext()
const { compileTimeout } = getMeta('ol-compileSettings') const { compileTimeout } = getMeta('ol-compileSettings')
const isHtmlOutput = pdfUrl?.includes('output.html') const isHtmlOutput = pdfUrl?.includes('output.html')
@@ -26,8 +36,9 @@ function PdfPreviewPane() {
activeOverallTheme === 'dark' && activeOverallTheme === 'dark' &&
darkModeSetting darkModeSetting
const isWasmMode = isTypstProject && typstPreviewMode === 'wasm'
const classes = classNames('pdf', 'full-size', { const classes = classNames('pdf', 'full-size', {
'pdf-empty': !pdfUrl, 'pdf-empty': !pdfUrl && !isWasmMode,
'pdf-dark-mode': darkModePdf, 'pdf-dark-mode': darkModePdf,
}) })
@@ -46,7 +57,11 @@ function PdfPreviewPane() {
</PdfPreviewMessages> </PdfPreviewMessages>
<Suspense fallback={<FullSizeLoadingSpinner delay={500} />}> <Suspense fallback={<FullSizeLoadingSpinner delay={500} />}>
<div className="pdf-viewer" data-testid="pdf-viewer"> <div className="pdf-viewer" data-testid="pdf-viewer">
<PdfViewer /> {isWasmMode ? (
<TypstWasmPreview />
) : (
<PdfViewer />
)}
</div> </div>
</Suspense> </Suspense>
<PdfLogsViewer /> <PdfLogsViewer />
@@ -0,0 +1,213 @@
import {
useEffect,
useRef,
useState,
useCallback,
memo,
type FC,
} from 'react'
import { useTranslation } from 'react-i18next'
import { createTypstRenderer } from '@myriaddreamin/typst.ts'
import type { TypstRenderer, RenderSession } from '@myriaddreamin/typst.ts'
import rendererWasmUrl from '@myriaddreamin/typst-ts-renderer/pkg/typst_ts_renderer_bg.wasm'
import { useLocalCompileContext } from '@/shared/context/local-compile-context'
import { useEditorViewContext } from '@/features/ide-react/context/editor-view-context'
type Status = 'initializing' | 'ready' | 'compiling' | 'error'
const createTypstWorker = () =>
new Worker(
/* webpackChunkName: "typst-preview-worker" */
new URL('../typst-preview-worker.ts', import.meta.url),
{ type: 'module' }
)
const TypstWasmPreview: FC = () => {
const { t } = useTranslation()
const { changedAt } = useLocalCompileContext()
const { view } = useEditorViewContext()
// Keep view in a ref so triggerCompile never has stale deps
// (avoids recreating the worker every time the view reference changes)
const viewRef = useRef(view)
useEffect(() => {
viewRef.current = view
}, [view])
const containerRef = useRef<HTMLDivElement>(null)
const workerRef = useRef<Worker | null>(null)
const rendererRef = useRef<TypstRenderer | null>(null)
// Persistent render session — created once, reused for all subsequent renders
// to avoid the Rust borrow/ownership panics that occur when runWithSession
// creates and frees a session on every render
const sessionRef = useRef<RenderSession | null>(null)
const isReadyRef = useRef(false)
const compileTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// If 'compiled' arrives before renderer is ready, buffer the latest vector data
const pendingVectorRef = useRef<Uint8Array | null>(null)
// Prevent concurrent renders
const isRenderingRef = useRef(false)
const renderQueueRef = useRef<Uint8Array | null>(null)
const [status, setStatus] = useState<Status>('initializing')
const [errorMsg, setErrorMsg] = useState('')
const doRender = useCallback(async (vectorData: Uint8Array) => {
const renderer = rendererRef.current
const container = containerRef.current
if (!renderer || !container) {
pendingVectorRef.current = vectorData
return
}
if (isRenderingRef.current) {
renderQueueRef.current = vectorData
return
}
let dataToRender: Uint8Array | null = vectorData
while (dataToRender) {
isRenderingRef.current = true
const data = dataToRender
dataToRender = null
try {
let session = sessionRef.current
if (!session) {
// createModule creates a persistent session loaded with the vector data.
// It does NOT go through runWithSession, so there is no automatic free()
// and no Rc::try_unwrap ownership conflict between renders.
session = await (renderer as any).createModule(data) as RenderSession
sessionRef.current = session
} else {
// Reset existing session with new vector data — synchronous, no ownership transfer
session.manipulateData({ action: 'reset', data })
}
// renderToSvg with an existing renderSession bypasses runWithSession entirely
// (withinOptionSession short-circuits to fn(session) when renderSession is present)
await session.renderToSvg({ container })
pendingVectorRef.current = null
setStatus('ready')
setErrorMsg('')
} catch (e) {
setStatus('error')
setErrorMsg(`Render failed: ${e}`)
} finally {
isRenderingRef.current = false
}
if (renderQueueRef.current) {
dataToRender = renderQueueRef.current
renderQueueRef.current = null
}
}
}, [])
// triggerCompile has no deps — reads view through viewRef
const triggerCompile = useCallback(() => {
const worker = workerRef.current
if (!worker || !isReadyRef.current) return
const content = viewRef.current?.state.doc.toString() ?? ''
if (!content) return
if (compileTimerRef.current) clearTimeout(compileTimerRef.current)
compileTimerRef.current = setTimeout(() => {
setStatus('compiling')
worker.postMessage({
type: 'compile',
content,
mainFilePath: '/main.typ',
})
}, 400)
}, []) // stable — no closure over view
// Init renderer (main thread, needs DOM). Stable dep: doRender.
useEffect(() => {
let cancelled = false
const renderer = createTypstRenderer()
renderer
.init({ getModule: () => fetch(rendererWasmUrl) })
.then(async () => {
if (cancelled) return
rendererRef.current = renderer
// Flush any compile result that arrived while renderer was loading
if (pendingVectorRef.current) {
await doRender(pendingVectorRef.current)
}
})
.catch(err => {
if (cancelled) return
setStatus('error')
setErrorMsg(`Renderer init failed: ${err}`)
})
return () => {
cancelled = true
sessionRef.current = null
rendererRef.current = null
}
}, [doRender])
// Init worker. Stable deps: triggerCompile, doRender.
useEffect(() => {
const worker = createTypstWorker()
workerRef.current = worker
worker.addEventListener('message', async event => {
const msg = event.data as
| { type: 'ready' }
| { type: 'compiled'; vectorData: Uint8Array; diagnostics: any[] }
| { type: 'error'; message: string }
if (msg.type === 'ready') {
isReadyRef.current = true
// Don't change status here — doRender will set 'ready' once rendered
triggerCompile()
}
if (msg.type === 'compiled') {
await doRender(msg.vectorData)
}
if (msg.type === 'error') {
setStatus('error')
setErrorMsg(msg.message)
}
})
return () => {
if (compileTimerRef.current) clearTimeout(compileTimerRef.current)
worker.terminate()
workerRef.current = null
isReadyRef.current = false
}
}, [triggerCompile, doRender]) // both stable
// Re-compile when document changes
useEffect(() => {
if (changedAt > 0) triggerCompile()
}, [changedAt, triggerCompile])
return (
<div className="typst-wasm-preview">
{status === 'initializing' && (
<div className="typst-wasm-preview-status">
<div className="typst-wasm-preview-spinner" />
<span>{t('typst_wasm_loading')}</span>
</div>
)}
{status === 'error' && (
<div className="typst-wasm-preview-error">
<strong>{t('typst_wasm_error')}</strong>
{errorMsg && <pre>{errorMsg}</pre>}
</div>
)}
<div
ref={containerRef}
className="typst-wasm-preview-container"
style={{ display: status === 'initializing' ? 'none' : 'block' }}
/>
</div>
)
}
export default memo(TypstWasmPreview)
@@ -0,0 +1,87 @@
import { createTypstCompiler, preloadRemoteFonts } from '@myriaddreamin/typst.ts'
import type { TypstCompiler } from '@myriaddreamin/typst.ts'
import compilerWasmUrl from '@myriaddreamin/typst-ts-web-compiler/pkg/typst_ts_web_compiler_bg.wasm'
type IncomingMessage = {
type: 'compile'
content: string
mainFilePath: string
}
type OutgoingMessage =
| { type: 'ready' }
| { type: 'compiled'; vectorData: Uint8Array; diagnostics: any[] }
| { type: 'error'; message: string }
let compiler: TypstCompiler | null = null
let compileQueue: IncomingMessage | null = null
let isCompiling = false
async function runCompile(msg: IncomingMessage) {
if (!compiler) return
isCompiling = true
try {
compiler.resetShadow()
compiler.addSource(msg.mainFilePath, msg.content)
const result = await (compiler as any).compile({
mainFilePath: msg.mainFilePath,
format: 0, // CompileFormatEnum.vector
diagnostics: 'full',
})
if (result?.result) {
self.postMessage(
{
type: 'compiled',
vectorData: result.result,
diagnostics: result.diagnostics ?? [],
} satisfies OutgoingMessage,
[result.result.buffer]
)
} else {
self.postMessage({
type: 'error',
message: 'Compilation produced no output',
} satisfies OutgoingMessage)
}
} catch (e) {
self.postMessage({
type: 'error',
message: String(e),
} satisfies OutgoingMessage)
} finally {
isCompiling = false
if (compileQueue) {
const next = compileQueue
compileQueue = null
await runCompile(next)
}
}
}
const initPromise = (async () => {
const c = createTypstCompiler()
await c.init({
getModule: () => fetch(compilerWasmUrl),
beforeBuild: [preloadRemoteFonts([], { assets: ['text'] })],
})
compiler = c
self.postMessage({ type: 'ready' } satisfies OutgoingMessage)
})().catch(err => {
compiler = null
self.postMessage({
type: 'error',
message: `Typst compiler init failed: ${err}`,
} satisfies OutgoingMessage)
})
self.addEventListener('message', async (event: MessageEvent<IncomingMessage>) => {
if (event.data.type === 'compile') {
await initPromise
if (!compiler) return
if (isCompiling) {
compileQueue = event.data
} else {
await runCompile(event.data)
}
}
})
@@ -77,6 +77,10 @@ export const DetachCompileProvider: FC<React.PropsWithChildren> = ({
smoothPdfTransition: _smoothPdfTransition, smoothPdfTransition: _smoothPdfTransition,
setSmoothPdfTransition: _setSmoothPdfTransition, setSmoothPdfTransition: _setSmoothPdfTransition,
isLatexProject: _isLatexProject, isLatexProject: _isLatexProject,
isTypstProject: _isTypstProject,
changedAt: _changedAt,
typstPreviewMode: _typstPreviewMode,
setTypstPreviewMode: _setTypstPreviewMode,
} = localCompileContext } = localCompileContext
const [animateCompileDropdownArrow] = useDetachStateWatcher( const [animateCompileDropdownArrow] = useDetachStateWatcher(
@@ -159,6 +163,24 @@ export const DetachCompileProvider: FC<React.PropsWithChildren> = ({
'detacher', 'detacher',
'detached' 'detached'
) )
const [isTypstProject] = useDetachStateWatcher(
'isTypstProject',
_isTypstProject,
'detacher',
'detached'
)
const [changedAt] = useDetachStateWatcher(
'changedAt',
_changedAt,
'detacher',
'detached'
)
const [typstPreviewMode] = useDetachStateWatcher(
'typstPreviewMode',
_typstPreviewMode,
'detacher',
'detached'
)
const [lastCompileOptions] = useDetachStateWatcher( const [lastCompileOptions] = useDetachStateWatcher(
'lastCompileOptions', 'lastCompileOptions',
_lastCompileOptions, _lastCompileOptions,
@@ -418,6 +440,13 @@ export const DetachCompileProvider: FC<React.PropsWithChildren> = ({
'detacher' 'detacher'
) )
const setTypstPreviewMode = useDetachAction(
'setTypstPreviewMode',
_setTypstPreviewMode,
'detached',
'detacher'
)
useCompileTriggers(startCompile, setChangedAt) useCompileTriggers(startCompile, setChangedAt)
useLogEvents(setShowLogs) useLogEvents(setShowLogs)
@@ -482,6 +511,10 @@ export const DetachCompileProvider: FC<React.PropsWithChildren> = ({
smoothPdfTransition, smoothPdfTransition,
setSmoothPdfTransition, setSmoothPdfTransition,
isLatexProject, isLatexProject,
isTypstProject,
changedAt,
typstPreviewMode,
setTypstPreviewMode,
}), }),
[ [
animateCompileDropdownArrow, animateCompileDropdownArrow,
@@ -541,6 +574,10 @@ export const DetachCompileProvider: FC<React.PropsWithChildren> = ({
smoothPdfTransition, smoothPdfTransition,
setSmoothPdfTransition, setSmoothPdfTransition,
isLatexProject, isLatexProject,
isTypstProject,
changedAt,
typstPreviewMode,
setTypstPreviewMode,
] ]
) )
@@ -133,6 +133,10 @@ export type CompileContext = {
setDarkModePdf: (value: boolean) => void setDarkModePdf: (value: boolean) => void
activeOverallTheme: ActiveOverallTheme activeOverallTheme: ActiveOverallTheme
isLatexProject: boolean isLatexProject: boolean
isTypstProject: boolean
changedAt: number
typstPreviewMode: 'wasm' | 'pdf'
setTypstPreviewMode: (value: 'wasm' | 'pdf') => void
} }
export const LocalCompileContext = createContext<CompileContext | undefined>( export const LocalCompileContext = createContext<CompileContext | undefined>(
@@ -305,6 +309,9 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
const [isTypstProject, setIsTypstProject] = useState(false) const [isTypstProject, setIsTypstProject] = useState(false)
const [isQuartoProject, setIsQuartoProject] = useState(false) const [isQuartoProject, setIsQuartoProject] = useState(false)
const isLatexProject = !isTypstProject && !isQuartoProject const isLatexProject = !isTypstProject && !isQuartoProject
const [typstPreviewMode, setTypstPreviewMode] = usePersistedState<
'wasm' | 'pdf'
>(`typst_preview_mode:${projectId}`, 'pdf', { listen: true })
const smoothPdfTransition = isTypstProject const smoothPdfTransition = isTypstProject
? smoothTransitionTypst ? smoothTransitionTypst
: smoothTransitionLatex : smoothTransitionLatex
@@ -879,6 +886,10 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
smoothPdfTransition, smoothPdfTransition,
setSmoothPdfTransition, setSmoothPdfTransition,
isLatexProject, isLatexProject,
isTypstProject,
changedAt,
typstPreviewMode,
setTypstPreviewMode,
}), }),
[ [
animateCompileDropdownArrow, animateCompileDropdownArrow,
@@ -937,6 +948,10 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
smoothPdfTransition, smoothPdfTransition,
setSmoothPdfTransition, setSmoothPdfTransition,
isLatexProject, isLatexProject,
isTypstProject,
changedAt,
typstPreviewMode,
setTypstPreviewMode,
] ]
) )
+7
View File
@@ -136,6 +136,13 @@ export interface Meta {
'ol-galleryTagName': string 'ol-galleryTagName': string
'ol-gitBridgeEnabled': boolean 'ol-gitBridgeEnabled': boolean
'ol-gitBridgePublicBaseUrl': string 'ol-gitBridgePublicBaseUrl': string
'ol-gitSyncEnabled': boolean
'ol-gitRemote': string
'ol-gitSyncPath': string
'ol-gitSyncPdfPath': string
'ol-gitSyncPushFiles': boolean
'ol-gitSyncPushPdf': boolean
'ol-gitSyncBranch': string
'ol-github': { enabled: boolean; error: boolean } 'ol-github': { enabled: boolean; error: boolean }
'ol-groupAuditLogs': [] 'ol-groupAuditLogs': []
'ol-groupDomains': [] 'ol-groupDomains': []
@@ -99,3 +99,39 @@
color: var(--integrations-panel-description-color); color: var(--integrations-panel-description-color);
margin-bottom: 0; margin-bottom: 0;
} }
.git-sync-widget {
padding: var(--spacing-04);
border-bottom: 1px solid var(--integrations-panel-card-border);
.git-sync-widget-header {
display: flex;
align-items: center;
gap: var(--spacing-02);
margin-bottom: var(--spacing-02);
}
.git-sync-widget-title {
font-size: var(--font-size-02);
font-weight: 600;
color: var(--integrations-panel-color);
}
.git-sync-widget-description {
font-size: var(--font-size-01);
color: var(--integrations-panel-description-color);
margin-bottom: var(--spacing-04);
}
.git-sync-widget-actions {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-02);
margin-top: var(--spacing-02);
.btn {
flex: 1 1 auto;
white-space: nowrap;
}
}
}
@@ -1,5 +1,11 @@
$stripe-width: 20px; $stripe-width: 20px;
.typst-preview-experimental-badge {
font-size: 0.7em;
font-style: italic;
opacity: 0.65;
}
@keyframes pdf-toolbar-stripes { @keyframes pdf-toolbar-stripes {
from { from {
background-position: 0 0; background-position: 0 0;
@@ -520,3 +520,64 @@
display: flex; display: flex;
gap: 20px; gap: 20px;
} }
.typst-wasm-preview {
width: 100%;
height: 100%;
overflow-y: auto;
background: var(--pdf-bg);
display: flex;
flex-direction: column;
align-items: center;
}
.typst-wasm-preview-status {
display: flex;
align-items: center;
gap: var(--spacing-04);
padding: var(--spacing-08);
color: var(--content-secondary);
}
.typst-wasm-preview-spinner {
width: 20px;
height: 20px;
border: 2px solid var(--neutral-40);
border-top-color: var(--content-primary);
border-radius: 50%;
animation: typst-spin 0.8s linear infinite;
}
@keyframes typst-spin {
to { transform: rotate(360deg); }
}
.typst-wasm-preview-error {
padding: var(--spacing-06);
color: var(--content-danger);
max-width: 100%;
pre {
font-size: 0.75rem;
white-space: pre-wrap;
word-break: break-word;
margin-top: var(--spacing-02);
background: var(--bg-dark-tertiary);
padding: var(--spacing-03);
border-radius: var(--border-radius-base);
}
}
.typst-wasm-preview-container {
width: 100%;
padding: var(--spacing-04);
svg {
max-width: 100%;
height: auto;
display: block;
margin: 0 auto var(--spacing-04);
background: #fff;
box-shadow: 0 2px 8px rgb(0 0 0 / 20%);
}
}
+27
View File
@@ -1043,6 +1043,27 @@
"git_bridge_modal_git_clone_your_project": "Git clone your project by using the link below and a Git authentication token", "git_bridge_modal_git_clone_your_project": "Git clone your project by using the link below and a Git authentication token",
"git_bridge_modal_learn_more_about_authentication_tokens": "Learn more about Git integration authentication tokens.", "git_bridge_modal_learn_more_about_authentication_tokens": "Learn more about Git integration authentication tokens.",
"git_bridge_modal_read_only": "<strong>You have read-only access to this project.</strong> This means you can pull from __appName__ but you cant push any changes you make back to this project.", "git_bridge_modal_read_only": "<strong>You have read-only access to this project.</strong> This means you can pull from __appName__ but you cant push any changes you make back to this project.",
"git_sync": "Git Sync",
"git_sync_description": "Force-push your project files to an external git repository. Include your auth token in the URL (e.g. https://user:token@github.com/org/repo.git).",
"git_sync_pull_now": "Pull from git",
"git_sync_pull_success": "Project updated from git remote successfully.",
"git_sync_pulling": "Pulling…",
"git_sync_push_now": "Push now",
"git_sync_pushing": "Pushing…",
"git_sync_push_success": "Project pushed to git remote successfully.",
"git_sync_remote_url": "Remote URL (with auth token)",
"git_sync_saved": "Settings saved.",
"git_sync_auto_push_on_compile": "Auto-push on successful compile",
"git_sync_branch": "Branch",
"git_sync_confirm_reset": "The \"__path__\" directory in the git repository will be completely replaced by the Verso project files. Any other files in that directory will be deleted. Are you sure?",
"git_sync_confirm_push": "Yes, push and replace",
"git_sync_repo_root": "repository root",
"git_sync_push_files": "Push project files",
"git_sync_push_pdf": "Push compiled PDF",
"git_sync_pdf_path": "PDF destination path (optional)",
"git_sync_pdf_path_placeholder": "e.g. output/main.pdf (leave blank to skip PDF)",
"git_sync_sub_path": "Subdirectory (optional)",
"git_sync_sub_path_placeholder": "e.g. docs/my-project (leave blank for repo root)",
"git_bridge_modal_review_access": "<0>You have review access to this project.</0> This means you can pull from __appName__ but you cant push any changes you make back to this project.", "git_bridge_modal_review_access": "<0>You have review access to this project.</0> This means you can pull from __appName__ but you cant push any changes you make back to this project.",
"git_bridge_modal_see_once": "Youll only see this token once. To delete it or generate a new one, visit Account settings. For detailed instructions and troubleshooting, read our <0>help page</0>.", "git_bridge_modal_see_once": "Youll only see this token once. To delete it or generate a new one, visit Account settings. For detailed instructions and troubleshooting, read our <0>help page</0>.",
"git_bridge_modal_use_previous_token": "If youre prompted for a password, you can use a previously generated Git authentication token. Or you can generate a new one in Account settings. For more support, read our <0>help page</0>.", "git_bridge_modal_use_previous_token": "If youre prompted for a password, you can use a previously generated Git authentication token. Or you can generate a new one in Account settings. For more support, read our <0>help page</0>.",
@@ -2899,6 +2920,12 @@
"turn_on_password_visibility": "Turn on password visibility", "turn_on_password_visibility": "Turn on password visibility",
"tutorials": "Tutorials", "tutorials": "Tutorials",
"typst_export_feedback_message": "This conversion may contain errors — pandoc does not support all LaTeX constructs.", "typst_export_feedback_message": "This conversion may contain errors — pandoc does not support all LaTeX constructs.",
"typst_preview_mode": "Preview mode",
"typst_preview_pdf": "PDF (server)",
"typst_preview_wasm": "Live (browser)",
"experimental": "experimental",
"typst_wasm_error": "Preview error",
"typst_wasm_loading": "Loading Typst compiler…",
"uk": "Ukrainian", "uk": "Ukrainian",
"unable_to_extract_the_supplied_zip_file": "Opening this content on Overleaf failed because the zip file could not be extracted. Please ensure that it is a valid zip file. If this keeps happening for links on a particular site, please report this to them.", "unable_to_extract_the_supplied_zip_file": "Opening this content on Overleaf failed because the zip file could not be extracted. Please ensure that it is a valid zip file. If this keeps happening for links on a particular site, please report this to them.",
"unarchive": "Restore", "unarchive": "Restore",
+27
View File
@@ -1046,6 +1046,27 @@
"git_bridge_modal_git_clone_your_project": "Git clonez votre projet en utilisant le lien ci-dessous et un jeton d'authentification Git", "git_bridge_modal_git_clone_your_project": "Git clonez votre projet en utilisant le lien ci-dessous et un jeton d'authentification Git",
"git_bridge_modal_learn_more_about_authentication_tokens": "Apprenez-en davantage sur les jetons dauthentification de lintégration Git.", "git_bridge_modal_learn_more_about_authentication_tokens": "Apprenez-en davantage sur les jetons dauthentification de lintégration Git.",
"git_bridge_modal_read_only": "<strong>Vous disposez d'un accès en lecture seule à ce projet.</strong> Cela signifie que vous pouvez extraire des données de __appName__, mais que vous ne pouvez pas transférer les modifications que vous apportez à ce projet.", "git_bridge_modal_read_only": "<strong>Vous disposez d'un accès en lecture seule à ce projet.</strong> Cela signifie que vous pouvez extraire des données de __appName__, mais que vous ne pouvez pas transférer les modifications que vous apportez à ce projet.",
"git_sync": "Synchronisation Git",
"git_sync_description": "Pousse de force les fichiers du projet vers un dépôt git externe. Incluez votre jeton d'authentification dans l'URL (ex. https://user:token@github.com/org/repo.git).",
"git_sync_pull_now": "Tirer depuis git",
"git_sync_pull_success": "Projet mis à jour depuis le dépôt git avec succès.",
"git_sync_pulling": "Récupération…",
"git_sync_push_now": "Pousser maintenant",
"git_sync_pushing": "Envoi en cours…",
"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_branch": "Branche",
"git_sync_confirm_reset": "Le répertoire \"__path__\" du dépôt git sera entièrement remplacé par les fichiers du projet Verso. Tout autre fichier dans ce répertoire sera supprimé. Êtes-vous sûr ?",
"git_sync_confirm_push": "Oui, pousser et remplacer",
"git_sync_repo_root": "racine du dépôt",
"git_sync_push_files": "Pousser les fichiers du projet",
"git_sync_push_pdf": "Pousser le PDF compilé",
"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.</0> Cela signifie que vous pouvez extraire de __appName__ mais vous ne pouvez pas repousser les modifications que vous apportez à ce projet.", "git_bridge_modal_review_access": "<0>You have review access to this project.</0> Cela signifie que vous pouvez extraire de __appName__ mais vous ne pouvez pas repousser les modifications que vous apportez à ce projet.",
"git_bridge_modal_see_once": "Vous nallez voir ce jeton quune seule fois. Pour leffacer ou pour en générer un nouveau, allez dans Paramètres du compte. Pour des instructions détaillées ou pour résoudre des problèmes, lisez notre <0>page daide</0>.", "git_bridge_modal_see_once": "Vous nallez voir ce jeton quune seule fois. Pour leffacer ou pour en générer un nouveau, allez dans Paramètres du compte. Pour des instructions détaillées ou pour résoudre des problèmes, lisez notre <0>page daide</0>.",
"git_bridge_modal_use_previous_token": "Si un mot de passe vous est demandé, vous pouvez utiliser un jeton Git précédent ou en générer un nouveau dans Paramètres du compte. Pour plus dinformations, lisez notre <0>page daide</0>.", "git_bridge_modal_use_previous_token": "Si un mot de passe vous est demandé, vous pouvez utiliser un jeton Git précédent ou en générer un nouveau dans Paramètres du compte. Pour plus dinformations, lisez notre <0>page daide</0>.",
@@ -2904,6 +2925,12 @@
"turn_on_password_visibility": "Activer la visibilité du mot de passe", "turn_on_password_visibility": "Activer la visibilité du mot de passe",
"tutorials": "Tutoriels", "tutorials": "Tutoriels",
"typst_export_feedback_message": "Cette conversion peut contenir des erreurs — pandoc ne supporte pas toutes les constructions LaTeX.", "typst_export_feedback_message": "Cette conversion peut contenir des erreurs — pandoc ne supporte pas toutes les constructions LaTeX.",
"typst_preview_mode": "Mode de prévisualisation",
"typst_preview_pdf": "PDF (serveur)",
"typst_preview_wasm": "Direct (navigateur)",
"experimental": "expérimental",
"typst_wasm_error": "Erreur de prévisualisation",
"typst_wasm_loading": "Chargement du compilateur Typst…",
"uk": "Ukrainien", "uk": "Ukrainien",
"unable_to_extract_the_supplied_zip_file": "Louverture de ce contenu sur Overleaf a échoué car larchive na pas pu être extraite. Veuillez vous assurer de la validité de cette archive. Si cela se produit régulièrement pour un site donné, veuillez leur faire part du problème.", "unable_to_extract_the_supplied_zip_file": "Louverture de ce contenu sur Overleaf a échoué car larchive na pas pu être extraite. Veuillez vous assurer de la validité de cette archive. Si cela se produit régulièrement pour un site donné, veuillez leur faire part du problème.",
"unarchive": "Restaurer", "unarchive": "Restaurer",
+3
View File
@@ -84,6 +84,9 @@
"@customerio/cdp-analytics-node": "^0.3.9", "@customerio/cdp-analytics-node": "^0.3.9",
"@google-cloud/bigquery": "^8.1.1", "@google-cloud/bigquery": "^8.1.1",
"@google-cloud/storage": "^7.19.0", "@google-cloud/storage": "^7.19.0",
"@myriaddreamin/typst-ts-renderer": "0.7.0",
"@myriaddreamin/typst-ts-web-compiler": "0.7.0",
"@myriaddreamin/typst.ts": "0.7.0",
"@node-oauth/oauth2-server": "^5.3.0", "@node-oauth/oauth2-server": "^5.3.0",
"@node-saml/passport-saml": "^5.1.0", "@node-saml/passport-saml": "^5.1.0",
"@overleaf/access-token-encryptor": "workspace:*", "@overleaf/access-token-encryptor": "workspace:*",
@@ -750,6 +750,13 @@ const BASE_COMPILE_CONTEXT_MOCK = {
darkModePdf: false, darkModePdf: false,
setDarkModePdf: () => {}, setDarkModePdf: () => {},
activeOverallTheme: 'light', activeOverallTheme: 'light',
smoothPdfTransition: false,
setSmoothPdfTransition: () => {},
isLatexProject: true,
isTypstProject: false,
changedAt: 0,
typstPreviewMode: 'wasm' as const,
setTypstPreviewMode: () => {},
} as const } as const
const makeDetachCompileProvider = (mockCompileOnLoad: boolean = false) => { const makeDetachCompileProvider = (mockCompileOnLoad: boolean = false) => {
+131 -4033
View File
File diff suppressed because it is too large Load Diff