feat: set-as-main for .typ/.qmd; fix compiler filter; fix ZIP import compiler
Build and Deploy Verso / deploy (push) Successful in 14m56s
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:
@@ -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
|
||||||
|
|||||||
+21
-6
@@ -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>
|
||||||
|
|||||||
+14
-6
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user