feat: set-as-main for .typ/.qmd; fix compiler filter; fix ZIP import compiler
Build and Deploy Verso / deploy (push) Successful in 14m56s

Set as main document (context menu):
- canSetRootDocId used selectedEntityIds so .typ/.qmd files couldn't be
  set as main via right-click on an unselected file.
  Now computed locally from contextMenuEntityId (same pattern as convert)
  using isValidTeXFile which already covers .typ, .qmd, .tex etc.

Compiler filter (editor settings):
- docs?.find(id) could return undefined due to ID format mismatch,
  causing all engines to show as available for non-LaTeX projects.
  Added findInTree fallback so the root doc name is always resolved.

ZIP import compiler:
- Projects created from ZIP always got defaultLatexCompiler ('quarto')
  regardless of content.
- findRootDocFileFromDirectory now also searches for .typ and .qmd root
  files after finding no .tex file.
- ProjectUploadManager now infers the compiler from the root doc
  extension and sets it on the project after import.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
claude
2026-06-17 21:56:39 +00:00
parent bc131f6440
commit b0b389dc4c
4 changed files with 98 additions and 29 deletions
@@ -51,24 +51,20 @@ async function setRootDocAutomatically(projectId) {
} }
async function findRootDocFileFromDirectory(directoryPath) { async function findRootDocFileFromDirectory(directoryPath) {
const unsortedFiles = await globby(['**/*.{tex,Rtex,Rnw}'], { // First try LaTeX files (look for \documentclass)
const unsortedTexFiles = await globby(['**/*.{tex,Rtex,Rnw,ltx}'], {
cwd: directoryPath, cwd: directoryPath,
followSymlinkedDirectories: false, followSymlinkedDirectories: false,
onlyFiles: true, onlyFiles: true,
case: false, case: false,
}) })
// the search order is such that we prefer files closer to the project root, then const texFiles = await _sortFileList(unsortedTexFiles, directoryPath)
// we go by file size in ascending order, because people often have a main let firstTexInRootFolder
// file that just includes a bunch of other files; then we go by name, in
// order to be deterministic
const files = await _sortFileList(unsortedFiles, directoryPath)
let firstFileInRootFolder
let doc = null let doc = null
while (files.length > 0 && doc == null) { while (texFiles.length > 0 && doc == null) {
const file = files.shift() const file = texFiles.shift()
const content = await fs.promises.readFile( const content = await fs.promises.readFile(
Path.join(directoryPath, file), Path.join(directoryPath, file),
'utf8' 'utf8'
@@ -77,18 +73,52 @@ async function findRootDocFileFromDirectory(directoryPath) {
if (DocumentHelper.contentHasDocumentclass(normalizedContent)) { if (DocumentHelper.contentHasDocumentclass(normalizedContent)) {
doc = { path: file, content: normalizedContent } doc = { path: file, content: normalizedContent }
} }
if (!firstTexInRootFolder && !file.includes('/')) {
if (!firstFileInRootFolder && !file.includes('/')) { firstTexInRootFolder = { path: file, content: normalizedContent }
firstFileInRootFolder = { path: file, content: normalizedContent }
} }
} }
// if no doc was found, use the first file in the root folder as the main doc if (!doc && firstTexInRootFolder) {
if (!doc && firstFileInRootFolder) { doc = firstTexInRootFolder
doc = firstFileInRootFolder
} }
return { path: doc?.path, content: doc?.content } if (doc) return { path: doc.path, content: doc.content }
// Then try Typst files
const typFiles = await globby(['*.typ'], {
cwd: directoryPath,
followSymlinkedDirectories: false,
onlyFiles: true,
case: false,
})
if (typFiles.length > 0) {
typFiles.sort()
const file = typFiles[0]
const content = await fs.promises.readFile(
Path.join(directoryPath, file),
'utf8'
)
return { path: file, content }
}
// Then try Quarto/R Markdown files
const qmdFiles = await globby(['*.{qmd,Rmd,rmd}'], {
cwd: directoryPath,
followSymlinkedDirectories: false,
onlyFiles: true,
case: false,
})
if (qmdFiles.length > 0) {
qmdFiles.sort()
const file = qmdFiles[0]
const content = await fs.promises.readFile(
Path.join(directoryPath, file),
'utf8'
)
return { path: file, content }
}
return { path: undefined, content: undefined }
} }
async function setRootDocFromName(projectId, rootDocName) { async function setRootDocFromName(projectId, rootDocName) {
@@ -14,10 +14,21 @@ import ProjectRootDocManager from '../Project/ProjectRootDocManager.mjs'
import ProjectDetailsHandler from '../Project/ProjectDetailsHandler.mjs' import ProjectDetailsHandler from '../Project/ProjectDetailsHandler.mjs'
import ProjectDeleter from '../Project/ProjectDeleter.mjs' import ProjectDeleter from '../Project/ProjectDeleter.mjs'
import { DeletedProjectReasons } from '../Project/DeletedProjectReasons.mjs' import { DeletedProjectReasons } from '../Project/DeletedProjectReasons.mjs'
import ProjectOptionsHandler from '../Project/ProjectOptionsHandler.mjs'
import TpdsProjectFlusher from '../ThirdPartyDataStore/TpdsProjectFlusher.mjs' import TpdsProjectFlusher from '../ThirdPartyDataStore/TpdsProjectFlusher.mjs'
import logger from '@overleaf/logger' import logger from '@overleaf/logger'
import OError from '@overleaf/o-error' import OError from '@overleaf/o-error'
const COMPILER_BY_EXTENSION = {
tex: 'pdflatex',
rtex: 'pdflatex',
ltx: 'pdflatex',
rnw: 'pdflatex',
typ: 'typst',
qmd: 'quarto',
rmd: 'quarto',
}
export default { export default {
createProjectFromZipArchive: callbackify(createProjectFromZipArchive), createProjectFromZipArchive: callbackify(createProjectFromZipArchive),
createProjectFromZipArchiveWithName: callbackify( createProjectFromZipArchiveWithName: callbackify(
@@ -52,6 +63,11 @@ async function createProjectFromZipArchive(ownerId, defaultName, zipPath) {
project._id, project._id,
path path
) )
const ext = path.split('.').pop()?.toLowerCase()
const compiler = ext ? COMPILER_BY_EXTENSION[ext] : null
if (compiler) {
await ProjectOptionsHandler.promises.setCompiler(project._id, compiler)
}
} }
} catch (err) { } catch (err) {
// no need to wait for the cleanup here // no need to wait for the cleanup here
@@ -14,6 +14,8 @@ import { useFileTreeMainContext } from '../../contexts/file-tree-main'
import { findInTree } from '../../util/find-in-tree' import { findInTree } from '../../util/find-in-tree'
import useConvertDoc from '@/features/ide-react/hooks/use-convert-doc' import useConvertDoc from '@/features/ide-react/hooks/use-convert-doc'
import getMeta from '@/utils/meta' import getMeta from '@/utils/meta'
import { isValidTeXFile } from '@/main/is-valid-tex-file'
import { syncRootDocId } from '../../util/sync-mutation'
function FileTreeItemMenuItems() { function FileTreeItemMenuItems() {
const { t } = useTranslation() const { t } = useTranslation()
@@ -29,12 +31,11 @@ function FileTreeItemMenuItems() {
startUploadingDocOrFile, startUploadingDocOrFile,
downloadPath, downloadPath,
selectedFileName, selectedFileName,
canSetRootDocId,
setRootDocId,
} = useFileTreeActionable() } = useFileTreeActionable()
const { project } = useProjectContext() const { project, projectId, updateProject } = useProjectContext()
const projectOwner = project?.owner?._id const projectOwner = project?.owner?._id
const rootDocId = project?.rootDocId
const { fileTreeData, fileTreeReadOnly } = useFileTreeData() const { fileTreeData, fileTreeReadOnly } = useFileTreeData()
const { selectedEntityIds } = useFileTreeSelectable() const { selectedEntityIds } = useFileTreeSelectable()
@@ -42,7 +43,7 @@ function FileTreeItemMenuItems() {
const selectedEntityId = const selectedEntityId =
selectedEntityIds.size === 1 ? Array.from(selectedEntityIds)[0] : null selectedEntityIds.size === 1 ? Array.from(selectedEntityIds)[0] : null
// Use context-menu-target entity for convert; falls back to selection // Use context-menu-target entity for convert/set-as-main; falls back to selection
const convertEntityId = contextMenuEntityId ?? selectedEntityId const convertEntityId = contextMenuEntityId ?? selectedEntityId
const convertEntity = convertEntityId const convertEntity = convertEntityId
? findInTree(fileTreeData, convertEntityId) ? findInTree(fileTreeData, convertEntityId)
@@ -64,6 +65,20 @@ function FileTreeItemMenuItems() {
isConvertableDoc && isConvertableDoc &&
convertEntityName?.endsWith('.typ') convertEntityName?.endsWith('.typ')
const canShowSetAsMain =
!fileTreeReadOnly &&
isConvertableDoc &&
!!convertEntityId &&
convertEntityId !== rootDocId &&
!!convertEntityName &&
isValidTeXFile(convertEntityName)
const handleSetAsMain = useCallback(async () => {
if (!convertEntityId) return
await syncRootDocId(projectId, convertEntityId)
updateProject({ rootDocId: convertEntityId })
}, [convertEntityId, projectId, updateProject])
const { convert: convertToTypst } = useConvertDoc('typst', convertEntityId) const { convert: convertToTypst } = useConvertDoc('typst', convertEntityId)
const { convert: convertToLatex } = useConvertDoc('latex', convertEntityId) const { convert: convertToLatex } = useConvertDoc('latex', convertEntityId)
@@ -102,11 +117,11 @@ function FileTreeItemMenuItems() {
</DropdownItem> </DropdownItem>
</li> </li>
) : null} ) : null}
{canSetRootDocId ? ( {canShowSetAsMain ? (
<> <>
<DropdownDivider /> <DropdownDivider />
<li role="none"> <li role="none">
<DropdownItem onClick={setRootDocId}> <DropdownItem onClick={handleSetAsMain}>
{t('set_as_main_document')} {t('set_as_main_document')}
</DropdownItem> </DropdownItem>
</li> </li>
@@ -7,6 +7,7 @@ import { usePermissionsContext } from '@/features/ide-react/context/permissions-
import { ProjectCompiler } from '@ol-types/project-settings' import { ProjectCompiler } from '@ol-types/project-settings'
import { useSetCompilationSettingWithEvent } from '@/features/editor-left-menu/hooks/use-set-compilation-setting' import { useSetCompilationSettingWithEvent } from '@/features/editor-left-menu/hooks/use-set-compilation-setting'
import { useFileTreeData } from '@/shared/context/file-tree-data-context' import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import { findInTree } from '@/features/file-tree/util/find-in-tree'
// Which compiler engines make sense for a given root-file extension. CLSI // Which compiler engines make sense for a given root-file extension. CLSI
// dispatches the real engine from this extension (.qmd → Quarto, .typ → Typst, // dispatches the real engine from this extension (.qmd → Quarto, .typ → Typst,
@@ -34,7 +35,7 @@ export default function CompilerSetting() {
const [compilerOptions] = useState(() => getCompilerOptions()) const [compilerOptions] = useState(() => getCompilerOptions())
const { t } = useTranslation() const { t } = useTranslation()
const { write } = usePermissionsContext() const { write } = usePermissionsContext()
const { docs } = useFileTreeData() const { docs, fileTreeData } = useFileTreeData()
const changeCompiler = useSetCompilationSettingWithEvent( const changeCompiler = useSetCompilationSettingWithEvent(
'compiler', 'compiler',
setCompiler setCompiler
@@ -43,14 +44,21 @@ export default function CompilerSetting() {
// Disable the engines that don't apply to the current root file's extension. // Disable the engines that don't apply to the current root file's extension.
// The currently-selected engine is always left enabled so it keeps showing. // The currently-selected engine is always left enabled so it keeps showing.
const options = useMemo(() => { const options = useMemo(() => {
const rootDoc = rootDocId let rootDocName: string | undefined
? docs?.find(doc => doc.doc.id === rootDocId) if (rootDocId) {
: undefined const fromDocs = docs?.find(doc => doc.doc.id === rootDocId)
const extension = rootDoc?.doc.name.split('.').pop()?.toLowerCase() if (fromDocs) {
rootDocName = fromDocs.doc.name
} else if (fileTreeData) {
// Fallback: look up directly in tree in case of ID format mismatch
const found = findInTree(fileTreeData, rootDocId)
if (found?.type === 'doc') rootDocName = found.entity.name
}
}
const extension = rootDocName?.split('.').pop()?.toLowerCase()
const allowed = extension ? ENGINES_BY_EXTENSION[extension] : undefined const allowed = extension ? ENGINES_BY_EXTENSION[extension] : undefined
if (!allowed) { if (!allowed) {
// Unknown / no root file: don't restrict anything.
return compilerOptions return compilerOptions
} }