Compare commits

...
Author SHA1 Message Date
Calum H. (IMB11) 4bafc951af fix: mrpack export candidates 2026-07-01 17:20:27 +01:00
6 changed files with 216 additions and 140 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 { 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()
@@ -69,26 +64,16 @@ const files = ref([])
const selectedFilePaths = ref([])
const fileTreeKey = ref(0)
const filesLoadId = ref(0)
const instanceRoot = ref('')
const loadedDirectories = ref(new Set())
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))
.filter((file) => !file.disabled && file.defaultSelected)
.map((file) => file.path)
}
@@ -123,68 +108,32 @@ function resetExportState() {
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 normalizedPath = normalizeExportPath(path)
if (!normalizedPath) return
if (loadedDirectories.value.has(normalizedPath)) {
replaceSelectedDirectoryWithLoadedChildren(
normalizedPath,
getLoadedDirectoryChildren(normalizedPath),
)
return
}
const loadId = filesLoadId.value
loadedDirectories.value.add(path)
loadedDirectories.value.add(normalizedPath)
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)
if (loadId !== filesLoadId.value) return
appendExportItems(childItems)
replaceSelectedDirectoryWithLoadedChildren(normalizedPath, 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,
loadedDirectories.value.delete(normalizedPath)
}
}
@@ -196,40 +145,34 @@ function appendExportItems(items) {
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,
function getLoadedDirectoryChildren(path) {
const normalizedPath = normalizeExportPath(path)
const prefix = `${normalizedPath}/`
return files.value.filter((item) => {
const itemPath = normalizeExportPath(item.path)
if (!itemPath.startsWith(prefix)) return false
return itemPath.slice(prefix.length).split('/').filter(Boolean).length === 1
})
}
function replaceSelectedDirectoryWithLoadedChildren(path, items) {
const nextSelectedPaths = new Set(selectedFilePaths.value.map(normalizeExportPath))
if (!nextSelectedPaths.delete(normalizeExportPath(path))) return
for (const item of items) {
if (item && !item.disabled) {
nextSelectedPaths.add(normalizeExportPath(item.path))
}
} catch {
return {}
}
selectedFilePaths.value = [...nextSelectedPaths]
}
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>
+20 -10
View File
@@ -297,16 +297,26 @@ export async function export_instance_mrpack(
})
}
// 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
+7 -2
View File
@@ -699,8 +699,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]
+2 -1
View File
@@ -16,7 +16,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::install::get_optimal_jre_key;
@@ -13,11 +13,56 @@ use crate::util::io::{self, IOError};
use async_zip::tokio::write::ZipFileWriter;
use async_zip::{Compression, ZipEntryBuilder};
use path_util::SafeRelativeUtf8UnixPathBuf;
use serde::Serialize;
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::UNIX_EPOCH;
use tokio::fs::File;
use tokio::io::AsyncReadExt;
const DEFAULT_SELECTED_EXPORT_PATH_PREFIXES: &[&str] =
&["mods", "datapacks", "resourcepacks", "shaderpacks", "config"];
const NEVER_EXPORTABLE_PATH_PREFIXES: &[&str] = &[
"profile.json",
"modrinth_logs",
"mods/.connector",
".sable/natives",
"local/crash_assistant",
"mods/mcef-libraries",
"mods/mcef-cache",
"config/super_resolution/libraries",
"config/Veinminer/update",
"config/epicfight/native",
"essential",
".mixin.out",
".fabric",
"__MACOSX",
];
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PackExportCandidate {
pub path: SafeRelativeUtf8UnixPathBuf,
#[serde(rename = "type")]
pub kind: PackExportCandidateType,
#[serde(skip_serializing_if = "Option::is_none")]
pub size: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub modified: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub count: Option<usize>,
pub disabled: bool,
pub default_selected: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum PackExportCandidateType {
Directory,
File,
}
#[tracing::instrument(skip_all)]
pub async fn export_mrpack(
instance_id: &str,
@@ -37,13 +82,9 @@ pub async fn export_mrpack(
})?;
let included_export_candidates = included_export_candidates
.into_iter()
.filter(|x| {
if let Some(f) = PathBuf::from(x).file_name()
&& f.to_string_lossy().starts_with(".DS_Store")
{
return false;
}
true
.filter_map(|path| SafeRelativeUtf8UnixPathBuf::try_from(path).ok())
.filter(|path| {
!path.as_str().is_empty() && is_path_exportable(path)
})
.collect::<Vec<_>>();
@@ -56,10 +97,11 @@ pub async fn export_mrpack(
let mut packfile =
create_mrpack_json(&metadata, version_id, description).await?;
packfile.files.retain(|f| {
is_export_candidate_included(
f.path.as_str(),
&included_export_candidates,
)
is_path_exportable(&f.path)
&& is_export_candidate_included(
&f.path,
&included_export_candidates,
)
});
let mut path_list = Vec::new();
@@ -79,8 +121,9 @@ 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)
|| !is_path_exportable(&relative_path)
|| !is_export_candidate_included(
relative_path.as_str(),
&relative_path,
&included_export_candidates,
)
{
@@ -113,10 +156,14 @@ pub async fn export_mrpack(
}
fn is_export_candidate_included(
path: &str,
included_export_candidates: &[String],
path: &SafeRelativeUtf8UnixPathBuf,
included_export_candidates: &[SafeRelativeUtf8UnixPathBuf],
) -> bool {
let path = path.as_str();
included_export_candidates.iter().any(|candidate| {
let candidate = candidate.as_str();
path == candidate
|| path
.strip_prefix(candidate)
@@ -124,38 +171,107 @@ fn is_export_candidate_included(
})
}
fn is_path_exportable(relative_path: &SafeRelativeUtf8UnixPathBuf) -> bool {
let path = relative_path.as_str();
if path.ends_with(".DS_Store") {
return false;
}
!NEVER_EXPORTABLE_PATH_PREFIXES.iter().any(|prefix| {
path == *prefix
|| path
.strip_prefix(prefix)
.is_some_and(|suffix| suffix.starts_with('/'))
})
}
#[tracing::instrument]
pub async fn get_pack_export_candidates(
instance_id: &str,
) -> crate::Result<Vec<SafeRelativeUtf8UnixPathBuf>> {
) -> crate::Result<Vec<PackExportCandidate>> {
get_pack_export_candidates_for_parent(instance_id, None).await
}
#[tracing::instrument]
pub async fn get_pack_export_candidates_for_parent(
instance_id: &str,
parent: Option<SafeRelativeUtf8UnixPathBuf>,
) -> crate::Result<Vec<PackExportCandidate>> {
let mut path_list = Vec::new();
let instance_base_dir = get_full_path(instance_id).await?;
let mut read_dir = io::read_dir(&instance_base_dir).await?;
let parent_dir = if let Some(parent) = parent {
if parent.as_str().is_empty() || !is_path_exportable(&parent) {
return Ok(path_list);
}
instance_base_dir.join(parent.as_str())
} else {
instance_base_dir.clone()
};
let mut read_dir = io::read_dir(&parent_dir).await?;
while let Some(entry) = read_dir
.next_entry()
.await
.map_err(|e| IOError::with_path(e, &instance_base_dir))?
.map_err(|e| IOError::with_path(e, &parent_dir))?
{
let path = entry.path();
if path.is_dir() {
let mut read_dir = io::read_dir(&path).await?;
while let Some(entry) = read_dir
.next_entry()
.await
.map_err(|e| IOError::with_path(e, &instance_base_dir))?
{
path_list.push(pack_get_relative_path(
&instance_base_dir,
&entry.path(),
)?);
}
} else {
path_list.push(pack_get_relative_path(&instance_base_dir, &path)?);
if let Some(candidate) =
build_pack_export_candidate(&instance_base_dir, &path).await?
{
path_list.push(candidate);
}
}
Ok(path_list)
}
async fn build_pack_export_candidate(
instance_base_dir: &PathBuf,
path: &PathBuf,
) -> crate::Result<Option<PackExportCandidate>> {
let relative_path = pack_get_relative_path(instance_base_dir, path)?;
if !is_path_exportable(&relative_path) {
return Ok(None);
}
let metadata = io::metadata(path).await?;
let kind = if metadata.is_dir() {
PackExportCandidateType::Directory
} else {
PackExportCandidateType::File
};
let size = metadata.is_file().then_some(metadata.len());
let modified = metadata
.modified()
.ok()
.and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_secs());
let default_selected = is_default_selected_export_candidate(&relative_path);
Ok(Some(PackExportCandidate {
path: relative_path,
kind,
size,
modified,
count: None,
disabled: false,
default_selected,
}))
}
fn is_default_selected_export_candidate(
relative_path: &SafeRelativeUtf8UnixPathBuf,
) -> bool {
let path = relative_path.as_str();
DEFAULT_SELECTED_EXPORT_PATH_PREFIXES.iter().any(|prefix| {
path == *prefix
|| path
.strip_prefix(prefix)
.is_some_and(|suffix| suffix.starts_with('/'))
})
}
fn pack_get_relative_path(
instance_path: &PathBuf,
path: &PathBuf,
@@ -505,7 +505,7 @@ function buildDirectoryEntry(
indeterminate: !selected && selectedCount > 0 && selectedCount < descendants.length,
disabled: item?.disabled ?? descendants.length === 0,
modified: item?.modified ?? getLatestModified(descendants),
count: item?.count ?? getFolderChildCount(path),
count: item?.count ?? (item ? undefined : getFolderChildCount(path)),
item,
}
}
@@ -590,6 +590,7 @@ function toggleAllVisible(selected: boolean) {
function formatSize(entry: FileTreeSelectEntry) {
if (entry.type === 'directory') {
if (entry.count === undefined) return ''
return formatMessage(messages.itemCount, { count: entry.count ?? 0 })
}