Compare commits

..
Author SHA1 Message Date
tdgao cd71b6298b feat: add tooltip for unknown dependents 2026-06-30 10:15:09 -06:00
Calum H.andGitHub 364fed52dd feat: changelog (#6563) 2026-06-30 01:57:11 +02:00
Calum H.andGitHub 0fe695721a fix: try fix app freezing on instance page load (#6562)
* fix: app freezing fix

* fix: fmt
2026-06-30 01:55:55 +02:00
Calum H.andGitHub 692f22b749 feat: changelog 0.15.3 (#6560)
feat: changlog 0.15.3
2026-06-30 00:54:16 +02:00
Calum H.andGitHub ed7792097d fix: better handling of larger instances for ExportModal (#6559) 2026-06-30 00:51:25 +02:00
Prospector c56ce6c9ee changelog 2026-06-29 13:45:04 -07:00
Calum H.andGitHub 91bc58b8ee fix: cleanup stale db entry on rename instance file (#6549)
fix: race on renaming instance file
2026-06-29 17:36:46 +00:00
Truman GaoandGitHub 38d757b66a fix: analytics colour pick is not excluding unknown (#6552)
* fix: analytics colour picking not excluding unknown

* feat: improve colours, better accessibility
2026-06-29 17:17:03 +00:00
Calum H.andGitHub 862b54f00d refactor: instance export modal (#6543)
* refactor: export modal w the design system

* fix: lint

* fix: rev

* fix: lint

* fix: qa
2026-06-29 17:01:00 +00:00
Calum H.andGitHub c2f4835fe7 fix: spacing + friends (#6550) 2026-06-29 17:00:59 +00:00
ThatGravyBoatandGitHub b97c191d75 fix(docs): create version endpoint missing file_types field (#6551) 2026-06-29 16:22:08 +00:00
46 changed files with 1102 additions and 379 deletions
@@ -1,28 +1,32 @@
<script setup>
import { WrenchIcon, XIcon } from '@modrinth/assets'
import { XIcon } from '@modrinth/assets'
import {
Accordion,
ButtonStyled,
Checkbox,
commonMessages,
defineMessages,
FileTreeSelect,
injectNotificationManager,
NewModal,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { save } from '@tauri-apps/plugin-dialog'
import { readDir, stat } from '@tauri-apps/plugin-fs'
import { ref } from 'vue'
import { PackageIcon, VersionIcon } from '@/assets/icons'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { export_instance_mrpack, get_pack_export_candidates } from '@/helpers/instance'
import { PackageIcon } from '@/assets/icons'
import {
export_instance_mrpack,
get_full_path,
get_pack_export_candidates,
} from '@/helpers/instance'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
header: { id: 'app.export-modal.header', defaultMessage: 'Export modpack' },
modpackNameLabel: { id: 'app.export-modal.modpack-name-label', defaultMessage: 'Modpack Name' },
modpackNameLabel: { id: 'app.export-modal.modpack-name-label', defaultMessage: 'Modpack name' },
modpackNamePlaceholder: {
id: 'app.export-modal.modpack-name-placeholder',
defaultMessage: 'Modpack name',
@@ -39,15 +43,7 @@ const messages = defineMessages({
id: 'app.export-modal.description-placeholder',
defaultMessage: 'Enter modpack description...',
},
selectFilesLabel: {
id: 'app.export-modal.select-files-label',
defaultMessage: 'Configure which files are included in this export',
},
exportButton: { id: 'app.export-modal.export-button', defaultMessage: 'Export' },
includeFile: {
id: 'app.export-modal.include-file-accessibility-label',
defaultMessage: 'Include "{file}"?',
},
})
const props = defineProps({
@@ -59,8 +55,9 @@ const props = defineProps({
defineExpose({
show: () => {
resetExportState()
exportModal.value.show()
initFiles()
void initFiles().catch(handleError)
},
})
@@ -69,62 +66,33 @@ const nameInput = ref(props.instance.name)
const exportDescription = ref('')
const versionInput = ref('1.0.0')
const files = ref([])
const folders = ref([])
const selectedFilePaths = ref([])
const fileTreeKey = ref(0)
const filesLoadId = ref(0)
const instanceRoot = ref('')
const loadedDirectories = ref(new Set())
const initFiles = async () => {
const newFolders = new Map()
const sep = '/'
files.value = []
await get_pack_export_candidates(props.instance.id).then((filePaths) =>
filePaths
.map((folder) => ({
path: folder,
name: folder.split(sep).pop(),
selected:
folder.startsWith('mods') ||
folder.startsWith('datapacks') ||
folder.startsWith('resourcepacks') ||
folder.startsWith('shaderpacks') ||
folder.startsWith('config'),
disabled:
folder === 'profile.json' ||
folder.startsWith('modrinth_logs') ||
folder.startsWith('.fabric') ||
folder.startsWith('__MACOSX'),
}))
.forEach((pathData) => {
const parent = pathData.path.split(sep).slice(0, -1).join(sep)
if (parent !== '') {
if (newFolders.has(parent)) {
newFolders.get(parent).push(pathData)
} else {
newFolders.set(parent, [pathData])
}
} else {
files.value.push(pathData)
}
}),
)
folders.value = [...newFolders.entries()].map(([name, value]) => [
{
name,
showingMore: false,
},
value,
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)),
)
if (loadId !== filesLoadId.value) return
files.value = exportCandidates
selectedFilePaths.value = files.value
.filter((file) => !file.disabled && isDefaultSelectedExportCandidate(file.path))
.map((file) => file.path)
}
await initFiles()
const exportPack = async () => {
const filesToExport = files.value.filter((file) => file.selected).map((file) => file.path)
folders.value.forEach((args) => {
args[1].forEach((child) => {
if (child.selected) {
filesToExport.push(child.path)
}
})
})
const outputPath = await save({
defaultPath: `${nameInput.value} ${versionInput.value}.mrpack`,
filters: [
@@ -139,7 +107,7 @@ const exportPack = async () => {
export_instance_mrpack(
props.instance.id,
outputPath,
filesToExport,
selectedFilePaths.value,
versionInput.value,
exportDescription.value,
nameInput.value,
@@ -147,94 +115,176 @@ const exportPack = async () => {
exportModal.value.hide()
}
}
function resetExportState() {
nameInput.value = props.instance.name
exportDescription.value = ''
versionInput.value = '1.0.0'
files.value = []
selectedFilePaths.value = []
fileTreeKey.value += 1
instanceRoot.value = ''
loadedDirectories.value = new Set()
}
async function loadExportDirectory(path) {
if (!path || !instanceRoot.value || loadedDirectories.value.has(path)) return
const loadId = filesLoadId.value
loadedDirectories.value.add(path)
try {
const entries = await readDir(`${instanceRoot.value}/${path}`)
const childItems = await Promise.all(
entries.map((entry) => buildExportDirectoryChildItem(instanceRoot.value, path, entry)),
)
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,
}
} 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 {}
}
}
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>
<ModalWrapper ref="exportModal" :header="formatMessage(messages.header)">
<div class="flex flex-col gap-4 w-[40rem]">
<NewModal
ref="exportModal"
:header="formatMessage(messages.header)"
scrollable
width="46rem"
max-width="calc(100vw - 2rem)"
>
<div class="flex flex-col gap-4">
<div class="grid grid-cols-2 gap-4">
<div class="labeled_input">
<p>{{ formatMessage(messages.modpackNameLabel) }}</p>
<div class="labeled_input w-full">
<p class="text-contrast font-semibold">{{ formatMessage(messages.modpackNameLabel) }}</p>
<StyledInput
v-model="nameInput"
:icon="PackageIcon"
type="text"
:placeholder="formatMessage(messages.modpackNamePlaceholder)"
clearable
wrapper-class="w-full"
/>
</div>
<div class="labeled_input">
<p>{{ formatMessage(messages.versionNumberLabel) }}</p>
<div class="labeled_input w-full">
<p class="text-contrast font-semibold">
{{ formatMessage(messages.versionNumberLabel) }}
</p>
<StyledInput
v-model="versionInput"
:icon="VersionIcon"
type="text"
:placeholder="formatMessage(messages.versionNumberPlaceholder)"
clearable
wrapper-class="w-full"
/>
</div>
</div>
<div class="flex flex-col gap-2">
<p class="m-0">{{ formatMessage(commonMessages.descriptionLabel) }}</p>
<div class="flex flex-col gap-2 min-w-0">
<p class="m-0 text-contrast font-semibold">
{{ formatMessage(commonMessages.descriptionLabel) }}
</p>
<StyledInput
v-model="exportDescription"
multiline
:placeholder="formatMessage(messages.descriptionPlaceholder)"
wrapper-class="w-full"
/>
</div>
<Accordion
class="w-full bg-surface-4 border border-solid border-surface-5 rounded-2xl overflow-clip"
button-class="p-4 w-full border-b border-solid border-b-surface-5 bg-surface-2 -mb-px hover:brightness-[--hover-brightness] group"
>
<template #title>
<span class="flex items-center gap-3 text-contrast group-active:scale-[0.98]">
<WrenchIcon aria-hidden="true" class="size-5 text-secondary" />
Configure which files are included in this export
</span>
</template>
<div class="flex flex-col [&>*:nth-child(even)]:bg-surface-3">
<div v-for="[path, children] in folders" :key="path.name" class="flex flex-col">
<Accordion
class="flex flex-col"
button-class="flex gap-3 pr-4 hover:bg-surface-5 group"
>
<template #title>
<Checkbox
:model-value="children.every((child) => child.selected)"
:indeterminate="
!children.every((child) => child.selected) &&
children.some((child) => child.selected)
"
:description="formatMessage(messages.includeFile, { file: path.name })"
class="pl-4 py-2"
:disabled="children.every((x) => x.disabled)"
@update:model-value="
(newValue) => children.forEach((child) => (child.selected = newValue))
"
@click.stop
/>
<span class="ml-2 group-active:scale-95">{{ path.name }}/</span>
</template>
<div v-for="child in children" :key="child.path">
<Checkbox
v-model="child.selected"
:label="child.name"
class="w-full px-8 py-2 hover:bg-surface-4 text-primary"
:disabled="child.disabled"
/>
</div>
</Accordion>
</div>
<Checkbox
v-for="file in files"
:key="file.path"
v-model="file.selected"
:label="file.name"
:disabled="file.disabled"
class="w-full px-4 py-2 hover:bg-surface-4 text-primary"
/>
</div>
</Accordion>
<FileTreeSelect
:key="fileTreeKey"
v-model="selectedFilePaths"
class="min-w-0"
:items="files"
@navigate="loadExportDirectory"
/>
</div>
<template #actions>
<div class="flex items-center justify-end gap-2">
<ButtonStyled type="outlined">
<button @click="exportModal.hide">
@@ -249,6 +299,6 @@ const exportPack = async () => {
</button>
</ButtonStyled>
</div>
</div>
</ModalWrapper>
</template>
</NewModal>
</template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { MailIcon, SendIcon, UserIcon, UserPlusIcon, XIcon } from '@modrinth/assets'
import { MailIcon, SearchIcon, SendIcon, UserIcon, UserPlusIcon, XIcon } from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
@@ -301,11 +301,12 @@ const messages = defineMessages({
</ButtonStyled>
<StyledInput
v-model="search"
:icon="SearchIcon"
type="text"
:placeholder="formatMessage(messages.searchFriends)"
clearable
variant="outlined"
wrapper-class="flex-1"
input-class="!bg-transparent !border !border-solid !border-button-bg !text-primary !placeholder:text-primary"
wrapper-class="flex-1 [&>svg]:!text-primary [&>svg]:!opacity-100"
@keyup.esc="search = ''"
/>
</template>
@@ -122,9 +122,6 @@
"app.export-modal.modpack-name-placeholder": {
"message": "إسم حزمة التعديل"
},
"app.export-modal.select-files-label": {
"message": "أختيار الملفات التي سيتم تصديرها"
},
"app.export-modal.version-number-label": {
"message": "رقم الإصدار"
},
@@ -203,18 +203,12 @@
"app.export-modal.header": {
"message": "Exportovat modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Zahrnout \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Název modpacku"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Název modpacku"
},
"app.export-modal.select-files-label": {
"message": "Nastavte, které soubory mají být do tohoto exportu zahrnuty"
},
"app.export-modal.version-number-label": {
"message": "Číslo verze"
},
@@ -164,18 +164,12 @@
"app.export-modal.header": {
"message": "Eksporter modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Inkludere \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Modpack Navn"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Modpack navn"
},
"app.export-modal.select-files-label": {
"message": "Konfigurer hvilke filer inkludere i denne eksport"
},
"app.export-modal.version-number-label": {
"message": "Versionsnummer"
},
@@ -203,18 +203,12 @@
"app.export-modal.header": {
"message": "Modpack exportieren"
},
"app.export-modal.include-file-accessibility-label": {
"message": "\"{file}\" einschliessen?"
},
"app.export-modal.modpack-name-label": {
"message": "Modpaketname"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Modpaketname"
},
"app.export-modal.select-files-label": {
"message": "Konfiguriere, welche Dateien in diesem Export miteinbezogen werden"
},
"app.export-modal.version-number-label": {
"message": "Versionsnummer"
},
@@ -203,18 +203,12 @@
"app.export-modal.header": {
"message": "Modpack exportieren"
},
"app.export-modal.include-file-accessibility-label": {
"message": "\"{file}\" einschließen?"
},
"app.export-modal.modpack-name-label": {
"message": "Modpackname"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Modpackname"
},
"app.export-modal.select-files-label": {
"message": "Konfiguriere, welche Dateien in diesen Export enthalten sind"
},
"app.export-modal.version-number-label": {
"message": "Versionsnummer"
},
@@ -203,18 +203,12 @@
"app.export-modal.header": {
"message": "Export modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Include \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Modpack Name"
"message": "Modpack name"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Modpack name"
},
"app.export-modal.select-files-label": {
"message": "Configure which files are included in this export"
},
"app.export-modal.version-number-label": {
"message": "Version number"
},
@@ -203,18 +203,12 @@
"app.export-modal.header": {
"message": "Exportar modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "¿Incluir \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Nombre del modpack"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Nombre del modpack"
},
"app.export-modal.select-files-label": {
"message": "Configura que archivos incluir en esta exportación"
},
"app.export-modal.version-number-label": {
"message": "Número de la versión"
},
@@ -182,18 +182,12 @@
"app.export-modal.header": {
"message": "Exportar modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "¿Incluir \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Nombre del modpack"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Nombre del modpack"
},
"app.export-modal.select-files-label": {
"message": "Configura que archivos se incluirán en esta instancia"
},
"app.export-modal.version-number-label": {
"message": "Número de versión"
},
@@ -203,18 +203,12 @@
"app.export-modal.header": {
"message": "Vie modipaketti"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Sisällytä \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Modipaketin nimi"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Modipaketin nimi"
},
"app.export-modal.select-files-label": {
"message": "Muuta mitkä tiedostot sisältyvät tähän vientiin"
},
"app.export-modal.version-number-label": {
"message": "Versio numero"
},
@@ -164,18 +164,12 @@
"app.export-modal.header": {
"message": "Iluwas ang modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Salihin ang \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Pangalan ng Modpack"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Pangalan ng modpack"
},
"app.export-modal.select-files-label": {
"message": "Isaayos kung anong mga file ang isasali sa pagluwas"
},
"app.export-modal.version-number-label": {
"message": "Numero ng bersiyon"
},
@@ -203,18 +203,12 @@
"app.export-modal.header": {
"message": "Exporter le modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Inclure « {file} » ?"
},
"app.export-modal.modpack-name-label": {
"message": "Nom du modpack"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Nom du modpack"
},
"app.export-modal.select-files-label": {
"message": "Configurez quels fichiers sont inclus dans cette exportation"
},
"app.export-modal.version-number-label": {
"message": "Numéro de version"
},
@@ -176,18 +176,12 @@
"app.export-modal.header": {
"message": "Modcsomag exportálása"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Tartalmaz „{file}” fájlokat?"
},
"app.export-modal.modpack-name-label": {
"message": "A modcsomag neve"
},
"app.export-modal.modpack-name-placeholder": {
"message": "A modcsomag neve"
},
"app.export-modal.select-files-label": {
"message": "Állítsd be, hogy mely fájlok kerüljenek bele az exportba"
},
"app.export-modal.version-number-label": {
"message": "Verziószám"
},
@@ -170,18 +170,12 @@
"app.export-modal.header": {
"message": "Ekspor paket mod"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Sertakan \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Nama Paket Mod"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Nama paket mod"
},
"app.export-modal.select-files-label": {
"message": "Konfigurasikan berkas mana yang disertakan dalam pengeksporan ini"
},
"app.export-modal.version-number-label": {
"message": "Nomor versi"
},
@@ -203,18 +203,12 @@
"app.export-modal.header": {
"message": "Esporta pacchetto"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Includere \"{file}\" nell'esportazione?"
},
"app.export-modal.modpack-name-label": {
"message": "Nome del pacchetto"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Nome del pacchetto"
},
"app.export-modal.select-files-label": {
"message": "Seleziona i file da includere in questa esportazione"
},
"app.export-modal.version-number-label": {
"message": "Numero di versione"
},
@@ -164,18 +164,12 @@
"app.export-modal.header": {
"message": "모드팩 내보내기"
},
"app.export-modal.include-file-accessibility-label": {
"message": "\"{file}\"(을)를 포함할까요?"
},
"app.export-modal.modpack-name-label": {
"message": "모드팩 이름"
},
"app.export-modal.modpack-name-placeholder": {
"message": "모드팩 이름"
},
"app.export-modal.select-files-label": {
"message": "내보내기에 어느 파일이 포함될지 구성"
},
"app.export-modal.version-number-label": {
"message": "버전 구분"
},
@@ -155,18 +155,12 @@
"app.export-modal.header": {
"message": "Eksport pek mod"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Sertakan \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Nama Pek Mod"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Nama pek mod"
},
"app.export-modal.select-files-label": {
"message": "Konfigurasikan fail yang disertakan dalam eksport ini"
},
"app.export-modal.version-number-label": {
"message": "Nombor versi"
},
@@ -170,18 +170,12 @@
"app.export-modal.header": {
"message": "Exporteer modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Betrek \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Modpack Naam"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Modpack naam"
},
"app.export-modal.select-files-label": {
"message": "Configureer welke bestanden bij deze export zijn betrokken"
},
"app.export-modal.version-number-label": {
"message": "Versie nummer"
},
@@ -203,18 +203,12 @@
"app.export-modal.header": {
"message": "Eksportuj paczkę modów"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Dołączyć \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Nazwa paczki modów"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Nazwa paczki modów"
},
"app.export-modal.select-files-label": {
"message": "Skonfiguruje jakie pliki zostaną załączone w tym eksporcie"
},
"app.export-modal.version-number-label": {
"message": "Numer wersji"
},
@@ -203,18 +203,12 @@
"app.export-modal.header": {
"message": "Exportar pacote de mods"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Incluir \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Nome do pacote de mods"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Nome do pacote de mods"
},
"app.export-modal.select-files-label": {
"message": "Configure quais arquivos serão incluídos na exportação"
},
"app.export-modal.version-number-label": {
"message": "Número da versão"
},
@@ -203,18 +203,12 @@
"app.export-modal.header": {
"message": "Экспорт сборки"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Добавить «{file}» в экспорт?"
},
"app.export-modal.modpack-name-label": {
"message": "Название сборки"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Название сборки"
},
"app.export-modal.select-files-label": {
"message": "Выберите файлы для экспорта"
},
"app.export-modal.version-number-label": {
"message": "Номер версии"
},
@@ -203,18 +203,12 @@
"app.export-modal.header": {
"message": "Exportera modpaket"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Inkludera \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Modpaketets namn"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Modpaketets namn"
},
"app.export-modal.select-files-label": {
"message": "Konfigurera vilka filer som inkluderas i denna export"
},
"app.export-modal.version-number-label": {
"message": "Versionsnummer"
},
@@ -170,18 +170,12 @@
"app.export-modal.header": {
"message": "ส่งออกแพ็กม็อด"
},
"app.export-modal.include-file-accessibility-label": {
"message": "รวมถึง \"{file}\" ใช่หรือไม่"
},
"app.export-modal.modpack-name-label": {
"message": "ชื่อแพ็กม็อด"
},
"app.export-modal.modpack-name-placeholder": {
"message": "ชื่อแพ็กม็อด"
},
"app.export-modal.select-files-label": {
"message": "กำหนดไฟล์ที่ต้องการจะส่งออก"
},
"app.export-modal.version-number-label": {
"message": "หมายเลขเวอร์ชัน"
},
@@ -203,18 +203,12 @@
"app.export-modal.header": {
"message": "Mod paketini dışa aktar"
},
"app.export-modal.include-file-accessibility-label": {
"message": "\"{file}\" dahil mi?"
},
"app.export-modal.modpack-name-label": {
"message": "Mod Paketi Adı"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Mod paketi adı"
},
"app.export-modal.select-files-label": {
"message": "Bu dışa aktarmaya hangi dosyaların dahil edileceğini yapılandırma"
},
"app.export-modal.version-number-label": {
"message": "Sürüm numarası"
},
@@ -170,18 +170,12 @@
"app.export-modal.header": {
"message": "Експортувати збірку"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Уключити «{file}»?"
},
"app.export-modal.modpack-name-label": {
"message": "Назва збірки"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Назва збірки"
},
"app.export-modal.select-files-label": {
"message": "Змініть файли, які додані до експорту"
},
"app.export-modal.version-number-label": {
"message": "Номер версії"
},
@@ -170,18 +170,12 @@
"app.export-modal.header": {
"message": "Xuất modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Bao gồm \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Tên modpack"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Tên modpack"
},
"app.export-modal.select-files-label": {
"message": "Cấu hình các tệp nào được bao gồm trong quá trình xuất phiên bản này"
},
"app.export-modal.version-number-label": {
"message": "Phiên bản"
},
@@ -203,18 +203,12 @@
"app.export-modal.header": {
"message": "导出整合包"
},
"app.export-modal.include-file-accessibility-label": {
"message": "包含 \"{file}\""
},
"app.export-modal.modpack-name-label": {
"message": "整合包名称"
},
"app.export-modal.modpack-name-placeholder": {
"message": "整合包名称"
},
"app.export-modal.select-files-label": {
"message": "配置此导出中包含哪些文件"
},
"app.export-modal.version-number-label": {
"message": "版本号"
},
@@ -203,18 +203,12 @@
"app.export-modal.header": {
"message": "匯出模組包"
},
"app.export-modal.include-file-accessibility-label": {
"message": "要包含「{file}」嗎?"
},
"app.export-modal.modpack-name-label": {
"message": "模組包名稱"
},
"app.export-modal.modpack-name-placeholder": {
"message": "模組包名稱"
},
"app.export-modal.select-files-label": {
"message": "設定要包含在此匯出檔案中的檔案"
},
"app.export-modal.version-number-label": {
"message": "版本號碼"
},
@@ -676,7 +676,7 @@ const handleOptionsClick = async (args: { option: string; item: unknown }) => {
break
case 'copy_path': {
if (instance.value) {
const fullPath = await get_full_path(instance.value?.path)
const fullPath = await get_full_path(instance.value.id)
await navigator.clipboard.writeText(fullPath)
}
break
@@ -1443,7 +1443,7 @@ onMounted(() => {
props.instance &&
event.instance_id === props.instance.id &&
event.event === 'synced' &&
props.instance.install_stage !== 'pack_installing' &&
props.instance.install_stage === 'installed' &&
!isBulkOperating.value
) {
await initProjects()
+6 -1
View File
@@ -414,7 +414,7 @@ components:
- unknown
- signature
example: required-resource-pack
# https://github.com/modrinth/labrinth/blob/master/src/routes/version_creation.rs#L27-L57
# https://github.com/modrinth/code/blob/main/apps/labrinth/src/routes/v2/version_creation.rs#L32-L76
CreatableVersion:
allOf:
- $ref: '#/components/schemas/BaseVersion'
@@ -446,6 +446,11 @@ components:
- client_or_server_prefers_both
- unknown
description: The environment that this version is for.
file_types:
type: object
additionalProperties:
$ref: '#/components/schemas/FileTypeEnum'
description: A map of file parts to their associated file type, a file type is used for additional files such as sources jars.
required:
- file_parts
- project_id
@@ -41,38 +41,38 @@ export const VISIBLE_PROJECT_STATUS_CHANGE_EVENT_STATUS_SET =
export const LIGHT_LEGEND_PALETTE = [
'hsl(152, 100%, 34%)',
'hsl(26, 100%, 42%)',
'hsl(202, 100%, 35%)',
'hsl(327, 45%, 64%)',
'hsl(41, 100%, 45%)',
'hsl(250, 60%, 33%)',
'hsl(170, 43%, 47%)',
'hsl(330, 60%, 33%)',
'hsl(46, 100%, 36%)',
'hsl(167, 100%, 30%)',
'hsl(343, 38%, 45%)',
'hsl(222, 100%, 28%)',
'hsl(270, 62%, 60%)',
'hsl(32, 100%, 37%)',
'hsl(349, 57%, 51%)',
'hsl(191, 43%, 37%)',
'hsl(41, 79%, 46%)',
'hsl(203, 76%, 64%)',
'hsl(0, 93%, 62%)',
'hsl(143, 66%, 29%)',
'hsl(58, 89%, 25%)',
'hsl(311, 64%, 49%)',
'hsl(198, 91%, 32%)',
'hsl(12, 88%, 27%)',
'hsl(236, 61%, 60%)',
'hsl(102, 59%, 74%)',
'hsl(293, 76%, 79%)',
'hsl(67, 99%, 41%)',
'hsl(179, 100%, 50%)',
'hsl(102, 100%, 61%)',
'hsl(0, 100%, 32%)',
]
export const DARK_LEGEND_PALETTE = [
'hsl(145, 78%, 48%)',
'hsl(41, 100%, 50%)',
'hsl(202, 77%, 63%)',
'hsl(323, 66%, 72%)',
'hsl(56, 85%, 60%)',
'hsl(255, 92%, 80%)',
'hsl(12, 100%, 67%)',
'hsl(176, 58%, 56%)',
'hsl(60, 100%, 41%)',
'hsl(165, 80%, 38%)',
'hsl(341, 36%, 56%)',
'hsl(226, 60%, 49%)',
'hsl(252, 53%, 62%)',
'hsl(75, 59%, 50%)',
'hsl(195, 56%, 42%)',
'hsl(30, 59%, 56%)',
'hsl(41, 79%, 46%)',
'hsl(203, 76%, 64%)',
'hsl(0, 93%, 62%)',
'hsl(143, 66%, 29%)',
'hsl(58, 94%, 45%)',
'hsl(311, 64%, 49%)',
'hsl(198, 91%, 32%)',
'hsl(12, 88%, 27%)',
'hsl(236, 61%, 60%)',
'hsl(102, 59%, 74%)',
'hsl(293, 76%, 79%)',
'hsl(61, 92%, 33%)',
'hsl(179, 100%, 50%)',
'hsl(102, 100%, 61%)',
'hsl(0, 100%, 32%)',
]
@@ -288,6 +288,7 @@ type PaletteRankEntry = {
key: string
label: string
total: number
excludedFromRank?: boolean
}
function formatDatasetTooltip(projectName: string | undefined): string | undefined {
@@ -330,9 +331,12 @@ function buildPaletteColorsByDownloadRank(
const colorsByKey = new Map<string, string>()
if (palette.length === 0) return colorsByKey
const sortedEntries = [...entries].sort(
(a, b) => b.total - a.total || a.label.localeCompare(b.label) || a.key.localeCompare(b.key),
)
const compareEntries = (a: PaletteRankEntry, b: PaletteRankEntry) =>
b.total - a.total || a.label.localeCompare(b.label) || a.key.localeCompare(b.key)
const rankedEntries = entries.filter((entry) => !entry.excludedFromRank).sort(compareEntries)
const excludedEntries = entries.filter((entry) => entry.excludedFromRank).sort(compareEntries)
const sortedEntries = [...rankedEntries, ...excludedEntries]
sortedEntries.forEach((entry, index) => {
colorsByKey.set(entry.key, getPaletteColorForIndex(index, palette))
})
@@ -340,6 +344,27 @@ function buildPaletteColorsByDownloadRank(
return colorsByKey
}
function isExcludedFromPaletteRank(breakdownValues: readonly string[]): boolean {
return breakdownValues.some(
(value) =>
isUnknownAnalyticsBreakdownValue(value) || isNoDependentAnalyticsBreakdownValue(value),
)
}
function buildPaletteRankEntry(
key: string,
breakdownValues: readonly string[],
total: number,
formatLabel: (breakdownValues: readonly string[]) => string,
): PaletteRankEntry {
return {
key,
label: formatLabel(breakdownValues),
total,
excludedFromRank: isExcludedFromPaletteRank(breakdownValues),
}
}
export function getMetricValue(
point: Labrinth.Analytics.v3.ProjectAnalytics,
activeStat: AnalyticsDashboardStat,
@@ -499,11 +524,14 @@ export function buildChartDatasets(
})
const colorsByBreakdown = buildPaletteColorsByDownloadRank(
Array.from(dataByBreakdown.keys()).map((breakdownKey) => ({
key: breakdownKey,
label: formatChartBreakdownLabels(breakdownValuesByKey.get(breakdownKey) ?? []),
total: downloadTotalsByBreakdown.get(breakdownKey) ?? 0,
})),
Array.from(dataByBreakdown.keys()).map((breakdownKey) =>
buildPaletteRankEntry(
breakdownKey,
breakdownValuesByKey.get(breakdownKey) ?? [],
downloadTotalsByBreakdown.get(breakdownKey) ?? 0,
formatChartBreakdownLabels,
),
),
palette,
)
@@ -538,7 +566,7 @@ export function buildChartDatasets(
? isNoDependentAnalyticsBreakdownValue(dependentProjectId)
? formatMessage(analyticsMessages.noDependentTooltip)
: isUnknownAnalyticsBreakdownValue(dependentProjectId)
? formatMessage(analyticsMessages.unknown)
? formatMessage(analyticsMessages.unknownDependentTooltip)
: formatDependentProjectDatasetTooltip(
versionName,
dependentProjectName,
@@ -675,11 +703,14 @@ export function buildChartDatasets(
})
const colorsByBreakdown = buildPaletteColorsByDownloadRank(
Array.from(dataByProjectBreakdown.keys()).map((breakdownKey) => ({
key: breakdownKey,
label: formatChartBreakdownLabels(breakdownValuesByKey.get(breakdownKey) ?? []),
total: downloadTotalsByProjectBreakdown.get(breakdownKey) ?? 0,
})),
Array.from(dataByProjectBreakdown.keys()).map((breakdownKey) =>
buildPaletteRankEntry(
breakdownKey,
breakdownValuesByKey.get(breakdownKey) ?? [],
downloadTotalsByProjectBreakdown.get(breakdownKey) ?? 0,
formatChartBreakdownLabels,
),
),
palette,
)
@@ -92,6 +92,11 @@ export const analyticsMessages = defineMessages({
id: 'analytics.value.no-dependent-tooltip',
defaultMessage: 'Downloaded for reasons other than being a dependency',
},
unknownDependentTooltip: {
id: 'analytics.value.unknown-dependent-tooltip',
defaultMessage:
"There's no metadata to determine which dependent project this download attributes to.",
},
other: {
id: 'analytics.value.other',
defaultMessage: 'Other',
@@ -515,7 +515,7 @@ function getDependentProjectTooltip(row: AnalyticsTableRow) {
return formatMessage(analyticsMessages.noDependentTooltip)
}
if (isUnknownAnalyticsBreakdownValue(row.breakdownValues.dependent_project_download)) {
return formatMessage(analyticsMessages.unknown)
return formatMessage(analyticsMessages.unknownDependentTooltip)
}
const dependencyProjectIds = new Set(row.dependentOnProjectIds)
@@ -518,6 +518,9 @@
"analytics.value.unknown": {
"message": "Unknown"
},
"analytics.value.unknown-dependent-tooltip": {
"message": "There's no metadata to determine which dependent project this download attributes to."
},
"analytics.value.unmonetized": {
"message": "Unmonetized"
},
@@ -13,8 +13,7 @@ use crate::util::io::{self, IOError};
use async_zip::tokio::write::ZipFileWriter;
use async_zip::{Compression, ZipEntryBuilder};
use path_util::SafeRelativeUtf8UnixPathBuf;
use std::collections::{HashMap, HashSet};
use std::iter::FromIterator;
use std::collections::HashMap;
use std::path::PathBuf;
use tokio::fs::File;
use tokio::io::AsyncReadExt;
@@ -56,12 +55,12 @@ pub async fn export_mrpack(
let version_id = version_id.unwrap_or("1.0.0".to_string());
let mut packfile =
create_mrpack_json(&metadata, version_id, description).await?;
let included_candidates_set = HashSet::<_>::from_iter(
included_export_candidates.iter().map(|x| x.as_str()),
);
packfile
.files
.retain(|f| included_candidates_set.contains(f.path.as_str()));
packfile.files.retain(|f| {
is_export_candidate_included(
f.path.as_str(),
&included_export_candidates,
)
});
let mut path_list = Vec::new();
add_all_recursive_folder_paths(&instance_base_path, &mut path_list).await?;
@@ -80,9 +79,10 @@ pub async fn export_mrpack(
let relative_path = pack_get_relative_path(&instance_base_path, &path)?;
if packfile.files.iter().any(|f| f.path == relative_path)
|| !included_candidates_set
.iter()
.any(|x| relative_path.starts_with(&**x))
|| !is_export_candidate_included(
relative_path.as_str(),
&included_export_candidates,
)
{
continue;
}
@@ -112,6 +112,18 @@ pub async fn export_mrpack(
Ok(())
}
fn is_export_candidate_included(
path: &str,
included_export_candidates: &[String],
) -> bool {
included_export_candidates.iter().any(|candidate| {
path == candidate
|| path
.strip_prefix(candidate)
.is_some_and(|suffix| suffix.starts_with('/'))
})
}
#[tracing::instrument]
pub async fn get_pack_export_candidates(
instance_id: &str,
@@ -209,29 +209,18 @@ pub(crate) async fn list_content(
.await?;
let imported_modpack_scope = is_imported_modpack_scope(&link);
let linked_modpack_source_kind = linked_modpack_source_kind(&link);
let mut failed_modpack_identifier_lookup = false;
let modpack_ids = if imported_modpack_scope {
None
} else {
match linked_modpack_ids(&link) {
Some((_, version_id)) => match get_modpack_identifiers(
&version_id,
&resolved.content_set,
&state.pool,
&state.api_semaphore,
)
.await
{
Ok(ids) => Some(ids),
Err(err) => {
tracing::warn!(
"Failed to fetch modpack identifiers: {}",
err
);
failed_modpack_identifier_lookup = true;
None
}
},
Some((_, version_id)) => {
get_cached_modpack_identifiers(
&version_id,
&state.pool,
&state.api_semaphore,
)
.await?
}
None => None,
}
};
@@ -243,10 +232,9 @@ pub(crate) async fn list_content(
}
} else if let Some(ids) = modpack_ids.as_ref() {
ContentFilter::ExcludeModpack(ids)
} else if failed_modpack_identifier_lookup {
} else if let Some(source_kind) = linked_modpack_source_kind {
ContentFilter::ExcludeSourceKind {
source_kind: linked_modpack_source_kind
.unwrap_or(ContentSourceKind::ModrinthModpack),
source_kind,
exclude_untracked: true,
}
} else {
@@ -1182,6 +1170,28 @@ impl ModpackIdentifiers {
}
}
async fn get_cached_modpack_identifiers(
version_id: &str,
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
) -> crate::Result<Option<ModpackIdentifiers>> {
let Some(cached) =
CachedEntry::get_modpack_files(version_id, pool, fetch_semaphore)
.await?
else {
return Ok(None);
};
if cached.project_ids.is_empty() {
return Ok(None);
}
Ok(Some(ModpackIdentifiers {
hashes: cached.file_hashes.into_iter().collect(),
project_ids: cached.project_ids.into_iter().collect(),
}))
}
async fn get_modpack_identifiers(
version_id: &str,
content_set: &ContentSet,
+49
View File
@@ -10,6 +10,55 @@ export type VersionEntry = {
}
const VERSIONS: VersionEntry[] = [
{
date: `2026-06-29T23:56:37+00:00`,
product: 'app',
version: '0.15.4',
body: `## Fixed
- Fixes another issue causing the app to freeze up when going into an instance page.`,
},
{
date: `2026-06-29T22:52:04+00:00`,
product: 'app',
version: '0.15.3',
body: `## Fixed
- Fixed hanging on larger legacy instances when visiting the Instance page.`,
},
{
date: `2026-06-29T20:44:35+00:00`,
product: 'web',
body: `## Changed
- Updated analytics graph colors to be more accessible.
- Updated project versions table UI and mobile view so columns overflow nicely.
- Updates projects page in dashboard to use new table and action bar.
- Updated translations`,
},
{
date: `2026-06-29T20:44:35+00:00`,
product: 'app',
version: '0.15.2',
body: `## Added
- Added the ["Chaos Cubed"](https://minecraft.wiki/w/Chaos_Cubed_(skin_pack)) official skin pack to the Skin selector page.
## Changed
- Redesigned the modpack export modal to align with the rest of the instance pages.
- Updated project versions table UI and mobile view so columns overflow nicely.
- Updated translations
## Fixed
- Fixed an issue where sometimes the app would desynchronise from the file system when disabling, enabling or removing mods from the Content tab.
- Fixed issue where server pinging in the Worlds tab of the instance page would be stuck in a loading state for too long.
- Fixed the Logs page in Modrinth App overflowing past the window instead of keeping the console contained.
- Fixed the Modrinth Hosting server panel in Modrinth App overflowing past the window instead of keeping the console contained.
- Fixed issue where sometimes disabling linked modpack content would not work. Thanks [@creeperkatze](@creeperkatze)!
- Fixed issue where unlinking a locally imported mrpack from an instance causes the content to never show up in the content list.`,
},
{
date: `2026-06-29T20:44:35+00:00`,
product: 'hosting',
body: `## Changed
- Updated translations`,
},
{
date: `2026-06-26T16:33:23+00:00`,
product: 'app',
@@ -0,0 +1,604 @@
<template>
<div class="flex w-full min-w-0 flex-col gap-2">
<div
class="flex w-full min-w-0 flex-col rounded-[20px] border border-solid border-surface-4 shadow-sm overflow-clip"
>
<div
class="flex h-10 w-full min-w-0 select-none flex-row items-center justify-between bg-surface-3 px-3 text-sm font-medium"
>
<div class="flex min-w-0 flex-1 items-center gap-2">
<Checkbox
:model-value="allVisibleSelected"
:indeterminate="someVisibleSelected && !allVisibleSelected"
:disabled="visibleSelectableEntries.length === 0"
@update:model-value="toggleAllVisible"
/>
<button
type="button"
class="flex min-w-0 appearance-none items-center gap-1.5 border-0 bg-transparent p-0 font-semibold hover:text-primary"
:class="sortField === 'name' ? 'text-contrast' : 'text-secondary'"
@click="handleSort('name')"
>
<span class="min-w-0 truncate">{{ formatMessage(messages.name) }}</span>
<ChevronUpIcon
v-if="sortField === 'name' && !sortDesc"
class="size-4 shrink-0"
aria-hidden="true"
/>
<ChevronDownIcon
v-if="sortField === 'name' && sortDesc"
class="size-4 shrink-0"
aria-hidden="true"
/>
</button>
</div>
<div class="ml-2 flex shrink-0 items-center gap-4">
<button
type="button"
class="hidden w-[92px] appearance-none items-center gap-1 border-0 bg-transparent p-0 text-left font-semibold hover:text-primary sm:flex"
:class="sortField === 'size' ? 'text-contrast' : 'text-secondary'"
@click="handleSort('size')"
>
<span>{{ formatMessage(messages.size) }}</span>
<ChevronUpIcon
v-if="sortField === 'size' && !sortDesc"
class="size-4 shrink-0"
aria-hidden="true"
/>
<ChevronDownIcon
v-if="sortField === 'size' && sortDesc"
class="size-4 shrink-0"
aria-hidden="true"
/>
</button>
<button
type="button"
class="hidden w-[132px] appearance-none items-center gap-1 border-0 bg-transparent p-0 text-left font-semibold hover:text-primary sm:flex"
:class="sortField === 'modified' ? 'text-contrast' : 'text-secondary'"
@click="handleSort('modified')"
>
<span>{{ formatMessage(messages.modified) }}</span>
<ChevronUpIcon
v-if="sortField === 'modified' && !sortDesc"
class="size-4 shrink-0"
aria-hidden="true"
/>
<ChevronDownIcon
v-if="sortField === 'modified' && sortDesc"
class="size-4 shrink-0"
aria-hidden="true"
/>
</button>
<span class="size-4 shrink-0" aria-hidden="true" />
</div>
</div>
<div
v-if="!isHomePath"
role="button"
tabindex="0"
class="group flex w-full min-w-0 cursor-pointer select-none items-center gap-2 border-0 border-t border-solid border-surface-4 bg-surface-2 px-3 py-2 text-left hover:bg-surface-2.5 focus:!outline-none"
@click="navigateTo(parentPath)"
@keydown="(event) => event.key === 'Enter' && navigateTo(parentPath)"
>
<span class="size-5 shrink-0" aria-hidden="true" />
<div class="flex size-4 shrink-0 items-center justify-center text-secondary">
<UndoIcon class="size-4 group-hover:text-contrast group-focus:text-contrast" />
</div>
<span
class="min-w-0 flex-1 truncate text-sm font-medium text-primary group-hover:text-contrast group-focus:text-contrast"
>
{{ formatMessage(messages.parentFolder) }}
</span>
<div class="ml-2 flex shrink-0 items-center gap-4">
<span class="hidden w-[92px] text-left text-sm text-secondary sm:block" />
<span class="hidden w-[132px] text-left text-sm text-secondary sm:block" />
<span class="size-4 shrink-0" aria-hidden="true" />
</div>
</div>
<div
v-if="entries.length === 0"
class="flex items-center gap-2 bg-surface-2 px-3 py-2 text-sm text-secondary"
>
<FileIcon class="size-4 shrink-0" />
<span>{{ formatMessage(messages.emptyFolderTitle) }}</span>
</div>
<div
v-for="(entry, i) in entries"
:key="`${entry.type}:${entry.path}`"
role="button"
tabindex="0"
class="group flex w-full min-w-0 select-none items-center gap-2 border-0 border-t border-solid border-surface-4 px-3 py-2 text-left first:border-t-0 focus:!outline-none"
:class="[
getRowBackgroundClass(i + (isHomePath ? 0 : 1)),
entry.disabled && entry.type === 'file'
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer hover:bg-surface-2.5',
]"
@click="selectEntry(entry)"
@keydown="(event) => event.key === 'Enter' && selectEntry(entry)"
>
<Checkbox
class="shrink-0"
:model-value="entry.checked"
:indeterminate="entry.indeterminate"
:disabled="entry.disabled"
:description="entry.name"
@click.stop
@update:model-value="toggleEntry(entry, $event)"
/>
<div class="flex size-4 shrink-0 items-center justify-center text-secondary">
<component
:is="entry.icon"
class="size-4 group-hover:text-contrast group-focus:text-contrast"
/>
</div>
<span
:ref="(element) => setEntryNameRef(entry.path, element)"
v-tooltip="truncatedTooltip(entryNameRefs[entry.path], entry.name)"
class="min-w-0 flex-1 truncate text-sm font-medium text-primary group-hover:text-contrast group-focus:text-contrast"
>
{{ entry.name }}
</span>
<div class="ml-2 flex shrink-0 items-center gap-4">
<span class="hidden w-[92px] truncate text-left text-sm text-secondary sm:block">
{{ formatSize(entry) }}
</span>
<span class="hidden w-[132px] truncate text-left text-sm text-secondary sm:block">
{{ formatModified(entry) }}
</span>
<ChevronRightIcon
class="size-4 shrink-0 text-secondary group-hover:text-contrast group-focus:text-contrast"
:class="{ invisible: entry.type !== 'directory' }"
aria-hidden="true"
/>
</div>
</div>
<div
v-for="row in fillerRowCount"
:key="`filler:${row}`"
class="flex w-full min-w-0 select-none items-center gap-2 border-0 border-t border-solid border-surface-4 px-3 py-2 text-left"
:class="getRowBackgroundClass(visibleRowCount + row - 1)"
aria-hidden="true"
>
<span class="size-5 shrink-0" />
<span class="size-4 shrink-0" />
<span class="min-w-0 flex-1 truncate text-sm font-medium opacity-0">.</span>
<div class="ml-2 flex shrink-0 items-center gap-4">
<span class="hidden w-[92px] text-left text-sm sm:block" />
<span class="hidden w-[132px] text-left text-sm sm:block" />
<span class="size-4 shrink-0" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
ChevronDownIcon,
ChevronRightIcon,
ChevronUpIcon,
FileIcon,
UndoIcon,
} from '@modrinth/assets'
import { type Component, type ComponentPublicInstance, computed, ref, watch } from 'vue'
import { useFormatBytes } from '../../composables/format-bytes'
import { useFormatDateTime } from '../../composables/format-date-time'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { getDirectoryIcon, getFileIcon } from '../../utils/auto-icons'
import { truncatedTooltip } from '../../utils/truncate'
import Checkbox from './Checkbox.vue'
const { formatMessage } = useVIntl()
const messages = defineMessages({
name: {
id: 'files.table-header.name',
defaultMessage: 'Name',
},
size: {
id: 'files.table-header.size',
defaultMessage: 'Size',
},
modified: {
id: 'files.table-header.modified',
defaultMessage: 'Modified',
},
itemCount: {
id: 'files.row.item-count',
defaultMessage: '{count, plural, one {# item} other {# items}}',
},
parentFolder: {
id: 'files.row.parent-folder',
defaultMessage: 'Parent folder',
},
emptyFolderTitle: {
id: 'files.layout.empty-folder-title',
defaultMessage: 'This folder is empty',
},
})
export type FileTreeSelectItem = {
path: string
type?: 'directory' | 'file'
disabled?: boolean
size?: number
modified?: number
count?: number
}
type NormalizedFileTreeSelectItem = FileTreeSelectItem & {
name: string
normalizedPath: string
}
type FileTreeSelectEntry = {
path: string
name: string
type: 'directory' | 'file'
icon: Component
checked: boolean
indeterminate: boolean
disabled: boolean
size?: number
modified?: number
count?: number
item?: NormalizedFileTreeSelectItem
}
type FileTreeSelectSortField = 'name' | 'size' | 'modified'
const formatBytes = useFormatBytes()
const formatDateTime = useFormatDateTime({
year: '2-digit',
month: '2-digit',
day: '2-digit',
hour: 'numeric',
minute: 'numeric',
})
const props = withDefaults(
defineProps<{
items: FileTreeSelectItem[]
modelValue: string[]
}>(),
{
items: () => [],
modelValue: () => [],
},
)
const emit = defineEmits<{
(e: 'update:modelValue', value: string[]): void
(e: 'navigate', path: string): void
}>()
const currentPath = ref('')
const sortField = ref<FileTreeSelectSortField>('name')
const sortDesc = ref(false)
const entryNameRefs = ref<Record<string, HTMLElement | null>>({})
const isHomePath = computed(() => currentPath.value === '')
const parentPath = computed(() => currentPath.value.split('/').slice(0, -1).join('/'))
const initialMinimumRowCount = ref(0)
const normalizedItems = computed(() => {
const items = new Map<string, NormalizedFileTreeSelectItem>()
for (const item of props.items) {
const normalizedPath = normalizePath(item.path)
if (!normalizedPath) continue
items.set(normalizedPath, {
...item,
path: item.path,
name: getName(normalizedPath),
normalizedPath,
})
}
return [...items.values()]
})
const selectedPaths = computed(() => new Set(props.modelValue.map((path) => normalizePath(path))))
const folderPaths = computed(() => {
const paths = new Set<string>()
for (const item of normalizedItems.value) {
const segments = item.normalizedPath.split('/')
for (let i = 1; i < segments.length; i++) {
paths.add(segments.slice(0, i).join('/'))
}
if (item.type === 'directory') {
paths.add(item.normalizedPath)
}
}
return paths
})
const entries = computed<FileTreeSelectEntry[]>(() => {
const directories = new Map<string, FileTreeSelectEntry>()
const files: FileTreeSelectEntry[] = []
const currentSegments = currentPath.value ? currentPath.value.split('/') : []
for (const item of normalizedItems.value) {
const segments = item.normalizedPath.split('/')
if (!isInCurrentPath(segments, currentSegments)) continue
const remaining = segments.slice(currentSegments.length)
if (remaining.length > 1) {
const directoryName = remaining[0]
const directoryPath = [...currentSegments, directoryName].join('/')
if (!directories.has(directoryPath)) {
directories.set(directoryPath, buildDirectoryEntry(directoryPath, directoryName))
}
} else if (remaining.length === 1) {
if (item.type === 'directory') {
directories.set(
item.normalizedPath,
buildDirectoryEntry(item.normalizedPath, item.name, item),
)
} else {
files.push(buildFileEntry(item))
}
}
}
return [...directories.values(), ...files].sort(compareEntries)
})
const visibleSelectableEntries = computed(() => entries.value.filter((entry) => !entry.disabled))
const visibleSelectablePaths = computed(() => {
const paths = new Set<string>()
for (const entry of visibleSelectableEntries.value) {
for (const path of getEntrySelectablePaths(entry)) {
paths.add(path)
}
}
return [...paths]
})
const allVisibleSelected = computed(
() =>
visibleSelectablePaths.value.length > 0 &&
visibleSelectablePaths.value.every((path) => selectedPaths.value.has(path)),
)
const someVisibleSelected = computed(() =>
visibleSelectablePaths.value.some((path) => selectedPaths.value.has(path)),
)
const visibleRowCount = computed(
() => entries.value.length + (isHomePath.value ? 0 : 1) + (entries.value.length === 0 ? 1 : 0),
)
const fillerRowCount = computed(() =>
Math.max(0, initialMinimumRowCount.value - visibleRowCount.value),
)
watch(
normalizedItems,
() => {
if (currentPath.value && !folderPaths.value.has(currentPath.value)) {
currentPath.value = ''
}
},
{ immediate: true },
)
watch(
visibleRowCount,
(rowCount) => {
if (isHomePath.value && rowCount > 1 && initialMinimumRowCount.value === 0) {
initialMinimumRowCount.value = rowCount
}
},
{ immediate: true },
)
watch(
isHomePath,
() => {
if (isHomePath.value && visibleRowCount.value > 1 && initialMinimumRowCount.value === 0) {
initialMinimumRowCount.value = visibleRowCount.value
}
},
{ flush: 'post' },
)
function normalizePath(path: string) {
return path.replaceAll('\\', '/').split('/').filter(Boolean).join('/')
}
function getName(path: string) {
return path.split('/').pop() ?? path
}
function isInCurrentPath(segments: string[], currentSegments: string[]) {
if (segments.length <= currentSegments.length) return false
return currentSegments.every((segment, index) => segments[index] === segment)
}
function handleSort(field: FileTreeSelectSortField) {
if (sortField.value === field) {
sortDesc.value = !sortDesc.value
} else {
sortField.value = field
sortDesc.value = false
}
}
function getRowBackgroundClass(index: number) {
return index % 2 === 0 ? 'bg-surface-2' : 'bg-surface-1.5'
}
function compareEntries(a: FileTreeSelectEntry, b: FileTreeSelectEntry) {
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1
switch (sortField.value) {
case 'modified':
return sortDesc.value
? getModifiedSortValue(a) - getModifiedSortValue(b)
: getModifiedSortValue(b) - getModifiedSortValue(a)
case 'size':
return sortDesc.value
? getSizeSortValue(a) - getSizeSortValue(b)
: getSizeSortValue(b) - getSizeSortValue(a)
default:
return sortDesc.value
? b.name.localeCompare(a.name, undefined, { numeric: true, sensitivity: 'base' })
: a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' })
}
}
function getSizeSortValue(entry: FileTreeSelectEntry) {
return entry.type === 'directory' ? (entry.count ?? 0) : (entry.size ?? 0)
}
function getModifiedSortValue(entry: FileTreeSelectEntry) {
return entry.modified ?? 0
}
function getFolderDescendants(path: string) {
return normalizedItems.value.filter((item) => item.normalizedPath.startsWith(`${path}/`))
}
function getFolderChildCount(path: string) {
const children = new Set<string>()
const prefix = `${path}/`
for (const item of normalizedItems.value) {
if (!item.normalizedPath.startsWith(prefix)) continue
const relativePath = item.normalizedPath.slice(prefix.length)
const [childName] = relativePath.split('/')
if (childName) {
children.add(childName)
}
}
return children.size
}
function getLatestModified(items: NormalizedFileTreeSelectItem[]) {
const modified = items
.map((item) => item.modified)
.filter((value): value is number => typeof value === 'number' && Number.isFinite(value))
if (modified.length === 0) return undefined
return Math.max(...modified)
}
function buildDirectoryEntry(
path: string,
name: string,
item?: NormalizedFileTreeSelectItem,
): FileTreeSelectEntry {
const descendants = getFolderDescendants(path).filter((item) => !item.disabled)
const selectedCount = descendants.filter((item) =>
selectedPaths.value.has(item.normalizedPath),
).length
const selected = selectedPaths.value.has(path)
return {
path,
name,
type: 'directory',
icon: getDirectoryIcon(name),
checked: selected || (descendants.length > 0 && selectedCount === descendants.length),
indeterminate: !selected && selectedCount > 0 && selectedCount < descendants.length,
disabled: item?.disabled ?? descendants.length === 0,
modified: item?.modified ?? getLatestModified(descendants),
count: item?.count ?? getFolderChildCount(path),
item,
}
}
function buildFileEntry(item: NormalizedFileTreeSelectItem): FileTreeSelectEntry {
return {
path: item.normalizedPath,
name: item.name,
type: 'file',
icon: getFileIcon(item.name),
checked: selectedPaths.value.has(item.normalizedPath),
indeterminate: false,
disabled: item.disabled ?? false,
size: item.size,
modified: item.modified,
item,
}
}
function navigateTo(path: string) {
currentPath.value = path
emit('navigate', path)
}
function setEntryNameRef(path: string, element: Element | ComponentPublicInstance | null) {
if (element instanceof HTMLElement) {
entryNameRefs.value[path] = element
} else {
entryNameRefs.value[path] = null
}
}
function selectEntry(entry: FileTreeSelectEntry) {
if (entry.type === 'directory') {
navigateTo(entry.path)
} else {
toggleEntry(entry, !entry.checked)
}
}
function toggleEntry(entry: FileTreeSelectEntry, selected: boolean) {
if (entry.disabled) return
const nextSelectedPaths = new Set(selectedPaths.value)
const paths = getEntrySelectablePaths(entry)
for (const path of paths) {
if (selected) {
nextSelectedPaths.add(path)
} else {
nextSelectedPaths.delete(path)
}
}
emit('update:modelValue', [...nextSelectedPaths])
}
function getEntrySelectablePaths(entry: FileTreeSelectEntry) {
if (entry.type === 'directory') {
return entry.item?.type === 'directory'
? [entry.path]
: getFolderDescendants(entry.path)
.filter((item) => !item.disabled)
.map((item) => item.normalizedPath)
}
return [entry.path]
}
function toggleAllVisible(selected: boolean) {
const nextSelectedPaths = new Set(selectedPaths.value)
for (const path of visibleSelectablePaths.value) {
if (selected) {
nextSelectedPaths.add(path)
} else {
nextSelectedPaths.delete(path)
}
}
emit('update:modelValue', [...nextSelectedPaths])
}
function formatSize(entry: FileTreeSelectEntry) {
if (entry.type === 'directory') {
return formatMessage(messages.itemCount, { count: entry.count ?? 0 })
}
if (entry.size === undefined) return ''
return formatBytes(entry.size)
}
function formatModified(entry: FileTreeSelectEntry) {
if (entry.modified === undefined) return ''
return formatDateTime(new Date(entry.modified * 1000))
}
</script>
+2
View File
@@ -32,6 +32,8 @@ export { default as EmptyState } from './EmptyState.vue'
export { default as EnvironmentIndicator } from './EnvironmentIndicator.vue'
export { default as ErrorInformationCard } from './ErrorInformationCard.vue'
export { default as FileInput } from './FileInput.vue'
export type { FileTreeSelectItem } from './FileTreeSelect.vue'
export { default as FileTreeSelect } from './FileTreeSelect.vue'
export type { FilterBarOption } from './FilterBar.vue'
export { default as FilterBar } from './FilterBar.vue'
export type { FilterPillOption } from './FilterPills.vue'
+13 -1
View File
@@ -126,7 +126,7 @@
<div
v-if="$slots.actions"
:class="{ 'pt-4 border-0 border-t border-solid border-surface-5': actionsDivider }"
class="p-4 pt-0"
class="p-4"
>
<slot name="actions" />
</div>
@@ -292,10 +292,22 @@ function hide() {
}, 300)
}
async function scrollToBottom(behavior: ScrollBehavior = 'smooth') {
await nextTick()
if (!scrollContainer.value) return
scrollContainer.value.scrollTo({
top: scrollContainer.value.scrollHeight,
behavior,
})
requestAnimationFrame(checkScrollState)
}
defineExpose({
show,
hide,
checkScrollState,
scrollToBottom,
})
const mouseX = ref(0)
@@ -154,10 +154,19 @@ onUnmounted(() => {
>
{{ project.title }}
</AutoLink>
<span v-if="project.filename" class="truncate text-secondary mb-2">
<span
v-if="project.filename && (owner || version)"
class="truncate text-secondary mb-2"
>
{{ project.filename }}
</span>
</div>
<div
v-if="project.filename && !(owner || version)"
class="flex min-h-8 min-w-0 items-center text-secondary"
>
<span class="truncate">{{ project.filename }}</span>
</div>
<div
v-if="owner || version"
class="flex flex-nowrap items-center gap-2 overflow-hidden text-secondary"
+3
View File
@@ -1406,6 +1406,9 @@
"files.row.item-count": {
"defaultMessage": "{count, plural, one {# item} other {# items}}"
},
"files.row.parent-folder": {
"defaultMessage": "Parent folder"
},
"files.table-header.created": {
"defaultMessage": "Created"
},
@@ -0,0 +1,86 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { computed, ref } from 'vue'
import FileTreeSelect, { type FileTreeSelectItem } from '../../components/base/FileTreeSelect.vue'
const modified = Math.floor(new Date('2026-06-29T10:30:00Z').getTime() / 1000)
const MODPACK_FILES: FileTreeSelectItem[] = [
{ path: 'config/fabric_loader_dependencies.json', size: 918, modified: modified - 600 },
{ path: 'config/modmenu.json', size: 2401, modified: modified - 7200 },
{ path: 'config/iris.properties', size: 1208, modified: modified - 5400 },
{ path: 'config/crash_assistant/settings.toml', size: 714, modified: modified - 1200 },
{ path: 'config/defaultoptions/options.txt', size: 4820, modified: modified - 3200 },
{ path: 'config/defaultoptions/keybindings.txt', size: 3012, modified: modified - 3300 },
{ path: 'mods/sodium-fabric-0.6.13+mc1.21.6.jar', size: 1290240, modified: modified - 900 },
{ path: 'mods/iris-fabric-1.8.8+mc1.21.6.jar', size: 2782400, modified: modified - 1100 },
{ path: 'mods/modmenu-15.0.0-beta.3.jar', size: 824220, modified: modified - 1800 },
{ path: 'resourcepacks/FreshAnimations_v1.9.3.zip', size: 4382210, modified: modified - 2600 },
{ path: 'resourcepacks/Mod Menu Helper.zip', size: 104522, modified: modified - 2800 },
{
path: 'shaderpacks/ComplementaryUnbound_r5.5.1.zip',
size: 23882210,
modified: modified - 3400,
},
{ path: 'datapacks/terralith.zip', size: 17452213, modified: modified - 4200 },
{ path: 'icon.png', size: 128044, modified: modified - 5000 },
{ path: 'profile.json', size: 928, modified: modified - 400, disabled: true },
{ path: 'modrinth_logs/launcher.log', size: 224018, modified: modified - 100, disabled: true },
]
const meta = {
title: 'Base/FileTreeSelect',
component: FileTreeSelect,
} satisfies Meta<typeof FileTreeSelect>
export default meta
export const ModpackExport: StoryObj = {
render: () => ({
components: { FileTreeSelect },
setup() {
const selected = ref([
'config/fabric_loader_dependencies.json',
'config/crash_assistant/settings.toml',
'config/defaultoptions/options.txt',
'mods/sodium-fabric-0.6.13+mc1.21.6.jar',
'mods/iris-fabric-1.8.8+mc1.21.6.jar',
'resourcepacks/FreshAnimations_v1.9.3.zip',
'shaderpacks/ComplementaryUnbound_r5.5.1.zip',
])
const selectedLabel = computed(() => `${selected.value.length} selected`)
return {
items: MODPACK_FILES,
selected,
selectedLabel,
}
},
template: /*html*/ `
<div class="max-w-2xl">
<FileTreeSelect v-model="selected" :items="items" />
<div class="mt-4 rounded-[20px] bg-surface-3 p-4 text-sm text-secondary">
<div class="font-semibold text-contrast">{{ selectedLabel }}</div>
<div class="mt-2 flex flex-col gap-1">
<span v-for="path in selected" :key="path" class="truncate">{{ path }}</span>
</div>
</div>
</div>
`,
}),
}
export const EmptyRoot: StoryObj = {
render: () => ({
components: { FileTreeSelect },
setup() {
const selected = ref<string[]>([])
return { selected }
},
template: /*html*/ `
<div class="max-w-2xl">
<FileTreeSelect v-model="selected" :items="[]" />
</div>
`,
}),
}