Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 37 additions & 115 deletions apps/app-frontend/src/components/ui/ExportModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
}

Expand All @@ -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,
Expand All @@ -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>
Expand Down Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand Down
34 changes: 23 additions & 11 deletions apps/app-frontend/src/helpers/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
11 changes: 9 additions & 2 deletions apps/app/src/api/instance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -729,6 +729,7 @@ pub async fn instance_export_mrpack(
instance_id: &str,
export_location: PathBuf,
included_overrides: Vec<String>,
excluded_overrides: Vec<String>,
version_id: Option<String>,
description: Option<String>,
name: Option<String>,
Expand All @@ -737,6 +738,7 @@ pub async fn instance_export_mrpack(
instance_id,
export_location,
included_overrides,
excluded_overrides,
version_id,
description,
name,
Expand All @@ -748,8 +750,13 @@ pub async fn instance_export_mrpack(
#[tauri::command]
pub async fn instance_get_pack_export_candidates(
instance_id: &str,
) -> Result<Vec<SafeRelativeUtf8UnixPathBuf>> {
Ok(theseus::instance::get_pack_export_candidates(instance_id).await?)
parent: Option<SafeRelativeUtf8UnixPathBuf>,
) -> Result<Vec<theseus::instance::PackExportCandidate>> {
Ok(theseus::instance::get_pack_export_candidates_for_parent(
instance_id,
parent,
)
.await?)
}

#[tauri::command]
Expand Down
3 changes: 2 additions & 1 deletion packages/app-lib/src/api/instance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ pub use self::content::{
list_content_sets, sync_content_files,
};
pub use self::export_mrpack::{
create_mrpack_json, export_mrpack, get_pack_export_candidates,
PackExportCandidate, create_mrpack_json, export_mrpack,
get_pack_export_candidates, get_pack_export_candidates_for_parent,
};
pub use self::get::{get, get_many, list};
pub use self::icon::edit_icon;
Expand Down
Loading
Loading