Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0caa4ff144 | |||
| af906d90b9 | |||
| c041719e6a | |||
| dbb519835a | |||
| 55ad9af7da | |||
| be8aef44fe | |||
| 9a474f7790 | |||
| 8ea6f6ecb1 | |||
| 0e18230d9a | |||
| 9c97e7c01a | |||
| e1533d979f | |||
| d5de6550d4 | |||
| 71f1b928e9 | |||
| f22ee608a7 | |||
| 9f1c9babf7 | |||
| 44a9adcea1 | |||
| a17d21c0ca | |||
| a7a569303c | |||
| 8214ca6121 | |||
| 4f5dad383b | |||
| eedf4b50f6 | |||
| c6d71e58b7 | |||
| 06085cda21 | |||
| 8515a899ac | |||
| 200bff4ecb | |||
| b5cf5f9e7b |
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
// ---------
|
// ---------
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 ?? []),
|
||||||
|
|||||||
@@ -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 }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 can’t 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 can’t 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 can’t 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 can’t push any changes you make back to this project.",
|
||||||
"git_bridge_modal_see_once": "You’ll 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": "You’ll 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 you’re 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 you’re 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",
|
||||||
|
|||||||
@@ -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 d’authentification de l’intégration Git.",
|
"git_bridge_modal_learn_more_about_authentication_tokens": "Apprenez-en davantage sur les jetons d’authentification de l’inté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 n’allez voir ce jeton qu’une seule fois. Pour l’effacer 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 d’aide</0>.",
|
"git_bridge_modal_see_once": "Vous n’allez voir ce jeton qu’une seule fois. Pour l’effacer 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 d’aide</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 d’informations, lisez notre <0>page d’aide</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 d’informations, lisez notre <0>page d’aide</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": "L’ouverture de ce contenu sur Overleaf a échoué car l’archive n’a 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": "L’ouverture de ce contenu sur Overleaf a échoué car l’archive n’a 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",
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user