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) {
const unsortedFiles = await globby(['**/*.{tex,Rtex,Rnw}'], {
// First try LaTeX files (look for \documentclass)
const unsortedTexFiles = await globby(['**/*.{tex,Rtex,Rnw,ltx}'], {
cwd: directoryPath,
followSymlinkedDirectories: false,
onlyFiles: true,
case: false,
})
// the search order is such that we prefer files closer to the project root, then
// we go by file size in ascending order, because people often have a main
// 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
const texFiles = await _sortFileList(unsortedTexFiles, directoryPath)
let firstTexInRootFolder
let doc = null
while (files.length > 0 && doc == null) {
const file = files.shift()
while (texFiles.length > 0 && doc == null) {
const file = texFiles.shift()
const content = await fs.promises.readFile(
Path.join(directoryPath, file),
'utf8'
@@ -77,18 +73,52 @@ async function findRootDocFileFromDirectory(directoryPath) {
if (DocumentHelper.contentHasDocumentclass(normalizedContent)) {
doc = { path: file, content: normalizedContent }
}
if (!firstFileInRootFolder && !file.includes('/')) {
firstFileInRootFolder = { path: file, content: normalizedContent }
if (!firstTexInRootFolder && !file.includes('/')) {
firstTexInRootFolder = { path: file, content: normalizedContent }
}
}
// if no doc was found, use the first file in the root folder as the main doc
if (!doc && firstFileInRootFolder) {
doc = firstFileInRootFolder
if (!doc && firstTexInRootFolder) {
doc = firstTexInRootFolder
}
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) {
@@ -14,10 +14,21 @@ import ProjectRootDocManager from '../Project/ProjectRootDocManager.mjs'
import ProjectDetailsHandler from '../Project/ProjectDetailsHandler.mjs'
import ProjectDeleter from '../Project/ProjectDeleter.mjs'
import { DeletedProjectReasons } from '../Project/DeletedProjectReasons.mjs'
import ProjectOptionsHandler from '../Project/ProjectOptionsHandler.mjs'
import TpdsProjectFlusher from '../ThirdPartyDataStore/TpdsProjectFlusher.mjs'
import logger from '@overleaf/logger'
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 {
createProjectFromZipArchive: callbackify(createProjectFromZipArchive),
createProjectFromZipArchiveWithName: callbackify(
@@ -52,6 +63,11 @@ async function createProjectFromZipArchive(ownerId, defaultName, zipPath) {
project._id,
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) {
// 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 useConvertDoc from '@/features/ide-react/hooks/use-convert-doc'
import getMeta from '@/utils/meta'
import { isValidTeXFile } from '@/main/is-valid-tex-file'
import { syncRootDocId } from '../../util/sync-mutation'
function FileTreeItemMenuItems() {
const { t } = useTranslation()
@@ -29,12 +31,11 @@ function FileTreeItemMenuItems() {
startUploadingDocOrFile,
downloadPath,
selectedFileName,
canSetRootDocId,
setRootDocId,
} = useFileTreeActionable()
const { project } = useProjectContext()
const { project, projectId, updateProject } = useProjectContext()
const projectOwner = project?.owner?._id
const rootDocId = project?.rootDocId
const { fileTreeData, fileTreeReadOnly } = useFileTreeData()
const { selectedEntityIds } = useFileTreeSelectable()
@@ -42,7 +43,7 @@ function FileTreeItemMenuItems() {
const selectedEntityId =
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 convertEntity = convertEntityId
? findInTree(fileTreeData, convertEntityId)
@@ -64,6 +65,20 @@ function FileTreeItemMenuItems() {
isConvertableDoc &&
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: convertToLatex } = useConvertDoc('latex', convertEntityId)
@@ -102,11 +117,11 @@ function FileTreeItemMenuItems() {
</DropdownItem>
</li>
) : null}
{canSetRootDocId ? (
{canShowSetAsMain ? (
<>
<DropdownDivider />
<li role="none">
<DropdownItem onClick={setRootDocId}>
<DropdownItem onClick={handleSetAsMain}>
{t('set_as_main_document')}
</DropdownItem>
</li>
@@ -7,6 +7,7 @@ import { usePermissionsContext } from '@/features/ide-react/context/permissions-
import { ProjectCompiler } from '@ol-types/project-settings'
import { useSetCompilationSettingWithEvent } from '@/features/editor-left-menu/hooks/use-set-compilation-setting'
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
// dispatches the real engine from this extension (.qmd → Quarto, .typ → Typst,
@@ -34,7 +35,7 @@ export default function CompilerSetting() {
const [compilerOptions] = useState(() => getCompilerOptions())
const { t } = useTranslation()
const { write } = usePermissionsContext()
const { docs } = useFileTreeData()
const { docs, fileTreeData } = useFileTreeData()
const changeCompiler = useSetCompilationSettingWithEvent(
'compiler',
setCompiler
@@ -43,14 +44,21 @@ export default function CompilerSetting() {
// 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.
const options = useMemo(() => {
const rootDoc = rootDocId
? docs?.find(doc => doc.doc.id === rootDocId)
: undefined
const extension = rootDoc?.doc.name.split('.').pop()?.toLowerCase()
let rootDocName: string | undefined
if (rootDocId) {
const fromDocs = docs?.find(doc => doc.doc.id === rootDocId)
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
if (!allowed) {
// Unknown / no root file: don't restrict anything.
return compilerOptions
}