fix: export modal path handling (#6949)

* fix: export modal path handling

* fix: prepr

* fix: NEVER_EXPORTABLE_PATH_SUFFIXES list
This commit is contained in:
Calum H.
2026-08-04 15:34:20 +00:00
committed by GitHub
parent af7336fffb
commit 4c3abc62a8
8 changed files with 596 additions and 292 deletions
@@ -11,15 +11,10 @@ import {
useVIntl,
} from '@modrinth/ui'
import { save } from '@tauri-apps/plugin-dialog'
import { readDir, stat } from '@tauri-apps/plugin-fs'
import { ref } from 'vue'
import { ref, shallowRef } from 'vue'
import { PackageIcon } from '@/assets/icons'
import {
export_instance_mrpack,
get_full_path,
get_pack_export_candidates,
} from '@/helpers/instance'
import { export_instance_mrpack, get_pack_export_candidates } from '@/helpers/instance'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
@@ -65,30 +60,24 @@ const exportModal = ref(null)
const nameInput = ref(props.instance.name)
const exportDescription = ref('')
const versionInput = ref('1.0.0')
const files = ref([])
const selectedFilePaths = ref([])
const files = shallowRef([])
const includedFilePaths = ref([])
const excludedFilePaths = ref([])
const fileTreeKey = ref(0)
const filesLoadId = ref(0)
const instanceRoot = ref('')
const loadedDirectories = ref(new Set())
const directoryEntries = new Map()
const currentDirectory = ref('')
async function initFiles() {
const loadId = ++filesLoadId.value
const [filePaths, root] = await Promise.all([
get_pack_export_candidates(props.instance.id),
get_full_path(props.instance.id),
])
if (loadId !== filesLoadId.value) return
instanceRoot.value = root
const exportCandidates = await Promise.all(
filePaths.map((path) => buildExportCandidateItem(root, path)),
)
const exportCandidates = await get_pack_export_candidates(props.instance.id)
if (loadId !== filesLoadId.value) return
files.value = exportCandidates
selectedFilePaths.value = files.value
.filter((file) => !file.disabled && isDefaultSelectedExportCandidate(file.path))
directoryEntries.set('', exportCandidates)
currentDirectory.value = ''
includedFilePaths.value = files.value
.filter((file) => !file.disabled && file.defaultSelected)
.map((file) => file.path)
}
@@ -107,7 +96,8 @@ const exportPack = async () => {
export_instance_mrpack(
props.instance.id,
outputPath,
selectedFilePaths.value,
includedFilePaths.value,
excludedFilePaths.value,
versionInput.value,
exportDescription.value,
nameInput.value,
@@ -121,115 +111,45 @@ function resetExportState() {
exportDescription.value = ''
versionInput.value = '1.0.0'
files.value = []
selectedFilePaths.value = []
includedFilePaths.value = []
excludedFilePaths.value = []
fileTreeKey.value += 1
instanceRoot.value = ''
loadedDirectories.value = new Set()
directoryEntries.clear()
currentDirectory.value = ''
}
async function loadExportDirectory(path) {
if (!path || !instanceRoot.value || loadedDirectories.value.has(path)) return
const normalizedPath = normalizeExportPath(path)
currentDirectory.value = normalizedPath
const cachedEntries = directoryEntries.get(normalizedPath)
if (cachedEntries) {
files.value = cachedEntries
return
}
const loadId = filesLoadId.value
loadedDirectories.value.add(path)
files.value = []
try {
const entries = await readDir(`${instanceRoot.value}/${path}`)
const childItems = await Promise.all(
entries.map((entry) => buildExportDirectoryChildItem(instanceRoot.value, path, entry)),
const childItems = await get_pack_export_candidates(
props.instance.id,
normalizedPath || undefined,
)
if (loadId !== filesLoadId.value) return
appendExportItems(childItems)
} catch {
loadedDirectories.value.delete(path)
}
}
async function buildExportCandidateItem(instanceRoot, path) {
try {
const entries = await readDir(`${instanceRoot}/${path}`)
const metadata = await getExportCandidateMetadata(instanceRoot, path)
return {
path,
type: 'directory',
disabled: isExportCandidateDisabled(path),
modified: metadata.modified,
count: entries.length,
directoryEntries.set(normalizedPath, childItems)
if (currentDirectory.value === normalizedPath) {
files.value = childItems
}
} catch {
return buildExportFileItem(instanceRoot, path)
}
}
async function buildExportDirectoryChildItem(instanceRoot, parentPath, entry) {
const path = `${parentPath}/${entry.name}`
if (entry.isDirectory) {
const metadata = await getExportCandidateMetadata(instanceRoot, path)
return {
path,
type: 'directory',
disabled: isExportCandidateDisabled(path),
modified: metadata.modified,
}
}
return buildExportFileItem(instanceRoot, path)
}
async function buildExportFileItem(instanceRoot, path) {
const metadata = await getExportCandidateMetadata(instanceRoot, path)
return {
path,
type: 'file',
disabled: isExportCandidateDisabled(path),
size: metadata.size,
modified: metadata.modified,
}
}
function appendExportItems(items) {
const nextFiles = new Map(files.value.map((file) => [normalizeExportPath(file.path), file]))
for (const item of items) {
nextFiles.set(normalizeExportPath(item.path), item)
}
files.value = [...nextFiles.values()]
}
async function getExportCandidateMetadata(instanceRoot, path) {
try {
const metadata = await stat(`${instanceRoot}/${path}`)
return {
size: metadata.size,
modified: metadata.mtime ? Math.floor(metadata.mtime.getTime() / 1000) : undefined,
}
} catch {
return {}
if (currentDirectory.value === normalizedPath) files.value = []
}
}
function normalizeExportPath(path) {
return path.replaceAll('\\', '/').split('/').filter(Boolean).join('/')
}
function isDefaultSelectedExportCandidate(path) {
return (
path.startsWith('mods') ||
path.startsWith('datapacks') ||
path.startsWith('resourcepacks') ||
path.startsWith('shaderpacks') ||
path.startsWith('config')
)
}
function isExportCandidateDisabled(path) {
return (
path === 'profile.json' ||
path.startsWith('modrinth_logs') ||
path.startsWith('.fabric') ||
path.startsWith('__MACOSX')
)
}
</script>
<template>
@@ -278,9 +198,11 @@ function isExportCandidateDisabled(path) {
</div>
<FileTreeSelect
:key="fileTreeKey"
v-model="selectedFilePaths"
v-model="includedFilePaths"
v-model:excluded-paths="excludedFilePaths"
class="min-w-0"
:items="files"
lazy
@navigate="loadExportDirectory"
/>
</div>
@@ -28,7 +28,8 @@
<div class="flex min-w-0 flex-col gap-3 pt-4">
<div ref="configFileTreeContainer" class="max-h-[292px] overflow-y-auto rounded-[20px]">
<FileTreeSelect
v-model="selectedConfigPaths"
v-model="includedConfigPaths"
v-model:excluded-paths="excludedConfigPaths"
:items="configFileItems"
:show-size="false"
:show-modified="false"
@@ -79,11 +80,20 @@ const publishReviewModal = ref<InstanceType<typeof ContentDiffModal>>()
const configFileTreeContainer = ref<HTMLElement>()
const publishDiffs = ref<ContentDiffItem[]>([])
const configFilePaths = ref<string[]>([])
const selectedConfigPaths = ref<string[]>([])
const includedConfigPaths = ref<string[]>([])
const excludedConfigPaths = ref<string[]>([])
const state = ref<SharedInstancePublishState>('idle')
const configFileItems = computed<FileTreeSelectItem[]>(() =>
configFilePaths.value.map((path) => ({ path, type: 'file' })),
)
const selectedConfigPaths = computed(() => {
const includedPaths = new Set(includedConfigPaths.value)
const excludedPaths = new Set(excludedConfigPaths.value)
return configFilePaths.value.filter((path) =>
isConfigPathSelected(path, includedPaths, excludedPaths),
)
})
async function show(e?: MouseEvent) {
if (state.value !== 'idle') return
@@ -108,7 +118,8 @@ async function show(e?: MouseEvent) {
disabled: diff.disabled,
}))
configFilePaths.value = preview.configFiles
selectedConfigPaths.value = []
includedConfigPaths.value = []
excludedConfigPaths.value = []
if (!publishReviewModal.value) return
publishReviewModal.value.show(e)
@@ -134,6 +145,23 @@ async function publishChanges() {
}
}
function isConfigPathSelected(
path: string,
includedPaths: Set<string>,
excludedPaths: Set<string>,
) {
let selected = false
let prefix = ''
for (const segment of path.split('/').filter(Boolean)) {
prefix = prefix ? `${prefix}/${segment}` : segment
if (includedPaths.has(prefix)) selected = true
if (excludedPaths.has(prefix)) selected = false
}
return selected
}
function scrollConfigFileTreeToTop() {
if (configFileTreeContainer.value) {
configFileTreeContainer.value.scrollTop = 0
+23 -11
View File
@@ -282,12 +282,13 @@ export async function update_repair_modrinth(instanceId: string): Promise<Instal
}
// Export an instance to .mrpack
// included_overrides is an array of paths to override folders to include (ie: 'mods', 'resource_packs')
// included_overrides and excluded_overrides are inherited path rules for files in the export.
// Version id is optional (ie: 1.1.5)
export async function export_instance_mrpack(
instanceId: string,
exportLocation: string,
includedOverrides: string[],
excludedOverrides: string[],
versionId?: string,
description?: string,
name?: string,
@@ -296,22 +297,33 @@ export async function export_instance_mrpack(
instanceId,
exportLocation,
includedOverrides,
excludedOverrides,
versionId,
description,
name,
})
}
// Given a folder path, populate an array of all the subfolders
// Intended to be used for finding potential override folders
// profile
// -- mods
// -- resourcepacks
// -- file1
// => [mods, resourcepacks]
// allows selection for 'included_overrides' in export_instance_mrpack
export async function get_pack_export_candidates(instanceId: string): Promise<string[]> {
return await invoke('plugin:instance|instance_get_pack_export_candidates', { instanceId })
export type PackExportCandidate = {
path: string
type: 'directory' | 'file'
size?: number
modified?: number
count?: number
disabled: boolean
defaultSelected: boolean
}
// Given a folder path, populate an array of exportable direct children.
// Allows selection for 'included_overrides' in export_instance_mrpack.
export async function get_pack_export_candidates(
instanceId: string,
parent?: string,
): Promise<PackExportCandidate[]> {
return await invoke('plugin:instance|instance_get_pack_export_candidates', {
instanceId,
parent: parent ?? null,
})
}
// Run Minecraft using an instance