diff --git a/services/web/app/src/Features/Project/ProjectRootDocManager.mjs b/services/web/app/src/Features/Project/ProjectRootDocManager.mjs index 59fd4ffcf0..29375590fe 100644 --- a/services/web/app/src/Features/Project/ProjectRootDocManager.mjs +++ b/services/web/app/src/Features/Project/ProjectRootDocManager.mjs @@ -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) { diff --git a/services/web/app/src/Features/Uploads/ProjectUploadManager.mjs b/services/web/app/src/Features/Uploads/ProjectUploadManager.mjs index 35add603e5..8b7a528310 100644 --- a/services/web/app/src/Features/Uploads/ProjectUploadManager.mjs +++ b/services/web/app/src/Features/Uploads/ProjectUploadManager.mjs @@ -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 diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-menu-items.tsx b/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-menu-items.tsx index 80eb8146c5..c014b20da9 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-menu-items.tsx +++ b/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-menu-items.tsx @@ -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() { ) : null} - {canSetRootDocId ? ( + {canShowSetAsMain ? ( <>
  • - + {t('set_as_main_document')}
  • diff --git a/services/web/frontend/js/features/settings/components/compiler-settings/compiler-setting.tsx b/services/web/frontend/js/features/settings/components/compiler-settings/compiler-setting.tsx index 91cb10a50d..c991ec62f3 100644 --- a/services/web/frontend/js/features/settings/components/compiler-settings/compiler-setting.tsx +++ b/services/web/frontend/js/features/settings/components/compiler-settings/compiler-setting.tsx @@ -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 }